testsAndMisc/focus_owner/test/policy_decisions_test.dart
Krzysztof kuhy Rudnicki d9982e53dd Split the focus_owner Dart files under the cap
main.dart 538 -> 130, policy.dart 272 -> 85, enforcement_status_card.dart
268 -> 145, plus both test files.

Every lib split uses part/part of rather than import. The first attempt
used import+export and the analyzer gave 30 errors like "The name
'_Card' isn't a class": Dart privates are visible to the whole LIBRARY,
and a separate file is a separate library unless it is a part.

The test splits separated groups from the testWidgets() calls that had
been initialising the binding implicitly, so four tests failed with
"Binding has not yet been initialized" - both halves now call
ensureInitialized() explicitly.

94 tests passing before and after; flutter analyze reports no issues.
focus_owner/analysis_options.yaml has an uncommitted change from an
earlier session and is deliberately left unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:39:13 +02:00

185 lines
6.4 KiB
Dart

// Allow/deny decision cases.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/policy.dart';
Map<String, Object?> _doc({
Object? schemaVersion = kSupportedSchemaVersion,
Object? curfew = const {'start': '23:00', 'end': '05:00'},
double? latitude,
double? longitude,
}) =>
{
'schema_version': schemaVersion,
'home': {
'latitude': latitude,
'longitude': longitude,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew,
'launcher_package': 'com.qqlabs.minimalistlauncher',
'allowed_packages': ['com.good', 'pl.mbank'],
'night_allowed_packages': ['pl.mbank'],
'never_disable_prefixes': ['com.android.providers'],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
};
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('allow decisions', () {
final policy = FocusPolicy.fromJson(_doc());
test('protects system prefixes on whole labels only', () {
expect(policy.isProtected('com.android.providers'), isTrue);
expect(policy.isProtected('com.android.providers.telephony'), isTrue);
expect(policy.isProtected('com.android.providersomething'), isFalse);
});
test('protected packages are allowed even during curfew', () {
expect(
policy.isAllowed('com.android.providers', duringCurfew: true),
isTrue,
);
});
test('curfew narrows the allowlist', () {
expect(policy.isAllowed('com.good'), isTrue);
expect(policy.isAllowed('com.good', duringCurfew: true), isFalse);
expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue);
});
test('unknown packages are blocked', () {
expect(policy.isAllowed('com.evil'), isFalse);
});
test('the launcher is allowed even when absent from the lists', () {
// Hiding it leaves no home screen. FocusPolicy.kt has always had this
// check; Dart had drifted without it.
expect(policy.allowedPackages, isNot(contains(policy.launcherPackage)));
expect(policy.isAllowed('com.qqlabs.minimalistlauncher'), isTrue);
expect(
policy.isAllowed('com.qqlabs.minimalistlauncher', duringCurfew: true),
isTrue,
);
});
test('an asset without prefix keys parses as empty', () {
// Back-compat: a policy rendered before prefixes existed must still
// parse, degrading to exact matching rather than failing entirely.
expect(policy.allowedPrefixes, isEmpty);
expect(policy.nightAllowedPrefixes, isEmpty);
});
test('allowed prefixes cover a family of packages', () {
final prefixed = FocusPolicy.fromJson(
_doc()
..['allowed_prefixes'] = ['eu.kanade.tachiyomi']
..['night_allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(prefixed.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.extension.all.mangadex'),
isTrue,
);
// Whole labels only, so an unrelated package is not swept in.
expect(prefixed.isAllowed('eu.kanade.tachiyomisomething'), isFalse);
// Chosen 2026-08-14: manga stays available during the curfew.
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isTrue,
);
});
test('a day-only prefix is blocked during curfew', () {
final dayOnly = FocusPolicy.fromJson(
_doc()..['allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(dayOnly.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
dayOnly.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isFalse,
);
});
test('a prefix list of the wrong type is still an error', () {
// Absent is fine; present-but-wrong is a real mistake.
expect(
() => FocusPolicy.fromJson(_doc()..['allowed_prefixes'] = 'nope'),
throwsA(isA<PolicyFormatException>()),
);
});
test('isCurfewActive follows the window', () {
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
expect(policy.isCurfewActive(12 * 60), isFalse);
});
});
group('FocusPolicy.load', () {
test('reads and parses the bundled asset', () async {
final bundle = _StubBundle(jsonEncode(_doc()));
final policy = await FocusPolicy.load(bundle: bundle);
expect(policy.allowedPackages, contains('pl.mbank'));
});
test('rejects an asset that is not a JSON object', () async {
final bundle = _StubBundle('[]');
await expectLater(
FocusPolicy.load(bundle: bundle),
throwsA(isA<PolicyFormatException>()),
);
});
});
test('the real committed asset parses and is redacted', () async {
// Guards the generator: a policy.json that this app cannot read, or that
// leaks the home coordinates into git, is a build error.
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
// Guards against an empty or truncated render, not against a specific
// size. The threshold was 50 when the allowlist still described the rooted
// Blackview; the 2026-08-11 rewrite to an explicit list for this phone cut
// it to ~26, so a high bound would only pin the old policy in place.
expect(policy.allowedPackages.length, greaterThan(10));
// The launcher must survive enforcement or the device has no home screen.
expect(policy.launcherPackage, isNotNull);
expect(policy.allowedPackages, contains(policy.launcherPackage));
expect(policy.home.hasCoordinates, isFalse,
reason: 'committed policy.json must not carry home coordinates');
expect(policy.curfew, isNotNull);
});
}
class _StubBundle extends CachingAssetBundle {
_StubBundle(this.contents);
final String contents;
@override
Future<ByteData> load(String key) async =>
ByteData.sublistView(utf8.encode(contents));
@override
Future<String> loadString(String key, {bool cache = true}) async => contents;
}
/// Reads the asset straight off disk, bypassing the (empty) test rootBundle.
class _RealAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
throw UnimplementedError(key);
}
@override
Future<String> loadString(String key, {bool cache = true}) async {
final file = File('assets/policy.json');
return file.readAsString();
}
}