add flutter tests
This commit is contained in:
205
packages/z-flutter/packages/z_timeline/pubspec.lock
Normal file
205
packages/z-flutter/packages/z_timeline/pubspec.lock
Normal 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"
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user