mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:54:04 +01:00
Tenth batch of the 250-line sweep (over-cap count 18 -> 12; all Python is
already clear, so the remainder is Dart).
sync_merge_test.dart 694 -> 232 (+ 4 siblings)
github_mirror_screen_test.dart 512 -> 250 (+ 3 siblings)
foodbank_service_test.dart 395 -> 190 (+ manual sibling)
sync_service_test.dart 394 -> 203 (+ budget sibling + support)
log_storage_service_test.dart 385 -> 182 (+ edit sibling)
log_meal_screen_test.dart 362 -> 243 (+ nav sibling)
sync_service_test's ~100-line fixture block is shared through
`sync_service_test_support.dart` rather than duplicated: copying it would have
put both siblings back over the cap on their own. The helpers had to become
public (`_FakeGitHub` -> `FakeGitHub`, `_manual` -> `manualNutrition`,
`_remoteBudgetJson` -> `remoteBudgetJson`) with doc comments, since a leading
underscore is library-private in Dart and the siblings could not otherwise see
them.
The block-boundary matcher earned two fixes, both caught by comparing block
counts against HEAD rather than by the suite:
- A test name wrapped onto its own line (dart format does this past 80 cols)
was invisible to a same-line-only pattern, so the neighbouring span sliced
through it -- the file stopped compiling. Spans now end at the matching
close paren of the call, not at a guess past the closure brace.
- A double-quoted name containing an apostrophe ("skips its own device id
('phone')") was excluded by a character class that banned both quote
characters. That block fell into the shared prologue and was duplicated into
every output: the suite went GREEN at 559 tests instead of 555. Reverted and
redone; text between two blocks is now treated as a shared helper.
Verified: flutter test 555 (unchanged, and each split family's block count
matches HEAD exactly), flutter analyze clean, pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
251 lines
8.7 KiB
Dart
251 lines
8.7 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
|
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
|
import 'package:diet_guard_app/services/budget_history_service.dart';
|
|
import 'package:diet_guard_app/services/document_store_io.dart';
|
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/services/sync_health.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:url_launcher_platform_interface/link.dart';
|
|
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
|
|
|
import '../fake_secure_storage.dart';
|
|
|
|
/// Stub launcher that records the URL instead of opening it, so the device
|
|
/// dialog's "Open GitHub & copy code" can be exercised without a real
|
|
/// platform channel.
|
|
class _FakeUrlLauncher extends UrlLauncherPlatform
|
|
with MockPlatformInterfaceMixin {
|
|
String? launched;
|
|
|
|
@override
|
|
final LinkDelegate? linkDelegate = null;
|
|
|
|
@override
|
|
Future<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
|
|
|
@override
|
|
Future<bool> launchUrl(String url, LaunchOptions options) async {
|
|
launched = url;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
|
|
late Directory tempDir;
|
|
|
|
setUp(() async {
|
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_github_');
|
|
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
BudgetHistoryService.resetForTesting(
|
|
store: FileDocumentStore(tempDir),
|
|
);
|
|
SharedPreferences.setMockInitialValues({});
|
|
installFakeSecureStorage();
|
|
});
|
|
|
|
tearDown(() async {
|
|
LogStorageService.resetForTesting();
|
|
FoodBankService.resetForTesting();
|
|
AppSettingsService.resetForTesting();
|
|
BudgetHistoryService.resetForTesting();
|
|
await tempDir.delete(recursive: true);
|
|
});
|
|
|
|
// GitHubMirrorScreen loads its settings via a fire-and-forget Future in
|
|
// initState that Flutter's frame scheduler does not track -- same pitfall
|
|
// as HistoryScreen/LogMealScreen.
|
|
Future<void> settle(WidgetTester tester) async {
|
|
tester.view.physicalSize = const Size(800, 1400);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth
|
|
/// injects no test delay, so under `runAsync` it is a genuine Timer, not
|
|
/// the fake-clock one `tester.pump(duration)` advances) by interleaving
|
|
/// real waits with frame pumps until [done] is true or [maxTries] is hit.
|
|
Future<void> pumpUntil(
|
|
WidgetTester tester,
|
|
bool Function() done, {
|
|
int maxTries = 200,
|
|
}) async {
|
|
for (var i = 0; i < maxTries && !done(); i++) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
await tester.pump();
|
|
}
|
|
}
|
|
|
|
|
|
/// Types [clientId] into the client-id field.
|
|
Future<void> enterClientId(WidgetTester tester, String clientId) async {
|
|
await tester.enterText(
|
|
find.widgetWithText(TextField, 'OAuth App client id'),
|
|
clientId,
|
|
);
|
|
}
|
|
|
|
testWidgets('shows the kuhyx/syncs defaults on a fresh install', (
|
|
tester,
|
|
) async {
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen()));
|
|
await settle(tester);
|
|
|
|
expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget);
|
|
expect(find.widgetWithText(TextField, 'syncs'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Save persists the entered token', (tester) async {
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen()));
|
|
await settle(tester);
|
|
|
|
await tester.enterText(
|
|
find.widgetWithText(TextField, 'Personal access token (fallback)'),
|
|
'my-pat',
|
|
);
|
|
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
|
|
await settle(tester);
|
|
|
|
expect(find.text('Saved.'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Test connection reports success', (tester) async {
|
|
final mock = MockClient(
|
|
(_) async => http.Response('{}', 200),
|
|
);
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(
|
|
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(find.text('GitHub connection OK.'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Test connection reports failure', (tester) async {
|
|
final mock = MockClient((_) async => http.Response('', 403));
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(
|
|
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(find.text('GitHub connection failed.'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Sync now runs a sync tick and reports success', (
|
|
tester,
|
|
) async {
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') return http.Response('{}', 200);
|
|
// A bare `/repos/<owner>/<repo>` GET is crdt_sync's GitHubClient
|
|
// probing whether the repo itself exists (vs. a content path just
|
|
// being unused) -- must succeed so an empty repo isn't mistaken for
|
|
// a missing one.
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
|
|
await settle(tester);
|
|
|
|
expect(find.text('Synced.'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Sync now clears a stored failure so the banner lifts', (
|
|
tester,
|
|
) async {
|
|
// "Sync now" is the button a user reaches *because* the log screen's
|
|
// banner told them syncing had stopped. If a successful run here left
|
|
// the recorded failure in place, the recovery action would not dismiss
|
|
// the warning it caused, and the banner would keep accusing a device
|
|
// that is now publishing fine.
|
|
await SyncHealth.recordFailure();
|
|
expect((await SyncHealth.read()).failureKind, SyncFailureKind.failed);
|
|
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') return http.Response('{}', 200);
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
|
|
await settle(tester);
|
|
|
|
expect(find.text('Synced.'), findsOneWidget);
|
|
final status = await SyncHealth.read();
|
|
expect(status.failureKind, isNull);
|
|
expect(status.isStalled, isFalse);
|
|
});
|
|
});
|
|
testWidgets('Test connection reports a network exception', (tester) async {
|
|
final mock = MockClient((_) async => throw const FormatException('no net'));
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(
|
|
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
|
);
|
|
await settle(tester);
|
|
|
|
expect(find.textContaining('GitHub connection failed:'), findsOneWidget);
|
|
});
|
|
});
|
|
testWidgets('Sync now reports a GitHub error', (tester) async {
|
|
final mock = MockClient((_) async => http.Response('boom', 500));
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
|
|
);
|
|
await settle(tester);
|
|
|
|
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
|
|
await settle(tester);
|
|
|
|
expect(find.textContaining('Sync failed:'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|