add flutter tests

This commit is contained in:
2026-03-05 18:57:32 +01:00
parent acb2878ed6
commit b2c88dc7cd
10 changed files with 1782 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -0,0 +1,12 @@
/// Reusable UTC date anchors for timeline tests.
///
/// All dates are UTC to avoid timezone flakiness.
/// Domain start: June 1, 2025 00:00 UTC.
final kDomainStart = DateTime.utc(2025, 6, 1);
/// Domain end: July 1, 2025 00:00 UTC (30-day domain).
final kDomainEnd = DateTime.utc(2025, 7, 1);
/// Midpoint anchor: June 15, 2025 12:00 UTC.
final kAnchor = DateTime.utc(2025, 6, 15, 12);

View File

@@ -0,0 +1,102 @@
import 'package:z_timeline/src/models/entry_drag_state.dart';
import 'package:z_timeline/src/models/entry_resize_state.dart';
import 'package:z_timeline/src/models/projected_entry.dart';
import 'package:z_timeline/src/models/timeline_entry.dart';
import 'package:z_timeline/src/models/timeline_group.dart';
import 'test_constants.dart';
/// Creates a [TimelineEntry] with sensible defaults, all fields overridable.
TimelineEntry makeEntry({
String? id,
String? groupId,
DateTime? start,
DateTime? end,
int lane = 1,
bool hasEnd = true,
}) {
return TimelineEntry(
id: id ?? 'entry-1',
groupId: groupId ?? 'group-1',
start: start ?? kAnchor,
end: end ?? kAnchor.add(const Duration(hours: 2)),
lane: lane,
hasEnd: hasEnd,
);
}
/// Creates a [TimelineGroup] with sensible defaults.
TimelineGroup makeGroup({String? id, String? title}) {
return TimelineGroup(id: id ?? 'group-1', title: title ?? 'Group 1');
}
/// Creates a [ProjectedEntry] with entry + normalized positions.
ProjectedEntry makeProjectedEntry({
TimelineEntry? entry,
double startX = 0.0,
double endX = 0.1,
}) {
return ProjectedEntry(
entry: entry ?? makeEntry(),
startX: startX,
endX: endX,
);
}
/// Creates an [EntryDragState] with sensible defaults.
EntryDragState makeDragState({
String? entryId,
TimelineEntry? originalEntry,
String? targetGroupId,
int targetLane = 1,
DateTime? targetStart,
}) {
final entry = originalEntry ?? makeEntry(id: entryId);
return EntryDragState(
entryId: entry.id,
originalEntry: entry,
targetGroupId: targetGroupId ?? entry.groupId,
targetLane: targetLane,
targetStart: targetStart ?? entry.start,
);
}
/// Creates an [EntryResizeState] with sensible defaults.
EntryResizeState makeResizeState({
String? entryId,
TimelineEntry? originalEntry,
ResizeEdge edge = ResizeEdge.end,
DateTime? targetStart,
DateTime? targetEnd,
int targetLane = 1,
}) {
final entry = originalEntry ?? makeEntry(id: entryId);
return EntryResizeState(
entryId: entry.id,
originalEntry: entry,
edge: edge,
targetStart: targetStart ?? entry.start,
targetEnd: targetEnd ?? entry.end,
targetLane: targetLane,
);
}
/// Creates N non-overlapping entries in the same group/lane for collision tests.
///
/// Each entry is 1 hour long, starting 2 hours apart.
List<TimelineEntry> makeEntrySequence(
int count, {
String groupId = 'group-1',
int lane = 1,
}) {
return List.generate(count, (i) {
final start = kAnchor.add(Duration(hours: i * 2));
return makeEntry(
id: 'seq-$i',
groupId: groupId,
lane: lane,
start: start,
end: start.add(const Duration(hours: 1)),
);
});
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/models/interaction_config.dart';
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
import 'package:z_timeline/src/widgets/timeline_scope.dart';
import 'test_constants.dart';
/// Test harness wrapping a child in MaterialApp > Scaffold > ZTimelineScope.
///
/// Provides controllable [TimelineViewportNotifier] and
/// [ZTimelineInteractionConfig].
class TimelineTestHarness extends StatelessWidget {
const TimelineTestHarness({
required this.viewport,
required this.child,
this.config = ZTimelineInteractionConfig.defaults,
super.key,
});
final TimelineViewportNotifier viewport;
final ZTimelineInteractionConfig config;
final Widget child;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ZTimelineScope(
viewport: viewport,
config: config,
child: child,
),
),
);
}
}
/// Convenience extension on [WidgetTester] for pumping a timeline test harness.
extension TimelineTestHarnessExtension on WidgetTester {
/// Pumps a [TimelineTestHarness] with the given child and optional config.
///
/// Returns the [TimelineViewportNotifier] for controlling the viewport.
Future<TimelineViewportNotifier> pumpTimeline(
Widget child, {
TimelineViewportNotifier? viewport,
ZTimelineInteractionConfig config = ZTimelineInteractionConfig.defaults,
}) async {
final vp = viewport ??
TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
await pumpWidget(
TimelineTestHarness(viewport: vp, config: config, child: child),
);
return vp;
}
}

