diff --git a/DOCS-phone-alarm.md b/DOCS-phone-alarm.md index 04bdeca..32d0919 100644 --- a/DOCS-phone-alarm.md +++ b/DOCS-phone-alarm.md @@ -18,8 +18,9 @@ intent) · `AlarmActivity` (dismiss screen over the lockscreen) · **Never replace any of this with a pub plugin.** The stack is AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20; a plugin that will not compile against AGP 9 breaks the build outright rather than degrading, and this is the one path that must -not break. Third-party plugins are acceptable only for droppable extras (QR -scanning) — never for the ring path. +not break. Third-party plugins are acceptable only for droppable extras — +never for the ring path, and never for NFC or the step counter, both of which +the dismiss sequence now hard-depends on. Things that are a hard crash or a silent no-ring, not a warning: @@ -71,15 +72,37 @@ once and then silently stops. The two are deliberately not synced — a shared day set is item 8's shape, and diverging defaults would have the halves firing on different days. -`lib/services/challenge.dart` is a port of `_challenges.py`'s five kinds so -both halves behave identically; `kDismissCodeLength`/`kDismissFlashSeconds` -are **duplicated** from `_constants.py`, not synced. Change one, change both. +`lib/services/challenge.dart` owns the four challenge kinds. **Every kind +must keep its prompt on screen until it is answered.** A memorise-then-hide +kind existed once and was deleted outright: the ring screen is reached by +walking in from another room, so anything already gone by the time the user +looks at it is unsolvable, not merely hard. -Room proof (item 3) is `lib/services/room_proof.dart`: **NFC or QR satisfies -on its own; steps alone never does** — the phone is stationary while you walk -to it, so steps only accrue after pickup and can be faked by shaking it in -bed. Steps are accepted only together with elapsed time, as the fallback for -when both other mechanisms fail. +Room proof is now a **strict, latched sequence**, owned by +`lib/services/dismiss_gate.dart`: + + tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges + +Every stage is required, they must happen **in that order**, and a stage once +passed **never reverts** — an unknown or out-of-order tag is ignored rather +than clearing progress. `room_proof.dart` answers only "did this one check +pass just now"; the ordering lives in the gate. Two *distinct* tags are +required: the same id in both slots is refused at registration, because one +tag satisfying both stages removes the walk that is the entire point. + +Steps still require elapsed time as well as a count — the phone is stationary +while you walk to it, so a bare count can be faked by shaking it in bed. That +pair is unchanged from when steps were a fallback; it is only its role that +changed, from alternative to prerequisite. + +**The bypass is the only fallback, and it is deliberately narrow.** After +`DismissGate.bypassAfter` (5 minutes) of ringing, or immediately when NFC is +off or fewer than two distinct tags are registered, the two *tag* stages are +skipped and the challenge count rises to 6. **The walk is never skipped** — +jumping straight to the challenges would let someone lie in bed for five +minutes and answer questions without standing up, which is weaker than the +gate this replaced. Without some such hatch a dead tag at 06:00 leaves an +alarm that literally cannot be switched off. **`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the diff --git a/phone_app/lib/screens/ring_challenge.dart b/phone_app/lib/screens/ring_challenge.dart new file mode 100644 index 0000000..3193ca2 --- /dev/null +++ b/phone_app/lib/screens/ring_challenge.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/challenge.dart'; + +/// The question half of the ring screen, shown once the walk is proven. +class RingChallenge extends StatelessWidget { + /// Creates the challenge view for [challenge]. + const RingChallenge({ + required this.challenge, + required this.controller, + required this.passed, + required this.total, + required this.onSubmit, + super.key, + }); + + /// The question currently being asked. + final Challenge challenge; + + /// Controller for the answer field, owned by the ring screen. + final TextEditingController controller; + + /// How many questions have been answered correctly so far. + final int passed; + + /// How many are needed in total; higher once the bypass is active. + final int total; + + /// Called when the user submits an answer. + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Text( + 'Question ${passed + 1} of $total', + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 8), + Text(challenge.hint, style: theme.textTheme.bodyLarge), + const SizedBox(height: 12), + Text( + // Always visible: a challenge that hides itself is unsolvable when + // the user reaches the screen having walked in from another room. + challenge.display, + textAlign: TextAlign.center, + style: theme.textTheme.displaySmall, + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration(labelText: 'Answer'), + onSubmitted: (_) => onSubmit(), + ), + const SizedBox(height: 16), + FilledButton(onPressed: onSubmit, child: const Text('Submit')), + ], + ); + } +} diff --git a/phone_app/lib/screens/ring_checklist.dart b/phone_app/lib/screens/ring_checklist.dart new file mode 100644 index 0000000..091de7f --- /dev/null +++ b/phone_app/lib/screens/ring_checklist.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; + +/// The three-step progress list shown while the room proof is incomplete. +/// +/// Showing every stage up front — including the ones not yet live — is +/// deliberate: a half-asleep user needs to see that a second tag is coming, +/// not discover it after walking back to bed. +class RingChecklist extends StatelessWidget { + /// Creates a checklist reflecting [gate]'s current stage. + const RingChecklist({required this.gate, super.key}); + + /// The sequence being displayed. + final DismissGate gate; + + static const List<(DismissStage, String)> _rows = <(DismissStage, String)>[ + (DismissStage.tag1, 'Tap tag 1'), + (DismissStage.steps, 'Walk 25 steps'), + (DismissStage.tag2, 'Tap tag 2'), + ]; + + /// Whether [stage] has already been passed. + bool _isDone(DismissStage stage) => stage.index < gate.stage.index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (stage, label) in _rows) + if (!(gate.bypassActive && stage != DismissStage.steps)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon( + _isDone(stage) + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: _isDone(stage) + ? theme.colorScheme.primary + : theme.disabledColor, + ), + const SizedBox(width: 12), + Text( + label, + style: gate.stage == stage + ? theme.textTheme.titleMedium + : theme.textTheme.bodyMedium, + ), + ], + ), + ), + if (gate.bypassActive) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'Tags unavailable — walk, then answer ' + '${gate.challengesRequired} questions instead.', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ); + } +} diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart index 28f3454..04f8667 100644 --- a/phone_app/lib/screens/ring_screen.dart +++ b/phone_app/lib/screens/ring_screen.dart @@ -2,19 +2,30 @@ import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/ring_challenge.dart'; +import 'package:wake_alarm_sync/screens/ring_checklist.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/room_proof.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +/// How often the live steps stage re-reads the step counter. +const Duration kStepPollInterval = Duration(seconds: 2); + /// The dismiss screen shown over the lockscreen while the alarm rings. /// -/// Two gates, in order: prove you are in the other room, then solve a -/// challenge. Only then does the sound stop. +/// A strict sequence: tag 1, then the walk, then tag 2, then the challenges. +/// `DismissGate` owns the ordering; this widget owns the clocks and the UI. class RingScreen extends StatefulWidget { - /// Creates the ring screen; both dependencies are injectable for tests. - const RingScreen({super.key, this.scheduler, this.generator}); + /// Creates the ring screen; every dependency is injectable for tests. + const RingScreen({ + super.key, + this.scheduler, + this.generator, + this.clock, + }); /// Platform bridge; defaults to the real MethodChannel. final AlarmScheduler? scheduler; @@ -22,6 +33,13 @@ class RingScreen extends StatefulWidget { /// Challenge source; defaults to a secure-random generator. final ChallengeGenerator? generator; + /// Time since the ring started; defaults to a real `Stopwatch`. + /// + /// Injectable because `Stopwatch` reads the wall clock, which + /// `tester.pump()` does not advance — without this seam no widget test can + /// reach the two-minute steps floor or the five-minute bypass. + final Duration Function()? clock; + @override State createState() => _RingScreenState(); } @@ -30,55 +48,91 @@ class _RingScreenState extends State { late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler(); late final ChallengeGenerator _generator = widget.generator ?? ChallengeGenerator(); - RoomProof _proof = RoomProof(scheduler: AlarmScheduler()); - RoomProofSettings _settings = const RoomProofSettings(); - bool _nfcAvailable = false; + late RoomProof _proof = RoomProof(scheduler: _scheduler); + final DismissGate _gate = DismissGate(); late Challenge _challenge = _generator.next(); final TextEditingController _input = TextEditingController(); - final Stopwatch _elapsed = Stopwatch()..start(); + final Stopwatch _stopwatch = Stopwatch()..start(); + late final Duration Function() _elapsed = + widget.clock ?? () => _stopwatch.elapsed; + Timer? _bypassTimer; + Timer? _stepTimer; - /// Whether the room proof has been satisfied; the challenge is gated on it. - bool _inRoom = false; String _status = ''; - @override void initState() { super.initState(); - _proof = RoomProof(scheduler: _scheduler); unawaited(_scheduler.beginStepCount()); unawaited(_loadRoomProof()); + // The escape hatch. Set up before anything can fail, so a crash in the + // settings load cannot leave an alarm with no way out. + _bypassTimer = Timer(DismissGate.bypassAfter, _onBypassDue); + _stepTimer = Timer.periodic(kStepPollInterval, (_) => _pollSteps()); } - /// Loads the registered tag/QR and starts listening for NFC taps. + /// Loads the registered tags and starts listening for NFC taps. Future _loadRoomProof() async { final settings = await RoomProofSettings.load(); final hasNfc = await _scheduler.hasNfc(); if (!mounted) return; setState(() { - _settings = settings; - _nfcAvailable = hasNfc; - _proof = RoomProof( - scheduler: _scheduler, - registeredTagId: settings.nfcTagId, - ); + _proof = RoomProof(scheduler: _scheduler, settings: settings); + // Nothing to wait for: without NFC or without two distinct tags the + // tag stages can never be satisfied, so making the user sit through + // the full timer would be pure punishment for a config problem. + if (!hasNfc || !settings.isComplete) { + _gate.activateBypass(); + _status = hasNfc + ? 'Register two NFC tags in Settings.' + : 'NFC is off or unavailable.'; + } }); _scheduler.onNfcTag(_onNfcTag); } + void _onBypassDue() { + if (!mounted) return; + if (!_gate.activateBypass()) return; + developer.log('bypass activated', name: 'WakeAlarmRing'); + setState(() => _status = 'Taking too long — tags skipped.'); + } + void _onNfcTag(String tagId) { - final result = _proof.checkNfc(tagId); + final slot = _gate.expectedTagSlot; + if (slot == null) return; + final result = _proof.checkNfc(tagId, slot: slot); developer.log( - 'nfc tag=$tagId satisfied=${result.satisfied} detail=${result.detail}', + 'nfc tag=$tagId slot=$slot satisfied=${result.satisfied} ' + 'detail=${result.detail}', name: 'WakeAlarmRing', ); if (!mounted) return; setState(() { - _inRoom = result.satisfied; + // Only a pass moves the sequence. A wrong tag reports itself and + // changes nothing — stages never un-latch. + if (result.satisfied) _gate.passTagStage(); _status = result.detail; }); - if (result.satisfied) _logChallenge(); + if (_gate.stage == DismissStage.challenges) _logChallenge(); + } + + /// Re-reads the step counter while the steps stage is live. + /// + /// Polled rather than button-driven: the walk to tag 1 has usually already + /// banked the steps, so the only thing still binding is the elapsed-time + /// floor. Making the user hunt for a button to discover that is friction + /// without difficulty. + Future _pollSteps() async { + if (_gate.stage != DismissStage.steps) return; + final result = await _proof.checkSteps(_elapsed()); + if (!mounted || _gate.stage != DismissStage.steps) return; + setState(() { + if (result.satisfied) _gate.passStepsStage(); + _status = result.detail; + }); + if (_gate.stage == DismissStage.challenges) _logChallenge(); } /// Logs the current challenge so a hardware test can be scripted. @@ -88,54 +142,42 @@ class _RingScreenState extends State { void _logChallenge() { developer.log( 'challenge kind=${_challenge.kind.name} display="${_challenge.display}" ' - 'answer=${_challenge.answer}', + 'answer=${_challenge.answer} ' + '${_gate.challengesPassed}/${_gate.challengesRequired}', name: 'WakeAlarmRing', ); } - /// Called by the QR scanner when a code is decoded. - void onQrScanned(String payload) { - final result = _proof.checkQr(payload, expected: _settings.qrPayload ?? ''); - if (!mounted) return; - setState(() { - _inRoom = result.satisfied; - _status = result.detail; - }); - } - @override void dispose() { + _bypassTimer?.cancel(); + _stepTimer?.cancel(); _scheduler.clearNfcListener(); _input.dispose(); - _elapsed.stop(); + _stopwatch.stop(); super.dispose(); } - Future _trySteps() async { - final result = await _proof.checkSteps(_elapsed.elapsed); - developer.log( - 'steps satisfied=${result.satisfied} detail=${result.detail}', - name: 'WakeAlarmRing', - ); - if (!mounted) return; - setState(() { - _inRoom = result.satisfied; - _status = result.detail; - }); - if (result.satisfied) _logChallenge(); - } - void _submit() { - if (!_challenge.accepts(_input.text)) { - setState(() { - _status = 'Wrong — here is another one'; + final correct = _challenge.accepts(_input.text); + setState(() { + _gate.recordChallenge(correct: correct); + _input.clear(); + // A wrong answer costs the attempt only; the tally never falls back. + if (_gate.isDone) { + _status = 'Dismissed'; + } else { + _status = correct + ? 'Correct — next one' + : 'Wrong — here is another one'; _challenge = _generator.next(); - _input.clear(); - }); - return; + } + }); + if (_gate.isDone) { + unawaited(_finishDismiss()); + } else { + _logChallenge(); } - setState(() => _status = 'Dismissed'); - unawaited(_finishDismiss()); } /// Stops the ring, then closes this screen. @@ -156,6 +198,7 @@ class _RingScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final inChallenges = _gate.stage == DismissStage.challenges; return PopScope( // The alarm cannot be escaped with back; dismissing is the only way out. canPop: false, @@ -168,7 +211,16 @@ class _RingScreenState extends State { children: [ Text('Wake up', style: theme.textTheme.headlineMedium), const SizedBox(height: 24), - if (!_inRoom) ..._roomGate(theme) else ..._challengeGate(theme), + if (inChallenges) + RingChallenge( + challenge: _challenge, + controller: _input, + passed: _gate.challengesPassed, + total: _gate.challengesRequired, + onSubmit: _submit, + ) + else + RingChecklist(gate: _gate), const SizedBox(height: 16), Text(_status, style: theme.textTheme.bodyMedium), ], @@ -179,41 +231,4 @@ class _RingScreenState extends State { ); } - List _roomGate(ThemeData theme) => [ - Text( - _nfcAvailable && _settings.nfcTagId != null - ? 'Go to the other room and tap the NFC tag.' - : 'Go to the other room: scan the QR code, or walk ' - '${RoomProof.requiredSteps} steps.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, - ), - const SizedBox(height: 16), - // The steps fallback stays reachable even when NFC is registered: a - // single failing mechanism at 06:00 must never make the alarm - // undismissable. - FilledButton(onPressed: _trySteps, child: const Text('Check steps')), - ]; - - List _challengeGate(ThemeData theme) => [ - Text(_challenge.hint, style: theme.textTheme.bodyLarge), - const SizedBox(height: 12), - Text( - // Always visible: a challenge that hides itself is unsolvable when the - // user reaches the screen seconds later, having walked to another room. - _challenge.display, - textAlign: TextAlign.center, - style: theme.textTheme.displaySmall, - ), - const SizedBox(height: 16), - TextField( - controller: _input, - autofocus: true, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration(labelText: 'Answer'), - onSubmitted: (_) => _submit(), - ), - const SizedBox(height: 16), - FilledButton(onPressed: _submit, child: const Text('Dismiss')), - ]; } diff --git a/phone_app/lib/screens/room_proof_screen.dart b/phone_app/lib/screens/room_proof_screen.dart index 2dc896a..951ebdc 100644 --- a/phone_app/lib/screens/room_proof_screen.dart +++ b/phone_app/lib/screens/room_proof_screen.dart @@ -1,12 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; -/// Registers the separate-room proofs: the NFC tag and the printed QR code. +/// Registers the two NFC tags the dismiss sequence walks between. /// -/// Without a registered tag, NFC can never satisfy the room proof and the -/// steps fallback becomes the only way to dismiss — which is exactly the -/// single-mechanism dependency the redundancy exists to avoid. +/// Both slots must hold **different** tags. With fewer than two distinct tags +/// the tag stages are unsatisfiable, so the ring screen falls straight through +/// to the bypass — a weaker morning, not a locked-out one. class RoomProofScreen extends StatefulWidget { /// Creates the screen; [scheduler] is injectable for tests. const RoomProofScreen({super.key, this.scheduler}); @@ -20,10 +22,9 @@ class RoomProofScreen extends StatefulWidget { class _RoomProofScreenState extends State { late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler(); - final TextEditingController _qr = TextEditingController(); - String? _tagId; - bool _scanning = false; + RoomProofSettings _settings = const RoomProofSettings(); + int? _scanningSlot; String _status = ''; @override @@ -34,48 +35,75 @@ class _RoomProofScreenState extends State { @override void dispose() { - if (_scanning) unawaited(_scheduler.stopTagRegistration()); + if (_scanningSlot != null) unawaited(_scheduler.stopTagRegistration()); _scheduler.clearNfcListener(); - _qr.dispose(); super.dispose(); } Future _load() async { final settings = await RoomProofSettings.load(); if (!mounted) return; - setState(() { - _tagId = settings.nfcTagId; - _qr.text = settings.qrPayload ?? ''; - }); + setState(() => _settings = settings); } - Future _startScan() async { - _scheduler.onNfcTag(_onTag); + Future _startScan(int slot) async { + _scheduler.onNfcTag((tagId) => unawaited(_onTag(slot, tagId))); final started = await _scheduler.startTagRegistration(); if (!mounted) return; setState(() { - _scanning = started; + _scanningSlot = started ? slot : null; _status = started - ? 'Tap the tag against the phone…' + ? 'Tap tag ${slot + 1} against the phone…' : 'NFC is unavailable or switched off.'; }); } - Future _onTag(String tagId) async { - await RoomProofSettings.saveNfcTag(tagId); + Future _onTag(int slot, String tagId) async { + // The same tag in both slots would let one tap satisfy both stages, which + // removes the walk between them — the entire point of having two. + final other = _settings.tagAt(slot == 0 ? 1 : 0); + if (tagId == other) { + await _scheduler.stopTagRegistration(); + if (!mounted) return; + setState(() { + _scanningSlot = null; + _status = 'That is already tag ${slot == 0 ? 2 : 1}. Use a different ' + 'tag, in a different room.'; + }); + return; + } + await RoomProofSettings.saveNfcTagAt(slot, tagId); + final settings = await RoomProofSettings.load(); await _scheduler.stopTagRegistration(); if (!mounted) return; setState(() { - _tagId = tagId; - _scanning = false; - _status = 'Tag registered.'; + _settings = settings; + _scanningSlot = null; + _status = 'Tag ${slot + 1} registered.'; }); } - Future _saveQr() async { - await RoomProofSettings.saveQrPayload(_qr.text.trim()); - if (!mounted) return; - setState(() => _status = 'QR code saved.'); + Widget _slotRow(int slot) { + final id = _settings.tagAt(slot); + final scanning = _scanningSlot == slot; + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Tag ${slot + 1}: ${id ?? "none registered"}'), + const SizedBox(height: 8), + FilledButton( + onPressed: _scanningSlot != null ? null : () => _startScan(slot), + child: Text( + scanning + ? 'Waiting for tag…' + : 'Register tag ${slot + 1}${id == null ? "" : " again"}', + ), + ), + ], + ), + ); } @override @@ -87,23 +115,13 @@ class _RoomProofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('NFC tag: ${_tagId ?? "none registered"}'), - const SizedBox(height: 12), - FilledButton( - onPressed: _scanning ? null : _startScan, - child: Text(_scanning ? 'Waiting for tag…' : 'Register NFC tag'), + const Text( + 'Put the two tags in different rooms. Dismissing the alarm ' + 'means tapping tag 1, walking, then tapping tag 2.', ), - const SizedBox(height: 32), - TextField( - controller: _qr, - decoration: const InputDecoration( - labelText: 'QR code text', - helperText: 'Text encoded in the code printed for that room', - ), - ), - const SizedBox(height: 12), - FilledButton(onPressed: _saveQr, child: const Text('Save QR code')), const SizedBox(height: 24), + for (var i = 0; i < RoomProofSettings.requiredTagCount; i++) + _slotRow(i), Text(_status), ], ), @@ -111,6 +129,3 @@ class _RoomProofScreenState extends State { ); } } - -/// Local helper so this file need not import dart:async for fire-and-forget. -void unawaited(Future future) {} diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index e015b2d..5271215 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -11,7 +11,7 @@ import 'package:wake_alarm_sync/services/google_sign_in_backend.dart'; /// "Sync settings" is the shared `sync_settings_ui` package (Firebase, /// identical in shape to every other kuhy app's Sync settings screen). /// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]), and -/// "Room proof" registers the NFC tag / QR code ([RoomProofScreen]) — without +/// "Room proof" registers the two NFC tags ([RoomProofScreen]) — without /// a registered tag, NFC can never satisfy the dismiss room proof. class SettingsScreen extends StatelessWidget { /// Creates a [SettingsScreen]. @@ -46,7 +46,7 @@ class SettingsScreen extends StatelessWidget { ListTile( contentPadding: EdgeInsets.zero, title: const Text('Room proof'), - subtitle: const Text('NFC tag and QR code for the other room'), + subtitle: const Text('Two NFC tags, at two locations'), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const RoomProofScreen()), diff --git a/phone_app/lib/services/challenge.dart b/phone_app/lib/services/challenge.dart index 79bb957..695d483 100644 --- a/phone_app/lib/services/challenge.dart +++ b/phone_app/lib/services/challenge.dart @@ -1,23 +1,12 @@ -/// Dismiss-challenge types, ported from the PC half's -/// `wake_alarm/_challenges.py` so both halves behave identically. +/// Dismiss-challenge types. /// -/// The two constants below are duplicated from `wake_alarm/_constants.py` -/// (`DISMISS_CODE_LENGTH`, `DISMISS_FLASH_SECONDS`) rather than synced — the -/// Dart side has no way to read Python. If they change there, change them here. +/// Every kind must keep its prompt **on screen until it is answered**. A +/// challenge that hides what it asked is unsolvable for someone who has just +/// walked in from another room, which is why there is no memorise-this kind. library; import 'dart:math'; -/// Length of a generated dismiss code. Mirrors `DISMISS_CODE_LENGTH`. -const int kDismissCodeLength = 10; - -/// Seconds a flash challenge stays visible. Mirrors `DISMISS_FLASH_SECONDS`. -const int kDismissFlashSeconds = 4; - -/// Uppercase alphanumerics with visually ambiguous characters removed: -/// O/0 and I/1 are excluded so the code is legible half-asleep. -const String _dismissChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - /// Words for the reverse challenge — common, unambiguous when read half-asleep. const List _reverseWords = [ 'MORNING', @@ -37,7 +26,7 @@ const List _reverseWords = [ 'WEEKEND', ]; -/// The five challenge kinds, matching the PC half's `kind` strings exactly. +/// The four challenge kinds. All keep their prompt visible while answering. enum ChallengeKind { /// Solve an arithmetic problem. math, @@ -45,9 +34,6 @@ enum ChallengeKind { /// Type shuffled digits in ascending order. sort, - /// Memorise a code before it is hidden. - flash, - /// Identify and continue a numeric pattern. sequence, @@ -92,26 +78,11 @@ class ChallengeGenerator { final Random _random; /// Picks a challenge kind at random and generates an instance. - /// - /// [flash] is deliberately **excluded**. It hides the code after - /// [kDismissFlashSeconds], and on the phone that is unsolvable in practice: - /// the ring screen is reached by walking to another room and tapping a tag, - /// so the code has usually already vanished by the time the user is looking - /// at the screen — reported from the phone on 2026-08-27 as "impossible to - /// actually do". Every challenge the alarm shows must stay on screen until - /// it is answered. [flash] is kept as a constructor for the PC half's - /// parity tests, but nothing routes to it here. Challenge next() { final builders = [math, sort, sequence, reverse]; return builders[_random.nextInt(builders.length)](); } - /// Generates a random dismiss code with ambiguous characters excluded. - String generateCode() => List.generate( - kDismissCodeLength, - (_) => _dismissChars[_random.nextInt(_dismissChars.length)], - ).join(); - /// An arithmetic problem: addition, subtraction, multiplication or division. Challenge math() { const hint = 'Solve and type the answer'; @@ -175,17 +146,6 @@ class ChallengeGenerator { ); } - /// A code shown briefly, then hidden; the user types it from memory. - Challenge flash() { - final code = generateCode(); - return Challenge( - kind: ChallengeKind.flash, - display: code, - answer: code, - hint: 'Memorise this code — it disappears in ${kDismissFlashSeconds}s', - ); - } - /// Four terms of an arithmetic or geometric sequence; the user gives /// the fifth term. Challenge sequence() { diff --git a/phone_app/lib/services/dismiss_gate.dart b/phone_app/lib/services/dismiss_gate.dart new file mode 100644 index 0000000..dbce195 --- /dev/null +++ b/phone_app/lib/services/dismiss_gate.dart @@ -0,0 +1,146 @@ +/// The stages of the dismiss sequence, in the order they must be satisfied. +enum DismissStage { + /// Tap the first registered tag. + tag1, + + /// Walk far enough, for long enough. + steps, + + /// Tap the second registered tag, somewhere else. + tag2, + + /// Answer challenges until the required count is reached. + challenges, + + /// Everything satisfied; the ring may stop. + done, +} + +/// The dismiss sequence: tag 1 -> steps -> tag 2 -> challenges. +/// +/// **Strictly ordered and latched.** Every stage is required and each one, +/// once passed, can never revert — an unknown or out-of-order tag is simply +/// ignored. The previous gate was an OR that reverted on a wrong tap, so a +/// mistimed tag could send a half-awake user back to the start. +/// +/// This owns sequencing only. Whether a given tag or step count actually +/// passes is `RoomProof`'s job, and generating questions is +/// `ChallengeGenerator`'s. +class DismissGate { + /// Creates a gate at the first stage. + DismissGate(); + + /// How long the alarm may ring before NFC stops being required. + /// + /// The escape hatch for a dead tag, an NFC radio that will not read, or a + /// tag that has fallen off the wall. Without it a hardware failure at 06:00 + /// leaves an alarm that literally cannot be switched off. + static const Duration bypassAfter = Duration(minutes: 5); + + /// Challenges required on the normal path. + static const int challengesNormal = 3; + + /// Challenges required once the NFC stages have been bypassed. + /// + /// Higher than [challengesNormal] so skipping the walk is never the cheap + /// option, even though it is always an available one. + static const int challengesBypass = 6; + + DismissStage _stage = DismissStage.tag1; + int _challengesPassed = 0; + int _challengesRequired = challengesNormal; + bool _bypassed = false; + + /// The stage currently awaiting satisfaction. + DismissStage get stage => _stage; + + /// Challenges answered correctly so far. + int get challengesPassed => _challengesPassed; + + /// Challenges needed in total; rises to [challengesBypass] under bypass. + int get challengesRequired => _challengesRequired; + + /// Whether the NFC stages have been skipped. + bool get bypassActive => _bypassed; + + /// Whether the alarm may now be dismissed. + bool get isDone => _stage == DismissStage.done; + + /// Whether a tag tap could advance anything right now. + bool get awaitingTag => + _stage == DismissStage.tag1 || _stage == DismissStage.tag2; + + /// Which registered tag slot the live stage expects, or null if none does. + /// + /// The ring screen passes this to `RoomProof.checkNfc` so that tapping the + /// *other* registered tag fails: two locations must stay two locations. + int? get expectedTagSlot => switch (_stage) { + DismissStage.tag1 => 0, + DismissStage.tag2 => 1, + _ => null, + }; + + /// Records that the tag for the live stage was tapped. + /// + /// Ignored unless a tag stage is live, so a tap arriving during the steps + /// stage or after bypass changes nothing. + bool passTagStage() { + if (_stage == DismissStage.tag1) { + _stage = DismissStage.steps; + return true; + } + if (_stage == DismissStage.tag2) { + _stage = DismissStage.challenges; + return true; + } + return false; + } + + /// Records that the step requirement was met. + /// + /// Ignored unless the steps stage is live: banking steps early must not + /// carry over, or walking to tag 1 would satisfy the walk by itself. + bool passStepsStage() { + if (_stage != DismissStage.steps) return false; + _stage = _bypassed ? DismissStage.challenges : DismissStage.tag2; + return true; + } + + /// Records a challenge answer; only a correct one counts. + /// + /// A wrong answer costs the attempt and nothing else — the count never + /// decreases, so guessing cannot dig a hole a half-asleep user can't climb. + bool recordChallenge({required bool correct}) { + if (_stage != DismissStage.challenges) return false; + if (!correct) return false; + _challengesPassed++; + if (_challengesPassed >= _challengesRequired) { + _stage = DismissStage.done; + } + return true; + } + + /// Drops the NFC requirement, raising the challenge count instead. + /// + /// **Only the two tag stages are skipped — the walk still counts.** Jumping + /// straight to the challenges would let someone lie in bed for + /// [bypassAfter] and then answer questions without ever standing up, which + /// is a weaker gate than the one this change replaced. + /// + /// One-way and idempotent. Deliberately refused once the challenge stage is + /// live: the user has already done the walk by then, and raising 3 to 6 + /// mid-answer would punish them for being slow at arithmetic. + bool activateBypass() { + if (_bypassed) return false; + if (_stage == DismissStage.challenges || _stage == DismissStage.done) { + return false; + } + _bypassed = true; + _challengesRequired = challengesBypass; + // Past tag 2 the steps are already banked; otherwise fall back to them. + _stage = _stage == DismissStage.tag2 + ? DismissStage.challenges + : DismissStage.steps; + return true; + } +} diff --git a/phone_app/lib/services/room_proof.dart b/phone_app/lib/services/room_proof.dart index 4334cf7..7b94ba0 100644 --- a/phone_app/lib/services/room_proof.dart +++ b/phone_app/lib/services/room_proof.dart @@ -1,13 +1,11 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/room_proof_settings.dart'; -/// Which mechanism satisfied the separate-room requirement. +/// Which mechanism satisfied a stage of the separate-room requirement. enum RoomProofKind { - /// Tapped the registered NFC tag in the other room. + /// Tapped the registered NFC tag for the stage that was live. nfc, - /// Scanned the printed QR code in the other room. - qr, - /// Walked far enough, for long enough, since the alarm started. steps, } @@ -17,7 +15,7 @@ class RoomProofResult { /// Creates a result; [kind] is null when nothing has satisfied yet. const RoomProofResult({required this.satisfied, this.kind, this.detail = ''}); - /// Whether the user has proven they are in the other room. + /// Whether this particular check passed. final bool satisfied; /// Which mechanism satisfied it, or null if none did. @@ -27,66 +25,60 @@ class RoomProofResult { final String detail; } -/// Proof that the user physically walked to another room before dismissing. +/// The individual checks behind the dismiss sequence. /// -/// **Any one of NFC or QR satisfies.** Steps alone deliberately does NOT: the -/// phone is stationary in the other room while you walk *to* it, so steps only -/// accrue after pickup and can be faked by shaking it in bed. Steps are only -/// accepted together with elapsed time, as the fallback for when both other -/// mechanisms fail — a single failing mechanism at 06:00 must never make the -/// alarm undismissable. +/// **Every check is required, in order** — see `DismissGate`, which owns the +/// sequencing. This class only answers "did this one thing pass right now". +/// +/// Steps deliberately require elapsed time as well as a count: the phone is +/// stationary while you walk *to* it, so a bare step count can be faked by +/// shaking it in bed. The pair is unchanged from when steps were a fallback. class RoomProof { /// Creates a room-proof checker over [scheduler]'s platform channel. - RoomProof({required this.scheduler, this._registeredTagId}); + RoomProof({ + required this.scheduler, + this.settings = const RoomProofSettings(), + }); /// Platform bridge used to read the step counter. final AlarmScheduler scheduler; - final String? _registeredTagId; - /// Steps required by the fallback, counted since the alarm started ringing. + /// The registered tags, one per sequence stage. + final RoomProofSettings settings; + + /// Steps required, counted since the alarm started ringing. static const int requiredSteps = 25; - /// Elapsed time the fallback also requires, so shaking the phone cannot pass. + /// Elapsed time also required, so shaking the phone cannot pass. static const Duration requiredElapsed = Duration(minutes: 2); - /// Whether [tagId] is the tag registered in Settings. + /// Whether [tagId] is the tag registered for [slot]. /// - /// Registering no tag means NFC cannot satisfy the proof — it does not mean - /// every tag passes. - RoomProofResult checkNfc(String tagId) { - final registered = _registeredTagId; - if (registered == null || registered.isEmpty) { - return const RoomProofResult( + /// A tag registered for a *different* slot fails here: tapping tag 2 while + /// stage 1 is live must not advance anything, or the two locations collapse + /// into one. + RoomProofResult checkNfc(String tagId, {required int slot}) { + final expected = settings.tagAt(slot); + if (expected == null) { + return RoomProofResult( satisfied: false, - detail: 'No NFC tag registered in Settings', + detail: 'No tag ${slot + 1} registered in Settings', ); } - if (tagId != registered) { - return const RoomProofResult(satisfied: false, detail: 'Wrong NFC tag'); + if (tagId != expected) { + return RoomProofResult( + satisfied: false, + detail: 'Not tag ${slot + 1}', + ); } - return const RoomProofResult( + return RoomProofResult( satisfied: true, kind: RoomProofKind.nfc, - detail: 'NFC tag matched', + detail: 'Tag ${slot + 1} matched', ); } - /// Whether [payload] is the expected printed QR code. - /// - /// A printed code is photographable, which is exactly why it is one of two - /// accepted proofs rather than the only one. - RoomProofResult checkQr(String payload, {required String expected}) { - if (expected.isEmpty || payload != expected) { - return const RoomProofResult(satisfied: false, detail: 'Wrong QR code'); - } - return const RoomProofResult( - satisfied: true, - kind: RoomProofKind.qr, - detail: 'QR code matched', - ); - } - - /// The fallback: enough steps AND enough elapsed time since the ring started. + /// Enough steps AND enough elapsed time since the ring started. /// /// Both are required together. [elapsed] is passed in rather than measured /// here so the ring screen owns the single clock. diff --git a/phone_app/lib/services/room_proof_settings.dart b/phone_app/lib/services/room_proof_settings.dart index e9be5fc..f0211c9 100644 --- a/phone_app/lib/services/room_proof_settings.dart +++ b/phone_app/lib/services/room_proof_settings.dart @@ -1,43 +1,91 @@ +import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; -/// Persisted room-proof configuration: the registered NFC tag and QR payload. +/// Persisted room-proof configuration: the two registered NFC tags. /// -/// Registering nothing means that mechanism cannot satisfy the proof — it does -/// not mean everything passes. See `RoomProof` for why redundancy matters. +/// Two tags, in two different places, are what force the walk. Registering +/// fewer than [requiredTagCount] does not weaken the sequence into something +/// easier — it makes the NFC stages unsatisfiable, which is why the ring +/// screen drops straight to the bypass when [isComplete] is false. class RoomProofSettings { - /// Creates settings with the registered values, if any. - const RoomProofSettings({this.nfcTagId, this.qrPayload}); + /// Creates settings from the registered tag ids, one per slot. + const RoomProofSettings({this.nfcTagIds = const ['', '']}); - /// SharedPreferences key for the registered NFC tag id. - static const String kNfcTagKey = 'roomProof.nfcTagId'; + /// SharedPreferences key holding the tag ids as a JSON list. + static const String kNfcTagsKey = 'roomProof.nfcTagIds'; - /// SharedPreferences key for the expected QR payload. - static const String kQrKey = 'roomProof.qrPayload'; + /// The pre-two-tag key, migrated into slot 0 on first load. + static const String kLegacyNfcTagKey = 'roomProof.nfcTagId'; - /// Lower-case hex id of the tag taped up in the other room. - final String? nfcTagId; + /// How many tags the strict sequence needs. + static const int requiredTagCount = 2; - /// Text encoded in the printed QR code in the other room. - final String? qrPayload; + /// Registered tag ids by slot; an empty string means that slot is unset. + /// + /// Always [requiredTagCount] long, so slot writes never have to grow it. + final List nfcTagIds; - /// Loads the stored configuration. + /// The tag registered for [slot], or null when that slot is unset. + String? tagAt(int slot) { + if (slot < 0 || slot >= nfcTagIds.length) return null; + final id = nfcTagIds[slot]; + return id.isEmpty ? null : id; + } + + /// Whether every slot holds a distinct tag, so the sequence is satisfiable. + /// + /// Distinctness matters: two slots holding the same id would let one tap + /// satisfy both stages, collapsing the walk this whole gate exists to force. + bool get isComplete { + final ids = {}; + for (var slot = 0; slot < requiredTagCount; slot++) { + final id = tagAt(slot); + if (id == null || !ids.add(id)) return false; + } + return true; + } + + /// Normalises [raw] to exactly [requiredTagCount] slots. + static List _padded(List raw) => List.generate( + requiredTagCount, + (i) => i < raw.length ? raw[i] : '', + ); + + /// Loads the stored configuration, migrating a single legacy tag if present. static Future load() async { final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(kNfcTagsKey); + if (stored != null) { + return RoomProofSettings(nfcTagIds: _padded(_decode(stored))); + } + // One-time migration: an existing install has a tag registered under the + // old key, and silently losing it would leave the user unable to dismiss + // by the normal path on the first morning after the update. + final legacy = prefs.getString(kLegacyNfcTagKey); return RoomProofSettings( - nfcTagId: prefs.getString(kNfcTagKey), - qrPayload: prefs.getString(kQrKey), + nfcTagIds: _padded([?legacy]), ); } - /// Stores [tagId] as the registered NFC tag. - static Future saveNfcTag(String tagId) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(kNfcTagKey, tagId); + /// Decodes the stored JSON list, treating anything malformed as unset. + static List _decode(String stored) { + try { + final decoded = jsonDecode(stored); + if (decoded is! List) return const []; + return decoded.whereType().toList(); + } on FormatException { + return const []; + } } - /// Stores [payload] as the expected QR code text. - static Future saveQrPayload(String payload) async { + /// Stores [tagId] in [slot], leaving the other slot untouched. + static Future saveNfcTagAt(int slot, String tagId) async { + final current = await load(); + final next = List.of(current.nfcTagIds); + if (slot < 0 || slot >= next.length) return; + next[slot] = tagId; final prefs = await SharedPreferences.getInstance(); - await prefs.setString(kQrKey, payload); + await prefs.setString(kNfcTagsKey, jsonEncode(next)); } } diff --git a/phone_app/test/ring_bypass_test.dart b/phone_app/test/ring_bypass_test.dart new file mode 100644 index 0000000..62319a8 --- /dev/null +++ b/phone_app/test/ring_bypass_test.dart @@ -0,0 +1,116 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/ring_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/challenge.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; +import 'package:wake_alarm_sync/services/room_proof_settings.dart'; + +const String _tag1 = '04a2b3c4'; +const String _tag2 = '04ffee11'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + var steps = 0; + var hasNfc = true; + var elapsed = Duration.zero; + + /// Installs the platform mock. Call after changing [steps] / [hasNfc]. + void mockChannel() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call.method); + if (call.method == 'stepCount') return steps; + if (call.method == 'hasNfc') return hasNfc; + return null; + }, + ); + } + + /// Lets the periodic step poll run once. + Future pollSteps(WidgetTester tester) async { + await tester.pump(kStepPollInterval); + await tester.pump(); + } + + setUp(() { + calls = []; + steps = 0; + hasNfc = true; + elapsed = Duration.zero; + SharedPreferences.setMockInitialValues({}); + mockChannel(); + }); + + /// A ring screen with both tags registered and a controllable clock. + Future pumpRing(WidgetTester tester) async { + await RoomProofSettings.saveNfcTagAt(0, _tag1); + await RoomProofSettings.saveNfcTagAt(1, _tag2); + await tester.pumpWidget( + MaterialApp( + home: RingScreen( + generator: ChallengeGenerator(random: Random(1)), + clock: () => elapsed, + ), + ), + ); + await tester.pump(); + return ChallengeGenerator(random: Random(1)); + } + + testWidgets('it is not offered before the timer elapses', (tester) async { + await pumpRing(tester); + await tester.pump(DismissGate.bypassAfter - const Duration(seconds: 1)); + expect(find.textContaining('Tags unavailable'), findsNothing); + expect(find.text('Tap tag 1'), findsOneWidget); + }); + + testWidgets('after the timer it skips the tags but still needs the walk', ( + tester, + ) async { + await pumpRing(tester); + await tester.pump(DismissGate.bypassAfter); + await tester.pump(); + + expect(find.textContaining('Tags unavailable'), findsOneWidget); + expect(find.text('Tap tag 1'), findsNothing); + expect(find.text('Walk 25 steps'), findsOneWidget); + expect(find.text('Submit'), findsNothing); + + steps = 500; + elapsed = const Duration(minutes: 6); + await pollSteps(tester); + expect(find.text('Question 1 of 6'), findsOneWidget); + }); + + testWidgets('an unregistered second tag bypasses immediately', ( + tester, + ) async { + await RoomProofSettings.saveNfcTagAt(0, _tag1); + await tester.pumpWidget( + MaterialApp(home: RingScreen(clock: () => elapsed)), + ); + await tester.pump(); + expect(find.textContaining('Register two NFC tags'), findsOneWidget); + expect(find.textContaining('Tags unavailable'), findsOneWidget); + }); + + testWidgets('no NFC hardware bypasses immediately', (tester) async { + hasNfc = false; + mockChannel(); + await pumpRing(tester); + expect(find.textContaining('NFC is off'), findsOneWidget); + expect(find.text('Tap tag 1'), findsNothing); + }); +} + +/// Local helper so the test need not import dart:async for one call. +void unawaited(Future future) {} diff --git a/phone_app/test/ring_route_test.dart b/phone_app/test/ring_route_test.dart index 2e89971..8ea6c39 100644 --- a/phone_app/test/ring_route_test.dart +++ b/phone_app/test/ring_route_test.dart @@ -8,22 +8,78 @@ import 'package:wake_alarm_sync/main.dart'; import 'package:wake_alarm_sync/screens/ring_screen.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +const String _tag1 = '04a2b3c4'; +const String _tag2 = '04ffee11'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUp(() { - SharedPreferences.setMockInitialValues({}); - const channel = MethodChannel(AlarmScheduler.channelName); + late List calls; + var steps = 0; + var hasNfc = true; + var elapsed = Duration.zero; + + /// Installs the platform mock. Call after changing [steps] / [hasNfc]. + void mockChannel() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'stepCount') return 0; - if (call.method == 'hasNfc') return false; - return null; - }); + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call.method); + if (call.method == 'stepCount') return steps; + if (call.method == 'hasNfc') return hasNfc; + return null; + }, + ); + } + + /// Pushes a tag up from Kotlin exactly as `NfcReader` does. + Future tapTag(WidgetTester tester, String id) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + AlarmScheduler.channelName, + const StandardMethodCodec().encodeMethodCall( + MethodCall('onNfcTag', id), + ), + (_) {}, + ); + await tester.pump(); + } + + /// Lets the periodic step poll run once. + Future pollSteps(WidgetTester tester) async { + await tester.pump(kStepPollInterval); + await tester.pump(); + } + + setUp(() { + calls = []; + steps = 0; + hasNfc = true; + elapsed = Duration.zero; + SharedPreferences.setMockInitialValues({}); + mockChannel(); }); + /// A ring screen with both tags registered and a controllable clock. + Future pumpRing(WidgetTester tester) async { + await RoomProofSettings.saveNfcTagAt(0, _tag1); + await RoomProofSettings.saveNfcTagAt(1, _tag2); + await tester.pumpWidget( + MaterialApp( + home: RingScreen( + generator: ChallengeGenerator(random: Random(1)), + clock: () => elapsed, + ), + ), + ); + await tester.pump(); + return ChallengeGenerator(random: Random(1)); + } + testWidgets('the /ring route resolves to the ring screen', (tester) async { // AlarmActivity hands "/ring" to the engine as its initial route; if the // app does not resolve it the full-screen intent opens the home screen and @@ -36,148 +92,117 @@ void main() { expect(find.byType(RingScreen), findsOneWidget); }); - testWidgets('the ring screen shows the room gate before the challenge', ( - tester, - ) async { - await tester.pumpWidget(const MaterialApp(home: RingScreen())); - await tester.pumpAndSettle(); - + testWidgets('it opens on the checklist, not the challenge', (tester) async { + await pumpRing(tester); expect(find.text('Wake up'), findsOneWidget); - expect(find.text('Check steps'), findsOneWidget); - // The challenge must not be reachable until the room proof is satisfied. - expect(find.text('Dismiss'), findsNothing); + expect(find.text('Tap tag 1'), findsOneWidget); + expect(find.text('Walk 25 steps'), findsOneWidget); + expect(find.text('Tap tag 2'), findsOneWidget); + expect(find.text('Submit'), findsNothing); }); - testWidgets('a registered NFC tag tap satisfies the proof and reveals ' - 'the challenge', (tester) async { - SharedPreferences.setMockInitialValues({ - RoomProofSettings.kNfcTagKey: '04a2b3c4', - }); - const channel = MethodChannel(AlarmScheduler.channelName); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'hasNfc') return true; - if (call.method == 'stepCount') return 0; - return null; - }); - - await tester.pumpWidget(const MaterialApp(home: RingScreen())); - await tester.pumpAndSettle(); - expect(find.textContaining('tap the NFC tag'), findsOneWidget); - - // Simulate the tag arriving over the channel, as NfcReader pushes it. - await TestDefaultBinaryMessengerBinding - .instance - .defaultBinaryMessenger - .handlePlatformMessage( - AlarmScheduler.channelName, - const StandardMethodCodec().encodeMethodCall( - const MethodCall('onNfcTag', '04a2b3c4'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - - expect(find.text('Dismiss'), findsOneWidget); - }); - - testWidgets('a foreign NFC tag does not satisfy the proof', (tester) async { - SharedPreferences.setMockInitialValues({ - RoomProofSettings.kNfcTagKey: '04a2b3c4', - }); - const channel = MethodChannel(AlarmScheduler.channelName); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'hasNfc') return true; - if (call.method == 'stepCount') return 0; - return null; - }); - - await tester.pumpWidget(const MaterialApp(home: RingScreen())); - await tester.pumpAndSettle(); - await TestDefaultBinaryMessengerBinding - .instance - .defaultBinaryMessenger - .handlePlatformMessage( - AlarmScheduler.channelName, - const StandardMethodCodec().encodeMethodCall( - const MethodCall('onNfcTag', 'deadbeef'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - - expect(find.text('Dismiss'), findsNothing); - expect(find.text('Wrong NFC tag'), findsOneWidget); - }); - - testWidgets('a correct answer stops the ring AND closes the screen', ( + testWidgets('tag 2 tapped first does nothing; tag 1 then works', ( tester, ) async { + await pumpRing(tester); + await tapTag(tester, _tag2); + expect(find.textContaining('Not tag 1'), findsOneWidget); + + await tapTag(tester, _tag1); + expect(find.textContaining('Tag 1 matched'), findsOneWidget); + }); + + testWidgets('an unknown tag does not un-latch a passed stage', ( + tester, + ) async { + // The regression this change exists to prevent: the old gate assigned the + // room-proof result straight onto a bool, so a wrong tap after passing + // sent the user back to the start. + await pumpRing(tester); + await tapTag(tester, _tag1); + await tapTag(tester, 'deadbeef'); + // Still past tag 1: the steps row is live, not the tag 1 row. + steps = 500; + elapsed = const Duration(minutes: 3); + await pollSteps(tester); + expect(find.text('Tap tag 2'), findsOneWidget); + }); + + testWidgets('steps do not count before tag 1', (tester) async { + await pumpRing(tester); + steps = 500; + elapsed = const Duration(minutes: 3); + await pollSteps(tester); + // Still on tag 1 — walking first must not skip the first location. + expect(find.text('Submit'), findsNothing); + await tapTag(tester, _tag2); + expect(find.textContaining('Not tag 1'), findsOneWidget); + }); + + testWidgets('the walk needs elapsed time, not just a step count', ( + tester, + ) async { + await pumpRing(tester); + await tapTag(tester, _tag1); + steps = 500; + elapsed = const Duration(seconds: 5); + await pollSteps(tester); + expect(find.textContaining('Keep moving'), findsOneWidget); + expect(find.text('Tap tag 2'), findsOneWidget); + }); + + testWidgets('the full sequence dismisses: tag 1, walk, tag 2, 3 answers', ( + tester, + ) async { + final expected = await pumpRing(tester); + + await tapTag(tester, _tag1); + steps = 500; + elapsed = const Duration(minutes: 3); + await pollSteps(tester); + await tapTag(tester, _tag2); + + expect(find.text('Question 1 of 3'), findsOneWidget); + for (var i = 1; i <= DismissGate.challengesNormal; i++) { + expect(find.text('Question $i of 3'), findsOneWidget); + await tester.enterText(find.byType(TextField), expected.next().answer); + await tester.tap(find.text('Submit')); + await tester.pump(); + } + // Closing is load-bearing: back is disabled in both halves, so a screen - // left up after a correct answer is a dead end over the lockscreen, and - // the live instance would carry the satisfied proof into the next ring. - SharedPreferences.setMockInitialValues({ - RoomProofSettings.kNfcTagKey: '04a2b3c4', - }); - final calls = []; - const channel = MethodChannel(AlarmScheduler.channelName); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call.method); - if (call.method == 'hasNfc') return true; - if (call.method == 'stepCount') return 0; - return null; - }); - - // A fixed generator so the expected answer is known. - final generator = ChallengeGenerator(random: Random(1)); - final challenge = ChallengeGenerator(random: Random(1)).next(); - - await tester.pumpWidget( - MaterialApp(home: RingScreen(generator: generator)), - ); - await tester.pumpAndSettle(); - await TestDefaultBinaryMessengerBinding - .instance - .defaultBinaryMessenger - .handlePlatformMessage( - AlarmScheduler.channelName, - const StandardMethodCodec().encodeMethodCall( - const MethodCall('onNfcTag', '04a2b3c4'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField), challenge.answer); - await tester.tap(find.text('Dismiss')); - await tester.pumpAndSettle(); - + // left up after the last answer is a dead end over the lockscreen. expect(calls, contains('dismiss')); expect(calls, contains('closeRingScreen')); }); - testWidgets('satisfying the room proof reveals the challenge', ( + testWidgets('a wrong answer costs the attempt but not the tally', ( tester, ) async { - const channel = MethodChannel(AlarmScheduler.channelName); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'stepCount') return 500; - if (call.method == 'hasNfc') return false; - return null; - }); + final expected = await pumpRing(tester); + await tapTag(tester, _tag1); + steps = 500; + elapsed = const Duration(minutes: 3); + await pollSteps(tester); + await tapTag(tester, _tag2); - await tester.pumpWidget(const MaterialApp(home: RingScreen())); - await tester.pumpAndSettle(); - // The stopwatch starts at zero, so steps alone must not be enough yet. - await tester.tap(find.text('Check steps')); - await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), expected.next().answer); + await tester.tap(find.text('Submit')); + await tester.pump(); + expect(find.text('Question 2 of 3'), findsOneWidget); + expected.next(); // the screen regenerated after the correct answer - expect(find.text('Dismiss'), findsNothing); - expect(find.textContaining('Keep moving'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'definitely wrong'); + await tester.tap(find.text('Submit')); + await tester.pump(); + expect( + find.text('Question 2 of 3'), + findsOneWidget, + reason: 'cumulative: a wrong answer must not reset the count', + ); + expect(calls, isNot(contains('closeRingScreen'))); }); + } /// Local helper so the test need not import dart:async for one call. diff --git a/phone_app/test/room_proof_screen_test.dart b/phone_app/test/room_proof_screen_test.dart index b628ec9..7fcd18f 100644 --- a/phone_app/test/room_proof_screen_test.dart +++ b/phone_app/test/room_proof_screen_test.dart @@ -26,12 +26,14 @@ void main() { }); }); - testWidgets('shows that no tag is registered on a fresh install', ( + testWidgets('shows both slots unregistered on a fresh install', ( tester, ) async { await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); await tester.pumpAndSettle(); - expect(find.textContaining('none registered'), findsOneWidget); + expect(find.textContaining('none registered'), findsNWidgets(2)); + expect(find.text('Register tag 1'), findsOneWidget); + expect(find.text('Register tag 2'), findsOneWidget); }); testWidgets('starting a scan puts the reader into waiting state', ( @@ -39,11 +41,29 @@ void main() { ) async { await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); await tester.pumpAndSettle(); - await tester.tap(find.text('Register NFC tag')); + await tester.tap(find.text('Register tag 1')); await tester.pumpAndSettle(); expect(calls, contains('startTagRegistration')); - expect(find.textContaining('Tap the tag'), findsOneWidget); + expect(find.textContaining('Tap tag 1'), findsOneWidget); + }); + + testWidgets('only the scanning row is disabled while waiting', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); + await tester.pumpAndSettle(); + await tester.tap(find.text('Register tag 2')); + await tester.pumpAndSettle(); + + expect(find.text('Waiting for tag…'), findsOneWidget); + final other = tester.widget( + find.ancestor( + of: find.text('Register tag 1'), + matching: find.byType(FilledButton), + ), + ); + expect(other.onPressed, isNull, reason: 'one scan at a time'); }); testWidgets('says so when the device cannot scan, rather than waiting', ( @@ -52,29 +72,21 @@ void main() { nfcStarts = false; await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); await tester.pumpAndSettle(); - await tester.tap(find.text('Register NFC tag')); + await tester.tap(find.text('Register tag 1')); await tester.pumpAndSettle(); expect(find.textContaining('unavailable'), findsOneWidget); }); - testWidgets('saving a QR payload persists it', (tester) async { - await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'kitchen'); - await tester.tap(find.text('Save QR code')); - await tester.pumpAndSettle(); - - expect((await RoomProofSettings.load()).qrPayload, 'kitchen'); - expect(find.text('QR code saved.'), findsOneWidget); - }); - - testWidgets('a registered tag is shown when reopening the screen', ( + testWidgets('each registered tag is shown against its own slot', ( tester, ) async { - await RoomProofSettings.saveNfcTag('04a2b3c4'); + await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4'); + await RoomProofSettings.saveNfcTagAt(1, '04ffee11'); await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); await tester.pumpAndSettle(); - expect(find.textContaining('04a2b3c4'), findsOneWidget); + expect(find.text('Tag 1: 04a2b3c4'), findsOneWidget); + expect(find.text('Tag 2: 04ffee11'), findsOneWidget); + expect(find.text('Register tag 1 again'), findsOneWidget); }); } diff --git a/phone_app/test/services/challenge_test.dart b/phone_app/test/services/challenge_test.dart index 837b31c..5ce8207 100644 --- a/phone_app/test/services/challenge_test.dart +++ b/phone_app/test/services/challenge_test.dart @@ -53,12 +53,6 @@ void main() { }); group('ChallengeGenerator', () { - test('generateCode has the mirrored length and no ambiguous chars', () { - final code = ChallengeGenerator().generateCode(); - expect(code, hasLength(kDismissCodeLength)); - expect(RegExp(r'^[A-HJ-NP-Z2-9]+$').hasMatch(code), isTrue); - }); - test('math covers addition', () { final c = ChallengeGenerator(random: _FixedRandom([0, 5, 7])).math(); expect(c.kind, ChallengeKind.math); @@ -101,13 +95,6 @@ void main() { expect(c.answer.split('').toSet(), hasLength(8)); }); - test('flash displays the code it expects back', () { - final c = ChallengeGenerator().flash(); - expect(c.kind, ChallengeKind.flash); - expect(c.display, c.answer); - expect(c.hint, contains('${kDismissFlashSeconds}s')); - }); - test('sequence covers the arithmetic branch', () { final c = ChallengeGenerator( random: _FixedRandom([0, 3, 2]), @@ -142,15 +129,16 @@ void main() { expect(c.answer, word.split('').reversed.join()); }); - test('next() never returns a challenge that hides itself', () { - // flash disappears after kDismissFlashSeconds, which is unsolvable on - // the phone: the user reaches the ring screen only after walking to - // another room and tapping a tag. - final kinds = {}; + test('every kind keeps its prompt on screen while it is answered', () { + // The memorise-then-hide kind was deleted outright rather than merely + // left out of the rotation, so this is now an enum-level guarantee: a + // challenge that hides itself is unsolvable for someone who has just + // walked in from another room. for (var i = 0; i < 400; i++) { - kinds.add(ChallengeGenerator().next().kind); + final c = ChallengeGenerator().next(); + expect(c.display, isNotEmpty, reason: '${c.kind} showed nothing'); } - expect(kinds, isNot(contains(ChallengeKind.flash))); + expect(ChallengeKind.values, hasLength(4)); }); test('next() produces every kind that is safe to show', () { diff --git a/phone_app/test/services/dismiss_gate_test.dart b/phone_app/test/services/dismiss_gate_test.dart new file mode 100644 index 0000000..009b295 --- /dev/null +++ b/phone_app/test/services/dismiss_gate_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; + +/// Drives a fresh gate through tag 1 -> steps -> tag 2, onto challenges. +DismissGate _atChallenges() { + final gate = DismissGate() + ..passTagStage() + ..passStepsStage() + ..passTagStage(); + expect(gate.stage, DismissStage.challenges); + return gate; +} + +void main() { + group('strict ordering', () { + test('a fresh gate waits for tag 1', () { + final gate = DismissGate(); + expect(gate.stage, DismissStage.tag1); + expect(gate.expectedTagSlot, 0); + expect(gate.isDone, isFalse); + }); + + test('steps before tag 1 do not advance anything', () { + final gate = DismissGate(); + expect(gate.passStepsStage(), isFalse); + expect(gate.stage, DismissStage.tag1); + }); + + test('the sequence runs tag 1 -> steps -> tag 2 -> challenges', () { + final gate = DismissGate(); + expect(gate.passTagStage(), isTrue); + expect(gate.stage, DismissStage.steps); + expect(gate.expectedTagSlot, isNull, reason: 'no tag stage is live'); + expect(gate.passStepsStage(), isTrue); + expect(gate.stage, DismissStage.tag2); + expect(gate.expectedTagSlot, 1); + expect(gate.passTagStage(), isTrue); + expect(gate.stage, DismissStage.challenges); + }); + + test('a tag tapped during the steps stage does not advance it', () { + final gate = DismissGate()..passTagStage(); + expect(gate.stage, DismissStage.steps); + expect(gate.passTagStage(), isFalse); + expect(gate.stage, DismissStage.steps, reason: 'steps still required'); + }); + }); + + group('latching', () { + test('a passed stage never reverts', () { + // The bug this replaces: the old gate assigned the room-proof result + // straight onto a bool, so a wrong tap after passing hid the challenge. + final gate = _atChallenges(); + expect(gate.passTagStage(), isFalse); + expect(gate.passStepsStage(), isFalse); + expect(gate.stage, DismissStage.challenges); + }); + + test('finishing does not un-finish', () { + final gate = _atChallenges(); + for (var i = 0; i < DismissGate.challengesNormal; i++) { + gate.recordChallenge(correct: true); + } + expect(gate.isDone, isTrue); + expect(gate.recordChallenge(correct: false), isFalse); + expect(gate.isDone, isTrue); + }); + }); + + group('challenges', () { + test('one correct answer is not enough; three are', () { + final gate = _atChallenges()..recordChallenge(correct: true); + expect(gate.isDone, isFalse); + gate.recordChallenge(correct: true); + expect(gate.isDone, isFalse); + gate.recordChallenge(correct: true); + expect(gate.isDone, isTrue); + }); + + test('a wrong answer does NOT reset the tally', () { + final gate = _atChallenges() + ..recordChallenge(correct: true) + ..recordChallenge(correct: true) + ..recordChallenge(correct: false); + expect(gate.challengesPassed, 2, reason: 'cumulative, not consecutive'); + gate.recordChallenge(correct: true); + expect(gate.isDone, isTrue); + }); + + test('answers before the challenge stage are ignored', () { + final gate = DismissGate(); + expect(gate.recordChallenge(correct: true), isFalse); + expect(gate.challengesPassed, 0); + }); + }); + + group('bypass', () { + test('it drops the tags but NOT the walk', () { + final gate = DismissGate(); + expect(gate.activateBypass(), isTrue); + expect(gate.bypassActive, isTrue); + expect(gate.stage, DismissStage.steps, reason: 'steps still required'); + expect(gate.challengesRequired, DismissGate.challengesBypass); + }); + + test('six answers are needed once bypassed, and five are not', () { + final gate = DismissGate() + ..activateBypass() + ..passStepsStage(); + expect(gate.stage, DismissStage.challenges, reason: 'tag 2 is skipped'); + for (var i = 0; i < DismissGate.challengesBypass - 1; i++) { + gate.recordChallenge(correct: true); + } + expect(gate.isDone, isFalse); + gate.recordChallenge(correct: true); + expect(gate.isDone, isTrue); + }); + + test('bypassing after tag 1 still requires the walk', () { + final gate = DismissGate() + ..passTagStage() + ..activateBypass(); + expect(gate.stage, DismissStage.steps); + gate.passStepsStage(); + expect(gate.stage, DismissStage.challenges); + }); + + test('bypassing at tag 2 keeps the already-banked steps', () { + final gate = DismissGate() + ..passTagStage() + ..passStepsStage(); + expect(gate.stage, DismissStage.tag2); + gate.activateBypass(); + expect(gate.stage, DismissStage.challenges); + }); + + test('it cannot fire once the challenges are live', () { + // Raising 3 to 6 mid-answer would punish someone who did the walk. + final gate = _atChallenges(); + expect(gate.activateBypass(), isFalse); + expect(gate.challengesRequired, DismissGate.challengesNormal); + }); + + test('it is one-way and idempotent', () { + final gate = DismissGate(); + expect(gate.activateBypass(), isTrue); + expect(gate.activateBypass(), isFalse); + expect(gate.challengesRequired, DismissGate.challengesBypass); + }); + + test('tags do nothing once bypassed', () { + final gate = DismissGate()..activateBypass(); + expect(gate.expectedTagSlot, isNull); + expect(gate.passTagStage(), isFalse); + expect(gate.stage, DismissStage.steps); + }); + }); + + test('the bypass is strictly harder than the normal path', () { + expect( + DismissGate.challengesBypass, + greaterThan(DismissGate.challengesNormal), + ); + }); +} diff --git a/phone_app/test/services/room_proof_settings_test.dart b/phone_app/test/services/room_proof_settings_test.dart index d1471e4..eee570d 100644 --- a/phone_app/test/services/room_proof_settings_test.dart +++ b/phone_app/test/services/room_proof_settings_test.dart @@ -7,27 +7,64 @@ void main() { setUp(() => SharedPreferences.setMockInitialValues({})); - test('an unconfigured install registers neither proof', () async { + test('an unconfigured install registers no tags', () async { final settings = await RoomProofSettings.load(); - expect(settings.nfcTagId, isNull); - expect(settings.qrPayload, isNull); + expect(settings.tagAt(0), isNull); + expect(settings.tagAt(1), isNull); + expect(settings.isComplete, isFalse); }); - test('a saved NFC tag round-trips', () async { - await RoomProofSettings.saveNfcTag('04a2b3c4'); - expect((await RoomProofSettings.load()).nfcTagId, '04a2b3c4'); + test('each slot round-trips independently', () async { + await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4'); + expect((await RoomProofSettings.load()).tagAt(0), '04a2b3c4'); + expect((await RoomProofSettings.load()).tagAt(1), isNull); + + await RoomProofSettings.saveNfcTagAt(1, '04ffee11'); + final both = await RoomProofSettings.load(); + expect(both.tagAt(0), '04a2b3c4', reason: 'slot 0 must survive'); + expect(both.tagAt(1), '04ffee11'); + expect(both.isComplete, isTrue); }); - test('a saved QR payload round-trips', () async { - await RoomProofSettings.saveQrPayload('kitchen'); - expect((await RoomProofSettings.load()).qrPayload, 'kitchen'); - }); - - test('the two proofs are stored independently', () async { - await RoomProofSettings.saveNfcTag('04a2b3c4'); - await RoomProofSettings.saveQrPayload('kitchen'); + test('re-registering one slot leaves the other alone', () async { + await RoomProofSettings.saveNfcTagAt(0, 'aaa'); + await RoomProofSettings.saveNfcTagAt(1, 'bbb'); + await RoomProofSettings.saveNfcTagAt(0, 'ccc'); final settings = await RoomProofSettings.load(); - expect(settings.nfcTagId, '04a2b3c4'); - expect(settings.qrPayload, 'kitchen'); + expect(settings.tagAt(0), 'ccc'); + expect(settings.tagAt(1), 'bbb'); + }); + + test('a legacy single tag migrates into slot 1', () async { + // An existing install has one tag under the pre-two-tag key. Losing it + // would leave the user bypass-only on the first morning after updating. + SharedPreferences.setMockInitialValues({ + RoomProofSettings.kLegacyNfcTagKey: '0465dab3ce2a81', + }); + final settings = await RoomProofSettings.load(); + expect(settings.tagAt(0), '0465dab3ce2a81'); + expect(settings.tagAt(1), isNull); + expect(settings.isComplete, isFalse); + }); + + test('the same tag in both slots is not a complete configuration', () async { + // One physical tag satisfying both stages would remove the walk. + await RoomProofSettings.saveNfcTagAt(0, 'same'); + await RoomProofSettings.saveNfcTagAt(1, 'same'); + expect((await RoomProofSettings.load()).isComplete, isFalse); + }); + + test('a malformed stored value degrades to unregistered', () async { + SharedPreferences.setMockInitialValues({ + RoomProofSettings.kNfcTagsKey: 'not json', + }); + final settings = await RoomProofSettings.load(); + expect(settings.tagAt(0), isNull); + expect(settings.isComplete, isFalse); + }); + + test('an out-of-range slot write is ignored', () async { + await RoomProofSettings.saveNfcTagAt(5, 'nope'); + expect((await RoomProofSettings.load()).isComplete, isFalse); }); } diff --git a/phone_app/test/services/room_proof_test.dart b/phone_app/test/services/room_proof_test.dart index 00bd19c..614bfd9 100644 --- a/phone_app/test/services/room_proof_test.dart +++ b/phone_app/test/services/room_proof_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/room_proof.dart'; +import 'package:wake_alarm_sync/services/room_proof_settings.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -19,48 +20,46 @@ void main() { scheduler = AlarmScheduler(channel: channel); }); + const twoTags = RoomProofSettings(nfcTagIds: ['tag-1', 'tag-2']); + group('NFC', () { - test('a matching registered tag satisfies the proof', () { - final proof = RoomProof(scheduler: scheduler, registeredTagId: 'tag-1'); - final result = proof.checkNfc('tag-1'); + test('a matching registered tag satisfies its own slot', () { + final proof = RoomProof(scheduler: scheduler, settings: twoTags); + final result = proof.checkNfc('tag-1', slot: 0); expect(result.satisfied, isTrue); expect(result.kind, RoomProofKind.nfc); }); test('a different tag does not', () { - final proof = RoomProof(scheduler: scheduler, registeredTagId: 'tag-1'); - expect(proof.checkNfc('tag-2').satisfied, isFalse); + final proof = RoomProof(scheduler: scheduler, settings: twoTags); + expect(proof.checkNfc('tag-9', slot: 0).satisfied, isFalse); + }); + + test('the OTHER registered tag does not satisfy this slot', () { + // Two locations must stay two locations: if tag 2 could pass stage 1 + // the walk between them would be optional. + final proof = RoomProof(scheduler: scheduler, settings: twoTags); + final result = proof.checkNfc('tag-2', slot: 0); + expect(result.satisfied, isFalse); + expect(result.detail, contains('Not tag 1')); + }); + + test('each slot matches only its own tag', () { + final proof = RoomProof(scheduler: scheduler, settings: twoTags); + expect(proof.checkNfc('tag-2', slot: 1).satisfied, isTrue); + expect(proof.checkNfc('tag-1', slot: 1).satisfied, isFalse); }); test('no registered tag means NFC cannot satisfy, not that all pass', () { final proof = RoomProof(scheduler: scheduler); - final result = proof.checkNfc('tag-1'); + final result = proof.checkNfc('tag-1', slot: 0); expect(result.satisfied, isFalse); - expect(result.detail, contains('No NFC tag registered')); + expect(result.detail, contains('No tag 1 registered')); }); test('an empty registered tag is treated as unregistered', () { - final proof = RoomProof(scheduler: scheduler, registeredTagId: ''); - expect(proof.checkNfc('').satisfied, isFalse); - }); - }); - - group('QR', () { - test('the expected payload satisfies the proof', () { final proof = RoomProof(scheduler: scheduler); - final result = proof.checkQr('kitchen', expected: 'kitchen'); - expect(result.satisfied, isTrue); - expect(result.kind, RoomProofKind.qr); - }); - - test('a different payload does not', () { - final proof = RoomProof(scheduler: scheduler); - expect(proof.checkQr('bedroom', expected: 'kitchen').satisfied, isFalse); - }); - - test('an empty expectation never matches', () { - final proof = RoomProof(scheduler: scheduler); - expect(proof.checkQr('', expected: '').satisfied, isFalse); + expect(proof.checkNfc('', slot: 0).satisfied, isFalse); }); });