From 3c2c2baba0364ad88ca357055e7a437d0060faa2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 20:44:03 +0200 Subject: [PATCH] Split the last over-cap focus_owner Dart test file status_page_test.dart 309 -> 175, with the release and polling tests in status_page_actions_test.dart. An intermediate run showed 97 rather than 94 - the tell that a git checkout had restored the original while the split copy still held the same three tests. Counting testWidgets() calls in both halves (5 + 3 = 8) confirmed the fix. status_page_state.dart stays at 307: it is one State class whose methods read seven private fields, so a mixin cannot see them without declaring the state twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...ner-status-page-test-split-2026-08-16.json | 37 ++++ .../test/status_page_actions_test.dart | 204 ++++++++++++++++++ focus_owner/test/status_page_test.dart | 137 +----------- 3 files changed, 242 insertions(+), 136 deletions(-) create mode 100644 docs/superpowers/evidence/focus-owner-status-page-test-split-2026-08-16.json create mode 100644 focus_owner/test/status_page_actions_test.dart diff --git a/docs/superpowers/evidence/focus-owner-status-page-test-split-2026-08-16.json b/docs/superpowers/evidence/focus-owner-status-page-test-split-2026-08-16.json new file mode 100644 index 00000000..3d2eeef0 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-status-page-test-split-2026-08-16.json @@ -0,0 +1,37 @@ +{ + "intent": "Bring the last over-cap focus_owner Dart test file under the 250-line cap, and record why status_page_state.dart cannot follow.", + "scope": [ + "test/status_page_test.dart (309 -> 175) + status_page_actions_test.dart", + "Not split: lib/status_page_state.dart (307)", + "Non-goal: no assertion changed" + ], + "changes": [ + "The release, enforcement and polling widget tests moved to status_page_actions_test.dart, taking the _logLine helper with them since nothing in the original half still used it.", + "status_page_state.dart was attempted and reverted. It is one State class whose methods call setState and read seven private fields; extracting them as a mixin gives 'Undefined name _busy' because a mixin cannot see the concrete class's fields, and making it work would mean declaring those fields twice." + ], + "verification": [ + { + "command": "flutter test", + "result": "pass", + "evidence": "94 passed, matching the baseline. An intermediate run showed 97, which was the tell that a git checkout had restored the original file while the split copy still held the same three tests - caught by comparing the count rather than trusting a green run." + }, + { + "command": "grep -c 'testWidgets(' on both halves", + "result": "pass", + "evidence": "5 and 3, summing to the 8 the original had. Counting the cases directly is what confirmed the duplicates were gone." + }, + { + "command": "flutter analyze", + "result": "pass", + "evidence": "'No issues found!' after removing the now-unused _logLine and a dart:convert import." + } + ], + "risks": [ + "status_page_state.dart stays at 307 lines. Splitting it needs the State's fields moved into the mixin as well, which changes where the widget's state actually lives - a design change, not a line-count split.", + "An automated unused-declaration pass removed _logLine while it was still in use and broke two tests; it was reverted and redone by hand. Analyzer 'unused' output is per-file and cannot see a sibling test file." + ], + "rollback": [ + "git revert the commit; status_page_test.dart returns to 309 lines and the new file disappears.", + "After rollback, re-run flutter test and expect 94 passed." + ] +} diff --git a/focus_owner/test/status_page_actions_test.dart b/focus_owner/test/status_page_actions_test.dart new file mode 100644 index 00000000..a463eab9 --- /dev/null +++ b/focus_owner/test/status_page_actions_test.dart @@ -0,0 +1,204 @@ +// StatusPage release, enforcement and polling behaviour. + +// StatusPage widget behaviour. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_owner/device_policy.dart'; +import 'package:focus_owner/main.dart'; +import 'package:focus_owner/policy.dart'; + +/// Builds a channel whose handler answers from [responses], recording calls. +({MethodChannel channel, List calls}) _fakeChannel( + Map responses, +) { + const channel = MethodChannel('test/device_policy'); + final calls = []; + // hasHomeLocation is read on every status refresh, so it is defaulted here + // rather than restated in each widget test. An explicit stub still wins, + // which is what the geofence-state tests rely on. + final stubs = {'hasHomeLocation': false, ...responses}; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (!stubs.containsKey(call.method)) { + throw PlatformException(code: 'unimplemented', message: 'no stub'); + } + return stubs[call.method]; + }); + return (channel: channel, calls: calls); +} + +Map _statusMap({bool isDeviceOwner = false}) => { + 'packageName': 'com.kuhy.focus_owner', + 'isDeviceOwner': isDeviceOwner, + 'isAdminActive': isDeviceOwner, + 'sdkInt': 36, + 'restrictionsApplied': false, + }; + +/// Minimal policy so widget tests never touch the (empty) test rootBundle. +Future _stubPolicy() async => FocusPolicy.fromJson({ + 'schema_version': kSupportedSchemaVersion, + 'home': { + 'latitude': null, + 'longitude': null, + 'radius_m': 150.0, + 'hysteresis_m': 30.0, + }, + 'curfew': {'start': '23:00', 'end': '05:00'}, + 'launcher_package': 'com.launcher', + 'allowed_packages': ['com.good'], + 'night_allowed_packages': [], + 'never_disable_prefixes': [], + 'workout_unblock_domains': ['youtube.com'], + 'browser_packages': [], + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('StatusPage actions', () { + testWidgets('confirming release calls the platform', (tester) async { + final fake = _fakeChannel({ + 'status': _statusMap(isDeviceOwner: true), + 'releaseDeviceOwner': true, + }); + await tester.pumpWidget( + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Release device owner'), 100); + await tester.tap(find.text('Release device owner')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Release')); + await tester.pumpAndSettle(); + + expect( + fake.calls.where((c) => c.method == 'releaseDeviceOwner'), + hasLength(1), + ); + expect(find.text('Device owner released'), findsOneWidget); + }); + + testWidgets('running enforcement waits for the new record', (tester) async { + // The pass runs in a separate service process and now waits for a + // location fix, so re-reading immediately returns the PREVIOUS record. + // Measured: after re-anchoring home the card still showed the old + // distance, which reads as "the button did nothing". + var passRan = false; + const channel = MethodChannel('test/refresh'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'status': + return _statusMap(isDeviceOwner: true); + case 'hasHomeLocation': + return true; + case 'runEnforcementNow': + passRan = true; + return true; + case 'readEnforcementLog': + return [_logLine(passRan ? 'AT_HOME' : 'AWAY')]; + default: + throw PlatformException(code: 'unimplemented'); + } + }); + + await tester.pumpWidget( + MaterialApp( + home: StatusPage( + policy: DevicePolicy(channel), + loadFocusPolicy: _stubPolicy, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('AWAY'), findsOneWidget); + + await tester.scrollUntilVisible(find.text('Run enforcement now'), 100); + await tester.tap(find.text('Run enforcement now')); + // Long enough for at least one poll interval to elapse. + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + + expect(find.text('AT HOME'), findsOneWidget); + expect(find.text('AWAY'), findsNothing); + }); + + testWidgets('a stuck pass stops polling instead of hanging', + (tester) async { + // If no new record ever lands the UI must still settle, not spin. + const channel = MethodChannel('test/stuck'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'status': + return _statusMap(isDeviceOwner: true); + case 'hasHomeLocation': + return true; + case 'runEnforcementNow': + return true; + case 'readEnforcementLog': + return [_logLine('AWAY')]; + default: + throw PlatformException(code: 'unimplemented'); + } + }); + + await tester.pumpWidget( + MaterialApp( + home: StatusPage( + policy: DevicePolicy(channel), + loadFocusPolicy: _stubPolicy, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Run enforcement now'), 100); + await tester.tap(find.text('Run enforcement now')); + // Past the polling budget (~30 s). + await tester.pump(const Duration(seconds: 40)); + await tester.pumpAndSettle(); + + expect(find.text('AWAY'), findsOneWidget); + }); + }); +} + + + +/// One JSON-lines record, as the platform channel returns them. +String _logLine(String reason) => jsonEncode({ + 'ts': DateTime.now().millisecondsSinceEpoch, + 'reason': reason, + 'distance_m': reason == 'AWAY' ? 766.0 : 0.0, + 'threshold_m': 150.0, + 'inside_fence': reason != 'AWAY', + 'home_configured': true, + 'fix': { + 'age_ms': 5000, + 'provider': 'gps', + 'accuracy_m': 11.0, + 'outcome': 'ACTIVE_OK', + }, + 'curfew_active': false, + 'curfew_window': '23:00-05:00', + 'counts': { + 'to_hide': 3, + 'to_show': 58, + 'hid_delta': 0, + 'restored_delta': 0, + }, + 'hidden': const [], + 'failure': null, + }); diff --git a/focus_owner/test/status_page_test.dart b/focus_owner/test/status_page_test.dart index b137bbcb..599828aa 100644 --- a/focus_owner/test/status_page_test.dart +++ b/focus_owner/test/status_page_test.dart @@ -1,6 +1,5 @@ // StatusPage widget behaviour. -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -167,143 +166,9 @@ void main() { isEmpty, ); }); - - testWidgets('confirming release calls the platform', (tester) async { - final fake = _fakeChannel({ - 'status': _statusMap(isDeviceOwner: true), - 'releaseDeviceOwner': true, - }); - await tester.pumpWidget( - MaterialApp( - home: StatusPage( - policy: DevicePolicy(fake.channel), - loadFocusPolicy: _stubPolicy, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible(find.text('Release device owner'), 100); - await tester.tap(find.text('Release device owner')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Release')); - await tester.pumpAndSettle(); - - expect( - fake.calls.where((c) => c.method == 'releaseDeviceOwner'), - hasLength(1), - ); - expect(find.text('Device owner released'), findsOneWidget); - }); - - testWidgets('running enforcement waits for the new record', (tester) async { - // The pass runs in a separate service process and now waits for a - // location fix, so re-reading immediately returns the PREVIOUS record. - // Measured: after re-anchoring home the card still showed the old - // distance, which reads as "the button did nothing". - var passRan = false; - const channel = MethodChannel('test/refresh'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - switch (call.method) { - case 'status': - return _statusMap(isDeviceOwner: true); - case 'hasHomeLocation': - return true; - case 'runEnforcementNow': - passRan = true; - return true; - case 'readEnforcementLog': - return [_logLine(passRan ? 'AT_HOME' : 'AWAY')]; - default: - throw PlatformException(code: 'unimplemented'); - } - }); - - await tester.pumpWidget( - MaterialApp( - home: StatusPage( - policy: DevicePolicy(channel), - loadFocusPolicy: _stubPolicy, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('AWAY'), findsOneWidget); - - await tester.scrollUntilVisible(find.text('Run enforcement now'), 100); - await tester.tap(find.text('Run enforcement now')); - // Long enough for at least one poll interval to elapse. - await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); - - expect(find.text('AT HOME'), findsOneWidget); - expect(find.text('AWAY'), findsNothing); - }); - - testWidgets('a stuck pass stops polling instead of hanging', - (tester) async { - // If no new record ever lands the UI must still settle, not spin. - const channel = MethodChannel('test/stuck'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - switch (call.method) { - case 'status': - return _statusMap(isDeviceOwner: true); - case 'hasHomeLocation': - return true; - case 'runEnforcementNow': - return true; - case 'readEnforcementLog': - return [_logLine('AWAY')]; - default: - throw PlatformException(code: 'unimplemented'); - } - }); - - await tester.pumpWidget( - MaterialApp( - home: StatusPage( - policy: DevicePolicy(channel), - loadFocusPolicy: _stubPolicy, - ), - ), - ); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Run enforcement now'), 100); - await tester.tap(find.text('Run enforcement now')); - // Past the polling budget (~30 s). - await tester.pump(const Duration(seconds: 40)); - await tester.pumpAndSettle(); - - expect(find.text('AWAY'), findsOneWidget); - }); }); } + /// One JSON-lines record, as the platform channel returns them. -String _logLine(String reason) => jsonEncode({ - 'ts': DateTime.now().millisecondsSinceEpoch, - 'reason': reason, - 'distance_m': reason == 'AWAY' ? 766.0 : 0.0, - 'threshold_m': 150.0, - 'inside_fence': reason != 'AWAY', - 'home_configured': true, - 'fix': { - 'age_ms': 5000, - 'provider': 'gps', - 'accuracy_m': 11.0, - 'outcome': 'ACTIVE_OK', - }, - 'curfew_active': false, - 'curfew_window': '23:00-05:00', - 'counts': { - 'to_hide': 3, - 'to_show': 58, - 'hid_delta': 0, - 'restored_delta': 0, - }, - 'hidden': const [], - 'failure': null, - });