From bf8a99fb239bf21583e9d49fde4ac6e59570041e Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Tue, 13 Oct 2026 11:12:44 +0900 Subject: [PATCH] Captures page: the recording checklist with one start/end button Steps come from a bundled copy of contracts checklists/ (assets/contracts/, refreshed by tool/sync-contracts.sh), not from Dart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- .gitea/workflows/ci.yaml | 1 + .../contracts/checklists/z27ag-captures.json | 226 +++++++++ lib/checklist.dart | 462 ++++++++++++++++++ lib/main.dart | 10 + pubspec.yaml | 7 +- test/checklist_test.dart | 79 +++ tool/sync-contracts.sh | 12 + 7 files changed, 793 insertions(+), 4 deletions(-) create mode 100644 assets/contracts/checklists/z27ag-captures.json create mode 100644 lib/checklist.dart create mode 100644 test/checklist_test.dart create mode 100755 tool/sync-contracts.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c1198b1..5bea66e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -5,6 +5,7 @@ on: branches: [ master ] paths: - 'lib/**' + - 'assets/**' - 'test/**' - 'android/**' - 'pubspec.yaml' diff --git a/assets/contracts/checklists/z27ag-captures.json b/assets/contracts/checklists/z27ag-captures.json new file mode 100644 index 0000000..56600f4 --- /dev/null +++ b/assets/contracts/checklists/z27ag-captures.json @@ -0,0 +1,226 @@ +{ + "id": "z27ag-captures", + "version": 1, + "title": "Z27AG decoding captures", + "about": "docs/impl/plans/Z27AG-CAPTURES.md", + "mark_format": " [ #] start|end for a span, [ #] for a point", + "sections": [ + { + "title": "Parked, ignition on, engine off", + "steps": [ + { + "id": "1", + "title": "Baseline", + "do": "Touch nothing.", + "finds": "the resting value of everything", + "actions": [{ "label": "baseline", "hold_s": 20 }] + }, + { + "id": "2", + "title": "Accelerator", + "do": "Slowly to the floor over 5 s, hold 3 s, slowly back. Start as the foot moves, end when it is off.", + "finds": "pedal position, with no rpm to confuse it", + "actions": [{ "label": "accel" }] + }, + { + "id": "3", + "title": "Brake", + "do": "Start as you press, hold 3 s, end as you release.", + "finds": "brake switch, maybe pressure", + "actions": [{ "label": "brake", "hold_s": 3, "repeat": 3 }] + }, + { + "id": "4", + "title": "Clutch", + "do": "Start as you press, hold 3 s, end as you release.", + "finds": "clutch switch", + "actions": [{ "label": "clutch", "hold_s": 3, "repeat": 3 }] + }, + { + "id": "5", + "title": "Steering", + "do": "Centre, full left, full right, back to centre, slowly.", + "finds": "steering angle", + "actions": [{ "label": "steer" }] + }, + { + "id": "6", + "title": "Gears", + "do": "Start when the gear is in, end as you take it out. Clutch down throughout.", + "finds": "reverse (likely as the reverse light); no other gearbox signal expected", + "actions": [ + { "label": "gear 1", "hold_s": 3 }, + { "label": "gear 2", "hold_s": 3 }, + { "label": "gear 3", "hold_s": 3 }, + { "label": "gear 4", "hold_s": 3 }, + { "label": "gear 5", "hold_s": 3 }, + { "label": "gear R", "hold_s": 3 }, + { "label": "neutral", "hold_s": 3 } + ] + }, + { + "id": "7", + "title": "Handbrake", + "do": "Start as you pull it up, end as you release it.", + "finds": "handbrake switch", + "actions": [{ "label": "handbrake", "hold_s": 3, "repeat": 2 }] + }, + { + "id": "8", + "title": "Indicators", + "do": "Start when switched on, end when off.", + "finds": "indicator states", + "actions": [ + { "label": "left", "hold_s": 5 }, + { "label": "right", "hold_s": 5 }, + { "label": "hazard", "hold_s": 5 } + ] + }, + { + "id": "9", + "title": "Lights", + "do": "Start when switched on, end when off.", + "finds": "light states", + "actions": [ + { "label": "side lights", "hold_s": 3 }, + { "label": "dipped", "hold_s": 3 }, + { "label": "main beam", "hold_s": 3 }, + { "label": "flash", "point": true }, + { "label": "front fogs", "hold_s": 3 }, + { "label": "rear fog", "hold_s": 3 } + ] + }, + { + "id": "10", + "title": "Doors", + "do": "Start as it opens, end when it is shut. Skip what the car does not have.", + "finds": "door switches", + "actions": [ + { "label": "driver door" }, + { "label": "passenger door" }, + { "label": "rear left door" }, + { "label": "rear right door" }, + { "label": "boot" }, + { "label": "bonnet" } + ] + }, + { + "id": "11", + "title": "Wipers", + "do": "Start when switched on, end when off.", + "finds": "wiper states", + "actions": [ + { "label": "wipers int", "hold_s": 5 }, + { "label": "wipers low", "hold_s": 5 }, + { "label": "wipers high", "hold_s": 5 }, + { "label": "washer" } + ] + }, + { + "id": "12", + "title": "A/C and blower", + "do": "A/C: start on, end off. Blower: start as you turn it up, end when it is back down.", + "finds": "A/C request, blower", + "actions": [ + { "label": "ac", "hold_s": 5 }, + { "label": "blower" } + ] + }, + { + "id": "13", + "title": "Seatbelt", + "do": "Start as it clicks in, end as it comes out.", + "finds": "belt switch", + "actions": [{ "label": "belt", "hold_s": 3 }] + } + ] + }, + { + "title": "Engine running, parked", + "steps": [ + { + "id": "14", + "title": "Engine start", + "do": "Cold start if possible: mark as the starter turns, then idle 60 s.", + "finds": "starting, coolant and oil warming, idle control", + "actions": [ + { "label": "engine start", "point": true }, + { "label": "idle", "hold_s": 60 } + ] + }, + { + "id": "15", + "title": "Revs", + "do": "Start when the revs are steady at the target, end as you leave it.", + "finds": "engine speed against OBD rpm, throttle against pedal", + "actions": [ + { "label": "rpm 1500", "hold_s": 5 }, + { "label": "rpm 2000", "hold_s": 5 }, + { "label": "rpm 3000", "hold_s": 5 } + ] + }, + { + "id": "16", + "title": "Blips", + "do": "Three sharp blips of the throttle.", + "finds": "response timing of throttle, rpm, pressure", + "actions": [{ "label": "blips" }] + }, + { + "id": "17", + "title": "A/C under load", + "do": "A/C on for 20 s, then off.", + "finds": "compressor clutch, idle-up", + "actions": [{ "label": "ac", "hold_s": 20 }] + }, + { + "id": "18", + "title": "Radiator fan", + "do": "Idle until the fan cuts in, if it will; mark when you hear it.", + "finds": "fan state, coolant threshold", + "actions": [{ "label": "fan", "point": true }] + } + ] + }, + { + "title": "Driving (a passenger presses, or skip the marks)", + "steps": [ + { + "id": "19", + "title": "Steady speeds", + "do": "Where safe, hold each speed 10 s.", + "finds": "road and wheel speeds against OBD and GNSS speed", + "actions": [ + { "label": "steady 20", "hold_s": 10 }, + { "label": "steady 40", "hold_s": 10 }, + { "label": "steady 60", "hold_s": 10 } + ] + }, + { + "id": "20", + "title": "Stops", + "do": "Start as you brake, end when stopped.", + "finds": "brake pressure, deceleration", + "actions": [ + { "label": "gentle stop" }, + { "label": "firm stop" } + ] + }, + { + "id": "21", + "title": "Corners", + "do": "A few corners each way.", + "finds": "yaw, lateral g, wheel speed differences", + "actions": [{ "label": "corners" }] + }, + { + "id": "22", + "title": "Through the box", + "do": "Each gear in turn.", + "finds": "gear from rpm / speed", + "actions": [{ "label": "gears" }] + } + ] + } + ] +} diff --git a/lib/checklist.dart b/lib/checklist.dart new file mode 100644 index 0000000..cb91410 --- /dev/null +++ b/lib/checklist.dart @@ -0,0 +1,462 @@ +// Capture checklists: step through a recording plan with one button per action. +// +// The steps are data, from contracts `checklists/` (bundled as an asset under +// assets/contracts/, an exact copy; tool/sync-contracts.sh refreshes it). Nothing here +// knows what a step is about: it only turns presses into marks. +// +// Mark text, as the analysis reads it back from events.ndjson: +// [ #] start | end a span (the one button, pressed twice) +// [ #] a point +// [ #] abort a span left open (skip, redo, another step) +// skip | redo the step was skipped / is being done again +// checklist v once, before this page's first mark + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'boundary.dart'; + +const checklistAssets = 'assets/contracts/checklists/'; + +class ChecklistAction { + ChecklistAction(this.label, {this.holdS, this.repeat = 1, this.point = false}); + final String label; + + /// Suggested length of the span, shown as a target while it runs. + final int? holdS; + final int repeat; + final bool point; + + factory ChecklistAction.fromJson(Map j) => ChecklistAction( + j['label'] as String, + holdS: j['hold_s'] as int?, + repeat: j['repeat'] as int? ?? 1, + point: j['point'] == true, + ); +} + +class ChecklistStep { + ChecklistStep(this.id, this.title, this.doText, this.finds, this.actions, this.section); + final String id; + final String title; + final String doText; + final String finds; + final List actions; + final String section; +} + +class Checklist { + Checklist(this.id, this.version, this.title, this.steps); + final String id; + final int version; + final String title; + final List steps; + + factory Checklist.fromJson(Map j) { + final steps = []; + for (final s in j['sections'] as List) { + final section = (s as Map)['title'] as String; + for (final st in s['steps'] as List) { + final m = st as Map; + steps.add(ChecklistStep( + m['id'] as String, + m['title'] as String, + m['do'] as String? ?? '', + m['finds'] as String? ?? '', + [for (final a in m['actions'] as List) ChecklistAction.fromJson(a as Map)], + section, + )); + } + } + return Checklist(j['id'] as String, j['version'] as int, j['title'] as String, steps); + } +} + +/// Where the one button is in a checklist, and the mark each press sends. No I/O: +/// every method returns the marks to send, in order. +class ChecklistRun { + ChecklistRun(this.checklist); + final Checklist checklist; + + int step = 0; + int action = 0; + int rep = 0; + DateTime? spanStarted; + bool _announced = false; + + ChecklistStep get current => checklist.steps[step]; + bool get inSpan => spanStarted != null; + bool get stepDone => action >= current.actions.length; + bool get finished => step >= checklist.steps.length - 1 && stepDone; + ChecklistAction? get currentAction => stepDone ? null : current.actions[action]; + + /// "brake #2" when the action repeats, "brake" when it does not. + String get actionName { + final a = currentAction!; + return a.repeat > 1 ? '${a.label} #${rep + 1}' : a.label; + } + + /// What the button will do next, for its label. + String get nextLabel { + if (stepDone) return finished ? 'All done' : 'Next step'; + final a = currentAction!; + if (a.point) return 'Mark $actionName'; + return inSpan ? 'End $actionName' : 'Start $actionName'; + } + + List _announce() { + if (_announced) return const []; + _announced = true; + return ['checklist ${checklist.id} v${checklist.version}']; + } + + List press([DateTime? now]) { + if (stepDone) { + if (!finished) _goto(step + 1); + return const []; + } + final out = _announce(); + final a = currentAction!; + final name = '${current.id} $actionName'; + if (a.point) { + _advance(); + return [...out, name]; + } + if (!inSpan) { + spanStarted = now ?? DateTime.now(); + return [...out, '$name start']; + } + spanStarted = null; + _advance(); + return [...out, '$name end']; + } + + void _advance() { + if (rep + 1 < currentAction!.repeat) { + rep++; + } else { + rep = 0; + action++; + } + } + + List _leave() => inSpan ? [..._announce(), '${current.id} $actionName abort'] : const []; + + List skip() { + final out = [..._leave(), ..._announce(), '${current.id} skip']; + if (step < checklist.steps.length - 1) { + _goto(step + 1); + } else { + _goto(step); + action = current.actions.length; + } + return out; + } + + List redo() { + final out = [..._leave(), ..._announce(), '${current.id} redo']; + _goto(step); + return out; + } + + List select(int i) { + if (i == step) return const []; + final out = _leave(); + _goto(i); + return out; + } + + void _goto(int i) { + step = i; + action = 0; + rep = 0; + spanStarted = null; + } +} + +Future> loadChecklists() async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final paths = manifest.listAssets().where((p) => p.startsWith(checklistAssets) && p.endsWith('.json')).toList()..sort(); + return [ + for (final p in paths) Checklist.fromJson(jsonDecode(await rootBundle.loadString(p)) as Map), + ]; +} + +/// Lists the bundled checklists; opens the only one directly. +class ChecklistsPage extends StatelessWidget { + const ChecklistsPage({super.key, required this.boundary}); + final Boundary boundary; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: loadChecklists(), + builder: (context, snap) { + if (snap.hasError) { + return Scaffold(appBar: AppBar(title: const Text('Captures')), body: Center(child: Text('${snap.error}'))); + } + final lists = snap.data; + if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator())); + if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single); + return Scaffold( + appBar: AppBar(title: const Text('Captures')), + body: ListView( + children: [ + if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')), + for (final c in lists) + ListTile( + title: Text(c.title), + subtitle: Text('${c.id} v${c.version} · ${c.steps.length} steps'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChecklistPage(boundary: boundary, checklist: c)), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _Sent { + _Sent(this.id, this.text); + final String id; + final String text; +} + +class ChecklistPage extends StatefulWidget { + const ChecklistPage({super.key, required this.boundary, required this.checklist}); + final Boundary boundary; + final Checklist checklist; + + @override + State createState() => _ChecklistPageState(); +} + +class _ChecklistPageState extends State { + late final run = ChecklistRun(widget.checklist); + final sent = <_Sent>[]; + Timer? _tick; + + @override + void initState() { + super.initState(); + widget.boundary.addListener(_changed); + } + + @override + void dispose() { + _tick?.cancel(); + widget.boundary.removeListener(_changed); + super.dispose(); + } + + void _changed() => setState(() {}); + + void _send(List marks) { + for (final m in marks) { + sent.insert(0, _Sent(widget.boundary.command('mark', {'text': m}), m)); + } + if (sent.length > 20) sent.removeRange(20, sent.length); + // The span timer only needs to tick while a span is open. + _tick?.cancel(); + if (run.inSpan) _tick = Timer.periodic(const Duration(milliseconds: 100), (_) => setState(() {})); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final steps = StepList(run: run, onSelect: (i) => _send(run.select(i))); + return Scaffold( + appBar: AppBar( + title: Text(widget.checklist.title), + actions: [ + Builder( + builder: (context) => MediaQuery.of(context).size.width > 800 + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.list), + tooltip: 'Steps', + onPressed: () => Scaffold.of(context).openEndDrawer(), + ), + ), + ], + ), + endDrawer: MediaQuery.of(context).size.width > 800 ? null : Drawer(child: SafeArea(child: steps)), + body: SafeArea( + child: LayoutBuilder( + builder: (context, c) { + final panel = _panel(context); + if (c.maxWidth <= 800) return panel; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: 300, child: steps), + const VerticalDivider(width: 1), + Expanded(child: panel), + ], + ); + }, + ), + ), + ); + } + + Widget _panel(BuildContext context) { + final s = run.current; + final theme = Theme.of(context); + final a = run.currentAction; + final (Color color, IconData icon) = run.stepDone + ? (Colors.blueGrey, Icons.skip_next) + : a!.point + ? (Colors.blue, Icons.flag) + : run.inSpan + ? (Colors.red.shade700, Icons.stop) + : (Colors.green.shade700, Icons.play_arrow); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(s.section, style: theme.textTheme.labelLarge?.copyWith(color: theme.colorScheme.outline)), + Text('${s.id}. ${s.title}', style: theme.textTheme.headlineMedium), + const SizedBox(height: 8), + Text(s.doText, style: theme.textTheme.titleLarge), + if (s.finds.isNotEmpty) + Text('Finds: ${s.finds}', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.outline)), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [for (var i = 0; i < s.actions.length; i++) _actionChip(s, i)], + ), + const SizedBox(height: 12), + Expanded( + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: run.finished ? null : () => _send(run.press()), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 72), + Text(run.nextLabel, textAlign: TextAlign.center, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.w700)), + if (run.inSpan) _spanTimer(a!), + if (!run.inSpan && a != null && a.holdS != null && !a.point) + Text('hold about ${a.holdS} s', style: const TextStyle(fontSize: 22)), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon(onPressed: () => _send(run.redo()), icon: const Icon(Icons.replay), label: const Text('Redo step')), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: run.finished ? null : () => _send(run.skip()), + icon: const Icon(Icons.redo), + label: const Text('Skip step'), + ), + const SizedBox(width: 16), + Expanded(child: _lastMark(context)), + ], + ), + ], + ), + ); + } + + Widget _actionChip(ChecklistStep s, int i) { + final a = s.actions[i]; + final done = i < run.action; + final now = i == run.action; + final reps = a.repeat > 1 ? ' ×${a.repeat}' : ''; + return Chip( + avatar: Icon(done ? Icons.check : (a.point ? Icons.flag_outlined : Icons.timelapse), size: 18), + label: Text('${a.label}$reps'), + backgroundColor: now ? Theme.of(context).colorScheme.primaryContainer : null, + ); + } + + Widget _spanTimer(ChecklistAction a) { + final secs = DateTime.now().difference(run.spanStarted!).inMilliseconds / 1000; + final reached = a.holdS != null && secs >= a.holdS!; + return Text( + a.holdS == null ? '${secs.toStringAsFixed(1)} s' : '${secs.toStringAsFixed(1)} s / ${a.holdS} s', + style: TextStyle( + fontSize: 32, + fontFeatures: const [FontFeature.tabularFigures()], + color: reached ? Colors.lightGreenAccent : Colors.white, + ), + ); + } + + /// The latest mark and whether the recorder took it: an unseen ✗ means marks are going nowhere. + Widget _lastMark(BuildContext context) { + if (sent.isEmpty) { + return Text('No marks sent yet', textAlign: TextAlign.end, style: TextStyle(color: Theme.of(context).colorScheme.outline)); + } + final b = widget.boundary; + final last = sent.first; + final pending = b.pending.length; + final ack = b.acks.where((x) => x.id == last.id).firstOrNull; + final (String status, Color? color) = ack != null + ? (ack.ok ? ('✓', null) : ('✗ ${ack.detail}', Colors.redAccent)) + : b.pending.any((p) => p.id == last.id) + ? ('… unacked', Colors.orange) + : ('?', null); + return Text( + '"${last.text}" $status${pending > 1 ? ' · $pending waiting' : ''}', + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle(color: color, fontSize: 16), + ); + } +} + +class StepList extends StatelessWidget { + const StepList({super.key, required this.run, required this.onSelect}); + final ChecklistRun run; + final void Function(int) onSelect; + + @override + Widget build(BuildContext context) { + final steps = run.checklist.steps; + return ListView.builder( + itemCount: steps.length, + itemBuilder: (context, i) { + final s = steps[i]; + final header = i == 0 || steps[i - 1].section != s.section; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (header) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text(s.section, style: Theme.of(context).textTheme.labelLarge), + ), + ListTile( + dense: true, + selected: i == run.step, + leading: Text(s.id, style: const TextStyle(fontSize: 18)), + title: Text(s.title), + onTap: () { + onSelect(i); + if (Scaffold.maybeOf(context)?.isEndDrawerOpen == true) Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index ae5eb1d..ea2ad9c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'boundary.dart'; import 'camview.dart'; +import 'checklist.dart'; import 'linktest.dart'; import 'net.dart'; @@ -250,6 +251,15 @@ class LinkBar extends StatelessWidget { icon: const Icon(Icons.videocam), label: const Text('Camera'), ), + TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChecklistsPage(boundary: b), + ), + ), + icon: const Icon(Icons.checklist), + label: const Text('Captures'), + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 57e87f8..e1ad0d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,10 +57,9 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + # Exact copies of contracts files (tool/sync-contracts.sh); crosscheck verifies them. + assets: + - assets/contracts/checklists/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/checklist_test.dart b/test/checklist_test.dart new file mode 100644 index 0000000..86a8896 --- /dev/null +++ b/test/checklist_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:display/checklist.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Checklist sample() => Checklist.fromJson({ + 'id': 'demo', + 'version': 2, + 'title': 'Demo', + 'sections': [ + { + 'title': 'A', + 'steps': [ + { + 'id': '1', + 'title': 'Brake', + 'actions': [ + {'label': 'brake', 'repeat': 2}, + {'label': 'flash', 'point': true}, + ], + }, + { + 'id': '2', + 'title': 'Gears', + 'actions': [ + {'label': 'gear 1'}, + ], + }, + ], + }, + ], +}); + +void main() { + test('one button: announce once, start/end spans, repeats numbered, points single', () { + final r = ChecklistRun(sample()); + expect(r.nextLabel, 'Start brake #1'); + expect(r.press(), ['checklist demo v2', '1 brake #1 start']); + expect(r.nextLabel, 'End brake #1'); + expect(r.press(), ['1 brake #1 end']); + expect(r.press(), ['1 brake #2 start']); + expect(r.press(), ['1 brake #2 end']); + expect(r.nextLabel, 'Mark flash'); + expect(r.press(), ['1 flash']); + expect(r.nextLabel, 'Next step'); + expect(r.press(), isEmpty); + expect(r.current.id, '2'); + expect(r.press(), ['2 gear 1 start']); + expect(r.press(), ['2 gear 1 end']); + expect(r.finished, isTrue); + expect(r.nextLabel, 'All done'); + }); + + test('leaving an open span aborts it', () { + final r = ChecklistRun(sample()); + r.press(); + expect(r.redo(), ['1 brake #1 abort', '1 redo']); + expect(r.inSpan, isFalse); + expect(r.nextLabel, 'Start brake #1'); + r.press(); + expect(r.select(1), ['1 brake #1 abort']); + expect(r.current.id, '2'); + expect(r.skip(), ['2 skip']); + expect(r.finished, isTrue); + }); + + test('the bundled checklists parse, with unique step ids', () { + final dir = Directory('assets/contracts/checklists'); + final files = dir.listSync().whereType().where((f) => f.path.endsWith('.json')).toList(); + expect(files, isNotEmpty); + for (final f in files) { + final c = Checklist.fromJson(jsonDecode(f.readAsStringSync()) as Map); + final ids = c.steps.map((s) => s.id).toList(); + expect(ids.toSet().length, ids.length, reason: f.path); + expect(c.steps.every((s) => s.actions.isNotEmpty), isTrue, reason: f.path); + } + }); +} diff --git a/tool/sync-contracts.sh b/tool/sync-contracts.sh new file mode 100755 index 0000000..1587382 --- /dev/null +++ b/tool/sync-contracts.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Refresh the bundled copies of contracts files under assets/contracts/ from a +# contracts checkout (default: ../contracts). The copies must stay byte-identical: +# crosscheck fails the build otherwise. New files are added by copying them in once. +set -eu +here=$(cd "$(dirname "$0")/.." && pwd) +src=$(cd "${1:-$here/../contracts}" && pwd) +cd "$here" +find assets/contracts -type f | while read -r f; do + cp "$src/${f#assets/contracts/}" "$f" + echo "synced $f" +done