View File

@@ -0,0 +1,256 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/services/entry_placement_service.dart';
import '../helpers/test_constants.dart';
import '../helpers/test_factories.dart';
void main() {
group('EntryPlacementService', () {
group('isPositionAvailable', () {
test('empty list is always available', () {
final result = EntryPlacementService.isPositionAvailable(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
existingEntries: [],
);
expect(result, isTrue);
});
test('overlap in same lane and group is blocked', () {
final existing = makeEntry(
id: 'existing',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.isPositionAvailable(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor.add(const Duration(hours: 1)),
targetEnd: kAnchor.add(const Duration(hours: 3)),
existingEntries: [existing],
);
expect(result, isFalse);
});
test('different lane is available', () {
final existing = makeEntry(
id: 'existing',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.isPositionAvailable(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 2,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 2)),
existingEntries: [existing],
);
expect(result, isTrue);
});
test('different group is available', () {
final existing = makeEntry(
id: 'existing',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.isPositionAvailable(
entryId: 'new',
targetGroupId: 'group-2',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 2)),
existingEntries: [existing],
);
expect(result, isTrue);
});
test('self is excluded from collision', () {
final existing = makeEntry(
id: 'self',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.isPositionAvailable(
entryId: 'self',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 2)),
existingEntries: [existing],
);
expect(result, isTrue);
});
test('touching entries (end == start) do not overlap', () {
final existing = makeEntry(
id: 'existing',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 1)),
);
final result = EntryPlacementService.isPositionAvailable(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor.add(const Duration(hours: 1)),
targetEnd: kAnchor.add(const Duration(hours: 2)),
existingEntries: [existing],
);
// end == start means s1.isBefore(e2) && e1.isAfter(s2) — touching
// is NOT overlapping because e1 == s2 means e1.isAfter(s2) is false
expect(result, isTrue);
});
});
group('findNearestAvailableLane', () {
test('target available returns target', () {
final result = EntryPlacementService.findNearestAvailableLane(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
existingEntries: [],
);
expect(result, 1);
});
test('target blocked finds next available', () {
final existing = makeEntry(
id: 'blocker',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.findNearestAvailableLane(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
existingEntries: [existing],
);
// Lane 1 blocked, tries lane 2 (which is free)
expect(result, 2);
});
test('expanding search: +1, -1, +2, etc.', () {
// Block lanes 3 and 4, target lane 3. Should find lane 2 (3-1).
final entries = [
makeEntry(
id: 'a',
groupId: 'group-1',
lane: 3,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
),
makeEntry(
id: 'b',
groupId: 'group-1',
lane: 4,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
),
];
final result = EntryPlacementService.findNearestAvailableLane(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 3,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
existingEntries: entries,
);
// Lane 3 blocked, tries 4 (blocked), then 2 (free)
expect(result, 2);
});
test('lane 1 minimum enforced (no lane 0)', () {
// Block lanes 1 and 2, target lane 1. Should skip 0 and find lane 3.
final entries = [
makeEntry(
id: 'a',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
),
makeEntry(
id: 'b',
groupId: 'group-1',
lane: 2,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
),
];
final result = EntryPlacementService.findNearestAvailableLane(
entryId: 'new',
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
existingEntries: entries,
);
// Lane 1 blocked, tries 2 (blocked), then 0 (skipped, <1), then 3
expect(result, 3);
});
});
group('resolvePlacement', () {
test('preserves duration', () {
final entry = makeEntry(
start: kAnchor,
end: kAnchor.add(const Duration(hours: 3)),
);
final newStart = kAnchor.add(const Duration(hours: 5));
final result = EntryPlacementService.resolvePlacement(
entry: entry,
targetGroupId: 'group-1',
targetLane: 1,
targetStart: newStart,
existingEntries: [],
);
final expectedEnd = newStart.add(const Duration(hours: 3));
expect(result.end, expectedEnd);
});
test('returns collision-free lane', () {
final existing = makeEntry(
id: 'blocker',
groupId: 'group-1',
lane: 1,
start: kAnchor,
end: kAnchor.add(const Duration(hours: 4)),
);
final entry = makeEntry(
id: 'mover',
start: kAnchor,
end: kAnchor.add(const Duration(hours: 2)),
);
final result = EntryPlacementService.resolvePlacement(
entry: entry,
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
existingEntries: [existing],
);
expect(result.lane, 2);
});
});
});
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/constants.dart';
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
void main() {
group('LayoutCoordinateService', () {
const contentWidth = 1000.0;
const laneHeight = ZTimelineConstants.laneHeight;
group('normalizedToWidgetX / widgetXToNormalized roundtrip', () {
test('0.0 normalized maps to 0.0 widget X', () {
final result = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: 0.0,
contentWidth: contentWidth,
);
expect(result, 0.0);
});
test('1.0 normalized maps to contentWidth', () {
final result = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: 1.0,
contentWidth: contentWidth,
);
expect(result, contentWidth);
});
test('roundtrip normalized → widget → normalized', () {
const normalizedX = 0.35;
final widgetX = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: normalizedX,
contentWidth: contentWidth,
);
final result = LayoutCoordinateService.widgetXToNormalized(
widgetX: widgetX,
contentWidth: contentWidth,
);
expect(result, closeTo(normalizedX, 0.0001));
});
});
group('widgetXToNormalized', () {
test('clamps negative widget X to 0.0', () {
final result = LayoutCoordinateService.widgetXToNormalized(
widgetX: -50.0,
contentWidth: contentWidth,
);
expect(result, 0.0);
});
test('clamps widget X > contentWidth to 1.0', () {
final result = LayoutCoordinateService.widgetXToNormalized(
widgetX: contentWidth + 100,
contentWidth: contentWidth,
);
expect(result, 1.0);
});
test('returns 0.0 when contentWidth is 0', () {
final result = LayoutCoordinateService.widgetXToNormalized(
widgetX: 50.0,
contentWidth: 0.0,
);
expect(result, 0.0);
});
});
group('calculateItemWidth', () {
test('normalized width * content width', () {
final result = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: 0.25,
contentWidth: contentWidth,
);
expect(result, 250.0);
});
test('clamps to 0 for negative input', () {
final result = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: -0.1,
contentWidth: contentWidth,
);
expect(result, 0.0);
});
});
group('laneToY / yToLane', () {
test('lane 1 starts at Y=0', () {
final y = LayoutCoordinateService.laneToY(
lane: 1,
laneHeight: laneHeight,
);
expect(y, 0.0);
});
test('lane 2 includes lane height + spacing', () {
final y = LayoutCoordinateService.laneToY(
lane: 2,
laneHeight: laneHeight,
);
expect(y, laneHeight + ZTimelineConstants.laneVerticalSpacing);
});
test('yToLane roundtrip from laneToY', () {
for (var lane = 1; lane <= 5; lane++) {
final y = LayoutCoordinateService.laneToY(
lane: lane,
laneHeight: laneHeight,
);
// Add a small offset within the lane to test mid-lane Y
final recoveredLane = LayoutCoordinateService.yToLane(
y: y + laneHeight / 2,
laneHeight: laneHeight,
);
expect(recoveredLane, lane);
}
});
test('yToLane at lane boundary returns correct lane', () {
final lane = LayoutCoordinateService.yToLane(
y: 0.0,
laneHeight: laneHeight,
);
expect(lane, 1);
});
});
});
}

