diff --git a/packages/z-flutter/packages/z_timeline/pubspec.lock b/packages/z-flutter/packages/z_timeline/pubspec.lock new file mode 100644 index 0000000..eb0a0b8 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/pubspec.lock @@ -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" diff --git a/packages/z-flutter/packages/z_timeline/test/helpers/test_constants.dart b/packages/z-flutter/packages/z_timeline/test/helpers/test_constants.dart new file mode 100644 index 0000000..b644135 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/helpers/test_constants.dart @@ -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); diff --git a/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart b/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart new file mode 100644 index 0000000..ab45032 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/helpers/test_factories.dart @@ -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 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)), + ); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/helpers/timeline_test_harness.dart b/packages/z-flutter/packages/z_timeline/test/helpers/timeline_test_harness.dart new file mode 100644 index 0000000..6ae01b8 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/helpers/timeline_test_harness.dart @@ -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 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; + } +} diff --git a/packages/z-flutter/packages/z_timeline/test/services/entry_placement_service_test.dart b/packages/z-flutter/packages/z_timeline/test/services/entry_placement_service_test.dart new file mode 100644 index 0000000..7d467c6 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/services/entry_placement_service_test.dart @@ -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); + }); + }); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/services/layout_coordinate_service_test.dart b/packages/z-flutter/packages/z_timeline/test/services/layout_coordinate_service_test.dart new file mode 100644 index 0000000..67e937a --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/services/layout_coordinate_service_test.dart @@ -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); + }); + }); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/services/time_scale_service_test.dart b/packages/z-flutter/packages/z_timeline/test/services/time_scale_service_test.dart new file mode 100644 index 0000000..4b4dbb4 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/services/time_scale_service_test.dart @@ -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), + ); + }); + }); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/state/timeline_interaction_notifier_test.dart b/packages/z-flutter/packages/z_timeline/test/state/timeline_interaction_notifier_test.dart new file mode 100644 index 0000000..e88bccd --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/state/timeline_interaction_notifier_test.dart @@ -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); + }); + }); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/state/timeline_viewport_notifier_test.dart b/packages/z-flutter/packages/z_timeline/test/state/timeline_viewport_notifier_test.dart new file mode 100644 index 0000000..28992f3 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/state/timeline_viewport_notifier_test.dart @@ -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); + }); + }); + }); +} diff --git a/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart b/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart new file mode 100644 index 0000000..31abf4e --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/test/widgets/interactive_event_pill_test.dart @@ -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 pumpPill( + WidgetTester tester, { + ProjectedEntry? entry, + bool enableDrag = true, + List 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( + 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(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(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(find.byType(Opacity)); + expect(opacity.opacity, 0.3); + }); + }); + }); +}