|
| 1 | +import 'dart:convert'; |
| 2 | +import 'dart:io'; |
| 3 | + |
| 4 | +import 'package:flutter_test/flutter_test.dart'; |
| 5 | + |
| 6 | +import 'package:omi/backend/schema/gen/action_items_folders_wire.g.dart'; |
| 7 | +import 'package:omi/pages/action_items/task_categorization.dart'; |
| 8 | +import 'package:omi/providers/conversation_provider.dart'; |
| 9 | + |
| 10 | +/// Flutter conformance suite for the shared cross-platform parity contracts |
| 11 | +/// (contracts/parity/README.md). Runs the repo-root fixture vectors through the |
| 12 | +/// REAL production rules: task bucketing (categorizeTasks, this platform's |
| 13 | +/// separate_overdue model), local-day conversation keys |
| 14 | +/// (conversationLocalDayKey, the #10198 contract), and action item wire decode |
| 15 | +/// (GeneratedActionItemResponse.fromJson). Day-key cases execute only when the |
| 16 | +/// fixture covers the runner's zone offset at that instant; offset 0 is always |
| 17 | +/// present so UTC CI runs every case. |
| 18 | +void main() { |
| 19 | + final root = _repoRoot(); |
| 20 | + |
| 21 | + group('task due buckets (separate_overdue model)', () { |
| 22 | + final fixture = _fixture(root, 'task_due_buckets.json'); |
| 23 | + const bucketByName = { |
| 24 | + 'today': TaskCategory.today, |
| 25 | + 'tomorrow': TaskCategory.tomorrow, |
| 26 | + 'later': TaskCategory.later, |
| 27 | + 'no_deadline': TaskCategory.noDeadline, |
| 28 | + 'overdue': TaskCategory.overdue, |
| 29 | + }; |
| 30 | + for (final raw in fixture['cases'] as List<dynamic>) { |
| 31 | + final c = raw as Map<String, dynamic>; |
| 32 | + test(c['name'] as String, () { |
| 33 | + final now = _localFromComponents(c['now'] as List<dynamic>); |
| 34 | + final due = c['due'] == null ? null : _localFromComponents(c['due'] as List<dynamic>); |
| 35 | + final created = c['created'] == null ? null : _localFromComponents(c['created'] as List<dynamic>); |
| 36 | + final item = _wireItem(due: due, created: created); |
| 37 | + |
| 38 | + final categorized = categorizeTasks([item], false, now: now); |
| 39 | + final actual = categorized.entries.where((e) => e.value.contains(item)).map((e) => e.key).toList(); |
| 40 | + final expectedName = (c['expected'] as Map<String, dynamic>)['separate_overdue'] as String; |
| 41 | + |
| 42 | + expect(actual, [bucketByName[expectedName]!]); |
| 43 | + }); |
| 44 | + } |
| 45 | + }); |
| 46 | + |
| 47 | + group('local day keys', () { |
| 48 | + final fixture = _fixture(root, 'day_keys.json'); |
| 49 | + for (final raw in fixture['cases'] as List<dynamic>) { |
| 50 | + final c = raw as Map<String, dynamic>; |
| 51 | + final instant = DateTime.parse(c['utc'] as String); |
| 52 | + final offsetEast = instant.toLocal().timeZoneOffset.inMinutes; |
| 53 | + final expected = (c['expected_by_offset'] as Map<String, dynamic>)['$offsetEast'] as String?; |
| 54 | + test('${c['name']} (offset $offsetEast)', () { |
| 55 | + final key = conversationLocalDayKey(instant); |
| 56 | + expect('${key.year}-${_pad(key.month)}-${_pad(key.day)}', expected); |
| 57 | + }, skip: expected == null ? 'fixture does not cover this zone offset' : false); |
| 58 | + } |
| 59 | + }); |
| 60 | + |
| 61 | + group('action item wire decode (parity contract)', () { |
| 62 | + final fixture = _fixture(root, 'wire_action_item.json'); |
| 63 | + for (final raw in fixture['cases'] as List<dynamic>) { |
| 64 | + final c = raw as Map<String, dynamic>; |
| 65 | + test(c['name'] as String, () { |
| 66 | + final byModel = c['expected_by_model'] as Map<String, dynamic>?; |
| 67 | + if (byModel != null) { |
| 68 | + // This client is the strict_decode model: a present-but-unparseable |
| 69 | + // due_at rejects the whole item (see the README divergence register). |
| 70 | + final strict = byModel['strict_decode'] as Map<String, dynamic>; |
| 71 | + expect(strict['parses'], isFalse); |
| 72 | + expect( |
| 73 | + () => GeneratedActionItemResponse.fromJson(c['payload'] as Map<String, dynamic>), |
| 74 | + throwsFormatException, |
| 75 | + ); |
| 76 | + return; |
| 77 | + } |
| 78 | + final expected = c['expected'] as Map<String, dynamic>; |
| 79 | + final item = GeneratedActionItemResponse.fromJson(c['payload'] as Map<String, dynamic>); |
| 80 | + |
| 81 | + expect(expected['parses'], isTrue); |
| 82 | + expect(item.description, expected['description']); |
| 83 | + expect(item.completed, expected['completed']); |
| 84 | + final dueUtc = expected['due_utc'] as String?; |
| 85 | + if (dueUtc == null) { |
| 86 | + expect(item.dueAt, isNull); |
| 87 | + } else { |
| 88 | + expect(item.dueAt?.millisecondsSinceEpoch, DateTime.parse(dueUtc).millisecondsSinceEpoch); |
| 89 | + } |
| 90 | + }); |
| 91 | + } |
| 92 | + }); |
| 93 | +} |
| 94 | + |
| 95 | +/// Walk up from the package dir to the repo root (the dir holding |
| 96 | +/// contracts/parity), so the suite works from either the app dir or repo root. |
| 97 | +Directory _repoRoot() { |
| 98 | + var dir = Directory.current.absolute; |
| 99 | + for (var i = 0; i < 6; i++) { |
| 100 | + if (Directory('${dir.path}${Platform.pathSeparator}contracts${Platform.pathSeparator}parity').existsSync()) { |
| 101 | + return dir; |
| 102 | + } |
| 103 | + final parent = dir.parent; |
| 104 | + if (parent.path == dir.path) break; |
| 105 | + dir = parent; |
| 106 | + } |
| 107 | + throw StateError('contracts/parity not found above ${Directory.current.path}'); |
| 108 | +} |
| 109 | + |
| 110 | +Map<String, dynamic> _fixture(Directory root, String name) { |
| 111 | + final file = File( |
| 112 | + '${root.path}${Platform.pathSeparator}contracts${Platform.pathSeparator}parity' |
| 113 | + '${Platform.pathSeparator}$name', |
| 114 | + ); |
| 115 | + return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>; |
| 116 | +} |
| 117 | + |
| 118 | +/// Fixture local-time components [year, month, day, hour?, minute?] in the |
| 119 | +/// runner's zone (the contracts are calendar rules, zone-independent). |
| 120 | +DateTime _localFromComponents(List<dynamic> c) => |
| 121 | + DateTime(c[0] as int, c[1] as int, c[2] as int, c.length > 3 ? c[3] as int : 0, c.length > 4 ? c[4] as int : 0); |
| 122 | + |
| 123 | +String _pad(int n) => n.toString().padLeft(2, '0'); |
| 124 | + |
| 125 | +/// Build the item through the production wire decode so bucket cases exercise |
| 126 | +/// the same path a backend response takes. |
| 127 | +GeneratedActionItemResponse _wireItem({DateTime? due, DateTime? created}) => GeneratedActionItemResponse.fromJson({ |
| 128 | + 'id': 'parity', |
| 129 | + 'description': 'parity case', |
| 130 | + 'completed': false, |
| 131 | + if (created != null) 'created_at': created.toUtc().toIso8601String(), |
| 132 | + if (due != null) 'due_at': due.toUtc().toIso8601String(), |
| 133 | + }); |
0 commit comments