View File

@@ -0,0 +1,222 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/services/time_scale_service.dart';
import '../helpers/test_constants.dart';
void main() {
group('TimeScaleService', () {
group('mapTimeToPosition', () {
test('domain start maps to 0.0', () {
final result = TimeScaleService.mapTimeToPosition(
kDomainStart,
kDomainStart,
kDomainEnd,
);
expect(result, 0.0);
});
test('domain end maps to 1.0', () {
final result = TimeScaleService.mapTimeToPosition(
kDomainEnd,
kDomainStart,
kDomainEnd,
);
expect(result, 1.0);
});
test('midpoint maps to ~0.5', () {
final mid = kDomainStart.add(
kDomainEnd.difference(kDomainStart) ~/ 2,
);
final result = TimeScaleService.mapTimeToPosition(
mid,
kDomainStart,
kDomainEnd,
);
expect(result, closeTo(0.5, 0.001));
});
test('before domain returns negative', () {
final before = kDomainStart.subtract(const Duration(days: 1));
final result = TimeScaleService.mapTimeToPosition(
before,
kDomainStart,
kDomainEnd,
);
expect(result, lessThan(0.0));
});
test('after domain returns >1', () {
final after = kDomainEnd.add(const Duration(days: 1));
final result = TimeScaleService.mapTimeToPosition(
after,
kDomainStart,
kDomainEnd,
);
expect(result, greaterThan(1.0));
});
});
group('mapPositionToTime', () {
test('position 0 maps to domain start', () {
final result = TimeScaleService.mapPositionToTime(
0.0,
kDomainStart,
kDomainEnd,
);
expect(
result.millisecondsSinceEpoch,
kDomainStart.millisecondsSinceEpoch,
);
});
test('position 1 maps to domain end', () {
final result = TimeScaleService.mapPositionToTime(
1.0,
kDomainStart,
kDomainEnd,
);
expect(
result.millisecondsSinceEpoch,
kDomainEnd.millisecondsSinceEpoch,
);
});
test('roundtrip with mapTimeToPosition', () {
final time = kAnchor;
final position = TimeScaleService.mapTimeToPosition(
time,
kDomainStart,
kDomainEnd,
);
final result = TimeScaleService.mapPositionToTime(
position,
kDomainStart,
kDomainEnd,
);
// Allow 1ms tolerance for rounding
expect(
result.millisecondsSinceEpoch,
closeTo(time.millisecondsSinceEpoch, 1),
);
});
});
group('domainDuration', () {
test('30-day domain returns correct milliseconds', () {
final result = TimeScaleService.domainDuration(
kDomainStart,
kDomainEnd,
);
// June has 30 days
const expectedMs = 30 * 24 * 60 * 60 * 1000;
expect(result, expectedMs.toDouble());
});
});
group('calculateZoomedDomain', () {
test('factor > 1 shrinks domain (zoom in)', () {
final result = TimeScaleService.calculateZoomedDomain(
kDomainStart,
kDomainEnd,
factor: 2.0,
);
final originalDuration = kDomainEnd.difference(kDomainStart);
final newDuration = result.end.difference(result.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds / 2, 1),
);
});
test('factor < 1 expands domain (zoom out)', () {
final result = TimeScaleService.calculateZoomedDomain(
kDomainStart,
kDomainEnd,
factor: 0.5,
);
final originalDuration = kDomainEnd.difference(kDomainStart);
final newDuration = result.end.difference(result.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds * 2, 1),
);
});
test('factor = 1 leaves domain unchanged', () {
final result = TimeScaleService.calculateZoomedDomain(
kDomainStart,
kDomainEnd,
factor: 1.0,
);
expect(
result.start.millisecondsSinceEpoch,
closeTo(kDomainStart.millisecondsSinceEpoch, 1),
);
expect(
result.end.millisecondsSinceEpoch,
closeTo(kDomainEnd.millisecondsSinceEpoch, 1),
);
});
test('focus position is preserved', () {
const focusPosition = 0.25;
final focusTime = TimeScaleService.mapPositionToTime(
focusPosition,
kDomainStart,
kDomainEnd,
);
final result = TimeScaleService.calculateZoomedDomain(
kDomainStart,
kDomainEnd,
factor: 2.0,
focusPosition: focusPosition,
);
final newFocusPosition = TimeScaleService.mapTimeToPosition(
focusTime,
result.start,
result.end,
);
expect(newFocusPosition, closeTo(focusPosition, 0.001));
});
});
group('calculatePannedDomain', () {
test('positive ratio shifts forward', () {
final result = TimeScaleService.calculatePannedDomain(
kDomainStart,
kDomainEnd,
ratio: 0.1,
);
expect(result.start.isAfter(kDomainStart), isTrue);
expect(result.end.isAfter(kDomainEnd), isTrue);
});
test('negative ratio shifts backward', () {
final result = TimeScaleService.calculatePannedDomain(
kDomainStart,
kDomainEnd,
ratio: -0.1,
);
expect(result.start.isBefore(kDomainStart), isTrue);
expect(result.end.isBefore(kDomainEnd), isTrue);
});
test('duration is preserved', () {
final originalDuration = kDomainEnd.difference(kDomainStart);
final result = TimeScaleService.calculatePannedDomain(
kDomainStart,
kDomainEnd,
ratio: 0.3,
);
final newDuration = result.end.difference(result.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds, 1),
);
});
});
});
}

View File

@@ -0,0 +1,291 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/models/entry_resize_state.dart';
import 'package:z_timeline/src/state/timeline_interaction_notifier.dart';
import '../helpers/test_constants.dart';
import '../helpers/test_factories.dart';
void main() {
group('ZTimelineInteractionNotifier', () {
late ZTimelineInteractionNotifier notifier;
setUp(() {
notifier = ZTimelineInteractionNotifier();
});
tearDown(() {
notifier.dispose();
});
group('initial state', () {
test('all flags are false', () {
expect(notifier.isGrabbing, isFalse);
expect(notifier.isDraggingEntry, isFalse);
expect(notifier.isResizingEntry, isFalse);
expect(notifier.isInteracting, isFalse);
});
test('all states are null', () {
expect(notifier.dragState, isNull);
expect(notifier.resizeState, isNull);
expect(notifier.hoveredEntryId, isNull);
expect(notifier.hoveredPillGlobalRect, isNull);
expect(notifier.interactionGlobalPosition, isNull);
});
});
group('hover', () {
test('setHoveredEntry sets id and rect and notifies', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
const rect = Rect.fromLTWH(10, 20, 100, 30);
notifier.setHoveredEntry('entry-1', rect);
expect(notifier.hoveredEntryId, 'entry-1');
expect(notifier.hoveredPillGlobalRect, rect);
expect(notifyCount, 1);
});
test('duplicate setHoveredEntry is no-op', () {
const rect = Rect.fromLTWH(10, 20, 100, 30);
notifier.setHoveredEntry('entry-1', rect);
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setHoveredEntry('entry-1', rect);
expect(notifyCount, 0);
});
test('clearHoveredEntry clears and notifies', () {
notifier.setHoveredEntry('entry-1', const Rect.fromLTWH(0, 0, 50, 20));
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.clearHoveredEntry();
expect(notifier.hoveredEntryId, isNull);
expect(notifier.hoveredPillGlobalRect, isNull);
expect(notifyCount, 1);
});
test('clearHoveredEntry when already null is no-op', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.clearHoveredEntry();
expect(notifyCount, 0);
});
});
group('grabbing', () {
test('setGrabbing(true) toggles and notifies', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setGrabbing(true);
expect(notifier.isGrabbing, isTrue);
expect(notifyCount, 1);
});
test('setGrabbing(false) toggles and notifies', () {
notifier.setGrabbing(true);
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setGrabbing(false);
expect(notifier.isGrabbing, isFalse);
expect(notifyCount, 1);
});
test('duplicate setGrabbing is no-op', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setGrabbing(false); // already false
expect(notifyCount, 0);
});
});
group('drag lifecycle', () {
test('beginDrag sets dragState and isDraggingEntry and clears hover', () {
notifier.setHoveredEntry('entry-1', const Rect.fromLTWH(0, 0, 50, 20));
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final entry = makeEntry(id: 'entry-1');
notifier.beginDrag(entry);
expect(notifier.isDraggingEntry, isTrue);
expect(notifier.dragState, isNotNull);
expect(notifier.dragState!.entryId, 'entry-1');
expect(notifier.hoveredEntryId, isNull);
expect(notifyCount, greaterThan(0));
});
test('updateDragTarget updates target', () {
final entry = makeEntry();
notifier.beginDrag(entry);
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final newStart = kAnchor.add(const Duration(hours: 5));
notifier.updateDragTarget(
targetGroupId: 'group-2',
targetLane: 3,
targetStart: newStart,
);
expect(notifier.dragState!.targetGroupId, 'group-2');
expect(notifier.dragState!.targetLane, 3);
expect(notifier.dragState!.targetStart, newStart);
expect(notifyCount, 1);
});
test('updateDragTarget is no-op without active drag', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.updateDragTarget(
targetGroupId: 'group-1',
targetLane: 1,
targetStart: kAnchor,
);
expect(notifyCount, 0);
});
test('endDrag clears all drag state', () {
final entry = makeEntry();
notifier.beginDrag(entry);
notifier.updateInteractionPosition(const Offset(100, 200));
notifier.endDrag();
expect(notifier.isDraggingEntry, isFalse);
expect(notifier.dragState, isNull);
expect(notifier.interactionGlobalPosition, isNull);
});
});
group('resize lifecycle', () {
test(
'beginResize sets resizeState and isResizingEntry and clears hover',
() {
notifier.setHoveredEntry(
'entry-1',
const Rect.fromLTWH(0, 0, 50, 20),
);
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final entry = makeEntry(id: 'entry-1');
notifier.beginResize(entry, ResizeEdge.start);
expect(notifier.isResizingEntry, isTrue);
expect(notifier.resizeState, isNotNull);
expect(notifier.resizeState!.edge, ResizeEdge.start);
expect(notifier.hoveredEntryId, isNull);
expect(notifyCount, greaterThan(0));
},
);
test('updateResizeTarget updates targets', () {
final entry = makeEntry();
notifier.beginResize(entry, ResizeEdge.end);
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final newStart = kAnchor.subtract(const Duration(hours: 1));
final newEnd = kAnchor.add(const Duration(hours: 5));
notifier.updateResizeTarget(
targetStart: newStart,
targetEnd: newEnd,
targetLane: 2,
);
expect(notifier.resizeState!.targetStart, newStart);
expect(notifier.resizeState!.targetEnd, newEnd);
expect(notifier.resizeState!.targetLane, 2);
expect(notifyCount, 1);
});
test('updateResizeTarget is no-op without active resize', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.updateResizeTarget(
targetStart: kAnchor,
targetEnd: kAnchor.add(const Duration(hours: 1)),
);
expect(notifyCount, 0);
});
test('endResize clears all resize state', () {
final entry = makeEntry();
notifier.beginResize(entry, ResizeEdge.end);
notifier.updateInteractionPosition(const Offset(100, 200));
notifier.endResize();
expect(notifier.isResizingEntry, isFalse);
expect(notifier.resizeState, isNull);
expect(notifier.interactionGlobalPosition, isNull);
});
});
group('cancelDrag / cancelResize', () {
test('cancelDrag clears same as endDrag', () {
final entry = makeEntry();
notifier.beginDrag(entry);
notifier.cancelDrag();
expect(notifier.isDraggingEntry, isFalse);
expect(notifier.dragState, isNull);
});
test('cancelResize clears same as endResize', () {
final entry = makeEntry();
notifier.beginResize(entry, ResizeEdge.start);
notifier.cancelResize();
expect(notifier.isResizingEntry, isFalse);
expect(notifier.resizeState, isNull);
});
});
group('isInteracting', () {
test('true during drag', () {
final entry = makeEntry();
notifier.beginDrag(entry);
expect(notifier.isInteracting, isTrue);
});
test('true during resize', () {
final entry = makeEntry();
notifier.beginResize(entry, ResizeEdge.end);
expect(notifier.isInteracting, isTrue);
});
test('false when idle', () {
expect(notifier.isInteracting, isFalse);
});
test('false after ending drag', () {
final entry = makeEntry();
notifier.beginDrag(entry);
notifier.endDrag();
expect(notifier.isInteracting, isFalse);
});
});
});
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
import '../helpers/test_constants.dart';
void main() {
group('TimelineViewportNotifier', () {
late TimelineViewportNotifier notifier;
setUp(() {
notifier = TimelineViewportNotifier(start: kDomainStart, end: kDomainEnd);
});
tearDown(() {
notifier.dispose();
});
group('construction', () {
test('stores start and end correctly', () {
expect(notifier.start, kDomainStart);
expect(notifier.end, kDomainEnd);
});
test('asserts on invalid domain (start >= end)', () {
expect(
() => TimelineViewportNotifier(start: kDomainEnd, end: kDomainStart),
throwsAssertionError,
);
});
});
group('setDomain', () {
test('updates and notifies', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final newStart = kDomainStart.add(const Duration(days: 1));
final newEnd = kDomainEnd.subtract(const Duration(days: 1));
notifier.setDomain(newStart, newEnd);
expect(notifier.start, newStart);
expect(notifier.end, newEnd);
expect(notifyCount, 1);
});
test('no-op if unchanged', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setDomain(kDomainStart, kDomainEnd);
expect(notifyCount, 0);
});
test('rejects invalid domain (start >= end)', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setDomain(kDomainEnd, kDomainStart);
expect(notifier.start, kDomainStart);
expect(notifier.end, kDomainEnd);
expect(notifyCount, 0);
});
test('rejects equal start and end', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.setDomain(kDomainStart, kDomainStart);
expect(notifyCount, 0);
});
});
group('zoom', () {
test('factor > 1 shrinks domain', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final originalDuration = notifier.end.difference(notifier.start);
notifier.zoom(2.0);
final newDuration = notifier.end.difference(notifier.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds / 2, 1),
);
expect(notifyCount, 1);
});
test('factor < 1 expands domain', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
final originalDuration = notifier.end.difference(notifier.start);
notifier.zoom(0.5);
final newDuration = notifier.end.difference(notifier.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds * 2, 1),
);
expect(notifyCount, 1);
});
});
group('pan', () {
test('shifts domain forward', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.pan(0.1);
expect(notifier.start.isAfter(kDomainStart), isTrue);
expect(notifier.end.isAfter(kDomainEnd), isTrue);
expect(notifyCount, 1);
});
test('shifts domain backward', () {
notifier.pan(-0.1);
expect(notifier.start.isBefore(kDomainStart), isTrue);
expect(notifier.end.isBefore(kDomainEnd), isTrue);
});
test('preserves duration', () {
final originalDuration = notifier.end.difference(notifier.start);
notifier.pan(0.25);
final newDuration = notifier.end.difference(notifier.start);
expect(
newDuration.inMilliseconds,
closeTo(originalDuration.inMilliseconds, 1),
);
});
});
group('listener counting', () {
test('exactly one notification per valid mutation', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
notifier.pan(0.1);
expect(notifyCount, 1);
notifier.zoom(1.5);
expect(notifyCount, 2);
final newStart = notifier.start.add(const Duration(days: 1));
final newEnd = notifier.end.add(const Duration(days: 1));
notifier.setDomain(newStart, newEnd);
expect(notifyCount, 3);
});
test('zero notifications for no-ops', () {
var notifyCount = 0;
notifier.addListener(() => notifyCount++);
// setDomain with same values
notifier.setDomain(kDomainStart, kDomainEnd);
// setDomain with invalid values
notifier.setDomain(kDomainEnd, kDomainStart);
expect(notifyCount, 0);
});
});
});
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:z_timeline/src/constants.dart';
import 'package:z_timeline/src/models/entry_resize_state.dart';
import 'package:z_timeline/src/models/projected_entry.dart';
import 'package:z_timeline/src/models/timeline_entry.dart';
import 'package:z_timeline/src/services/layout_coordinate_service.dart';
import 'package:z_timeline/src/state/timeline_viewport_notifier.dart';
import 'package:z_timeline/src/widgets/event_pill.dart';
import 'package:z_timeline/src/widgets/interactive_event_pill.dart';
import 'package:z_timeline/src/widgets/timeline_scope.dart';
import '../helpers/test_constants.dart';
import '../helpers/test_factories.dart';
import '../helpers/timeline_test_harness.dart';
void main() {
group('InteractiveEventPill', () {
const contentWidth = 800.0;
const laneHeight = ZTimelineConstants.laneHeight;
late TimelineViewportNotifier viewport;
setUp(() {
viewport = TimelineViewportNotifier(
start: kDomainStart,
end: kDomainEnd,
);
});
tearDown(() {
viewport.dispose();
});
/// Helper to pump an InteractiveEventPill in a test harness.
Future<void> pumpPill(
WidgetTester tester, {
ProjectedEntry? entry,
bool enableDrag = true,
List<TimelineEntry> allEntries = const [],
void Function(TimelineEntry, DateTime, String, int)? onEntryMoved,
void Function(TimelineEntry, DateTime, DateTime, int)? onEntryResized,
}) async {
final projectedEntry = entry ??
makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.3,
);
await tester.pumpTimeline(
SizedBox(
width: contentWidth,
height: 200,
child: Stack(
children: [
InteractiveEventPill(
entry: projectedEntry,
laneHeight: laneHeight,
labelBuilder: (e) => e.id,
colorBuilder: (_) => Colors.blue,
contentWidth: contentWidth,
enableDrag: enableDrag,
viewport: viewport,
allEntries: allEntries,
onEntryMoved: onEntryMoved,
onEntryResized: onEntryResized,
),
],
),
),
viewport: viewport,
);
}
group('rendering', () {
testWidgets('pill appears at correct position', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(lane: 2),
startX: 0.2,
endX: 0.5,
);
await pumpPill(tester, entry: projEntry);
final positioned = tester.widget<Positioned>(
find.ancestor(
of: find.byType(EventPill),
matching: find.byType(Positioned),
).first,
);
final expectedTop = LayoutCoordinateService.laneToY(
lane: 2,
laneHeight: laneHeight,
);
final expectedLeft = LayoutCoordinateService.normalizedToWidgetX(
normalizedX: 0.2,
contentWidth: contentWidth,
);
final expectedWidth = LayoutCoordinateService.calculateItemWidth(
normalizedWidth: 0.3, // 0.5 - 0.2
contentWidth: contentWidth,
);
expect(positioned.top, expectedTop);
expect(positioned.left, closeTo(expectedLeft, 0.1));
expect(positioned.width, closeTo(expectedWidth, 0.1));
expect(positioned.height, laneHeight);
});
testWidgets('enableDrag=false still renders pill', (tester) async {
await pumpPill(tester, enableDrag: false);
expect(find.byType(EventPill), findsOneWidget);
// No GestureDetector when drag is disabled
expect(
find.descendant(
of: find.byType(InteractiveEventPill),
matching: find.byType(GestureDetector),
),
findsNothing,
);
});
});
group('hover', () {
testWidgets('mouse enter calls setHoveredEntry', (tester) async {
await pumpPill(tester);
final pillFinder = find.byType(EventPill);
final gesture = await tester.createGesture(
kind: PointerDeviceKind.mouse,
);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getCenter(pillFinder));
await tester.pump();
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
expect(scope.interaction.hoveredEntryId, isNotNull);
});
testWidgets('mouse exit calls clearHoveredEntry', (tester) async {
await pumpPill(tester);
final pillFinder = find.byType(EventPill);
final gesture = await tester.createGesture(
kind: PointerDeviceKind.mouse,
);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
// Enter
await gesture.moveTo(tester.getCenter(pillFinder));
await tester.pump();
// Exit
await gesture.moveTo(Offset.zero);
await tester.pump();
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
expect(scope.interaction.hoveredEntryId, isNull);
});
});
group('drag flow', () {
testWidgets('pan from center triggers beginDrag', (tester) async {
// Use a wide pill so center is clearly in drag zone
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
await pumpPill(tester, entry: projEntry);
final pillCenter = tester.getCenter(find.byType(EventPill));
// Simulate pan gesture
await tester.timedDragFrom(
pillCenter,
const Offset(20, 0),
const Duration(milliseconds: 200),
);
await tester.pump();
// After drag ends, state should be cleared
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
expect(scope.interaction.isDraggingEntry, isFalse);
});
});
group('resize', () {
testWidgets('pan from left edge triggers resize start', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
);
// Get the pill's left edge position (within first 6px)
final pillRect = tester.getRect(find.byType(EventPill));
final leftEdge = Offset(pillRect.left + 3, pillRect.center.dy);
await tester.timedDragFrom(
leftEdge,
const Offset(-20, 0),
const Duration(milliseconds: 200),
);
await tester.pump();
expect(resizedEntry, isNotNull);
});
testWidgets('pan from right edge triggers resize end', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
);
final pillRect = tester.getRect(find.byType(EventPill));
final rightEdge = Offset(pillRect.right - 3, pillRect.center.dy);
await tester.timedDragFrom(
rightEdge,
const Offset(20, 0),
const Duration(milliseconds: 200),
);
await tester.pump();
expect(resizedEntry, isNotNull);
});
testWidgets('narrow pill (< 16px) always uses drag mode', (
tester,
) async {
// Make a very narrow pill (< 16px)
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.1 + (15 / contentWidth), // ~15px wide
);
TimelineEntry? resizedEntry;
await pumpPill(
tester,
entry: projEntry,
onEntryResized: (entry, newStart, newEnd, newLane) {
resizedEntry = entry;
},
);
final pillCenter = tester.getCenter(find.byType(EventPill));
await tester.timedDragFrom(
pillCenter,
const Offset(20, 0),
const Duration(milliseconds: 200),
);
await tester.pump();
// Should not have triggered resize
expect(resizedEntry, isNull);
});
});
group('opacity', () {
testWidgets('pill at 1.0 opacity when idle', (tester) async {
await pumpPill(tester);
final opacity = tester.widget<Opacity>(find.byType(Opacity));
expect(opacity.opacity, 1.0);
});
testWidgets('pill at 0.3 opacity during drag', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
await pumpPill(tester, entry: projEntry);
// Start a drag by triggering beginDrag on the notifier directly
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
scope.interaction.beginDrag(projEntry.entry);
await tester.pump();
final opacity = tester.widget<Opacity>(find.byType(Opacity));
expect(opacity.opacity, 0.3);
});
testWidgets('pill at 0.3 opacity during resize', (tester) async {
final projEntry = makeProjectedEntry(
entry: makeEntry(),
startX: 0.1,
endX: 0.4,
);
await pumpPill(
tester,
entry: projEntry,
onEntryResized: (_, __, ___, ____) {},
);
final scope = ZTimelineScope.of(
tester.element(find.byType(InteractiveEventPill)),
);
scope.interaction.beginResize(
projEntry.entry,
ResizeEdge.end,
);
await tester.pump();
final opacity = tester.widget<Opacity>(find.byType(Opacity));
expect(opacity.opacity, 0.3);
});
});
});
}