026323b72a
- Add FlyerTraceSupplement type for AI trace metadata - Implement getFlyerTraceSupplements method to fetch trace supplements - Update AiTraceService to include prompt/rawOutput and counters in flyer traces - Add persistFlyerTrace method to FlyerImportService for trace persistence - Enhance AiFlyerParserService to return structured trace data with prompts and retries - Update FlyerNormalizerService with OCR typo fixes for cheese variants and spröd bakad firre - Improve Flutter admin panel with selectable text, warnings display, and tooltips - Add comprehensive tests for AI trace supplements and normalization rules
242 lines
7.6 KiB
Dart
242 lines
7.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:recipe_flutter/core/ui/app_shell.dart';
|
|
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
|
|
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
|
|
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
|
|
import 'package:recipe_flutter/features/admin/presentation/admin_ai_panel.dart';
|
|
import 'package:recipe_flutter/features/admin/presentation/admin_screen.dart';
|
|
import 'package:recipe_flutter/features/auth/data/auth_providers.dart';
|
|
|
|
class _FakeAdminRepository implements AdminRepository {
|
|
final AdminAiTraceListResponse flyerList;
|
|
final AdminAiTraceListResponse receiptList;
|
|
final Map<String, AdminAiTraceDetail> details;
|
|
|
|
_FakeAdminRepository({
|
|
required this.flyerList,
|
|
required this.receiptList,
|
|
required this.details,
|
|
});
|
|
|
|
@override
|
|
Future<AdminAiTraceListResponse> listAiTraces({
|
|
required AdminAiTraceSource source,
|
|
int limit = 25,
|
|
String? cursor,
|
|
String? period,
|
|
bool onlyErrors = false,
|
|
}) async {
|
|
return source == AdminAiTraceSource.flyer ? flyerList : receiptList;
|
|
}
|
|
|
|
@override
|
|
Future<AdminAiTraceDetail> getAiTraceById(String traceId) async {
|
|
return details[traceId] ?? details.values.first;
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
Widget _buildPanelApp(AdminRepository repo) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
adminRepositoryProvider.overrideWithValue(repo),
|
|
],
|
|
child: const MaterialApp(
|
|
home: Scaffold(
|
|
body: AdminAiPanel(embedded: true),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
final veryLargeOutput = '{"payload":"${List.filled(13050, 'x').join()}"}';
|
|
|
|
final flyerItem = AdminAiTraceListItem(
|
|
id: 'flyer-101',
|
|
source: AdminAiTraceSource.flyer,
|
|
status: AdminAiTraceStatus.warning,
|
|
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
|
userId: 7,
|
|
userLabel: 'admin',
|
|
sessionId: 101,
|
|
fileName: 'willys-v20.pdf',
|
|
model: 'ministral-8b-2512',
|
|
durationMs: 1880,
|
|
warningsCount: 2,
|
|
hasPrompt: true,
|
|
hasOutput: true,
|
|
error: 'Det finns 2 varningar i detaljvyn.',
|
|
);
|
|
|
|
final flyerDetail = AdminAiTraceDetail(
|
|
id: 'flyer-101',
|
|
source: AdminAiTraceSource.flyer,
|
|
status: AdminAiTraceStatus.warning,
|
|
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
|
userId: 7,
|
|
userLabel: 'admin',
|
|
sessionId: 101,
|
|
fileName: 'willys-v20.pdf',
|
|
model: 'ministral-8b-2512',
|
|
durationMs: 1880,
|
|
retryCount: 1,
|
|
chunkCount: 3,
|
|
warnings: const ['parse:low_confidence', 'match:no_match'],
|
|
error: null,
|
|
prompt: 'Prompttext exempel',
|
|
rawOutput: veryLargeOutput,
|
|
normalizedOutput: null,
|
|
summary: const {'itemCount': 1},
|
|
);
|
|
|
|
group('Admin AI tab and panel', () {
|
|
testWidgets('Admin main route query tab=ai renders AI panel',
|
|
(tester) async {
|
|
final fakeRepo = _FakeAdminRepository(
|
|
flyerList:
|
|
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
|
receiptList:
|
|
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
|
details: {'flyer-101': flyerDetail},
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
adminRepositoryProvider.overrideWithValue(fakeRepo),
|
|
],
|
|
child: const MaterialApp(
|
|
home: Scaffold(
|
|
body: AdminScreen(initialTab: AdminViewTab.ai),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Kvitto'), findsOneWidget);
|
|
expect(find.text('Flyer'), findsOneWidget);
|
|
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('AppShell shows AI top chip and navigates with query',
|
|
(tester) async {
|
|
String? navigatedTo;
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
isAdminProvider.overrideWithValue(true),
|
|
],
|
|
child: MaterialApp(
|
|
home: AppShell(
|
|
location: '/admin?tab=ai',
|
|
onNavigateToPath: (path) => navigatedTo = path,
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('AI'), findsOneWidget);
|
|
await tester.tap(find.text('Databas'));
|
|
await tester.pump();
|
|
|
|
expect(navigatedTo, '/admin?tab=database');
|
|
});
|
|
|
|
testWidgets('Source switching toggles between Flyer and Kvitto views',
|
|
(tester) async {
|
|
final fakeRepo = _FakeAdminRepository(
|
|
flyerList:
|
|
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
|
receiptList:
|
|
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
|
details: {'flyer-101': flyerDetail},
|
|
);
|
|
|
|
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Kvitto'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Receipt trace-data saknas i recipe-api i denna fas.'),
|
|
findsOneWidget);
|
|
|
|
await tester.tap(find.text('Flyer'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Prompt/output are selectable and warning details are visible',
|
|
(tester) async {
|
|
await tester.binding.setSurfaceSize(const Size(1400, 1200));
|
|
final fakeRepo = _FakeAdminRepository(
|
|
flyerList:
|
|
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
|
receiptList:
|
|
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
|
details: {'flyer-101': flyerDetail},
|
|
);
|
|
|
|
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
|
await tester.pumpAndSettle();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
await tester.tap(find.byType(ListTile).first);
|
|
await tester.pumpAndSettle();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
expect(find.text('Sammanfattning'), findsOneWidget);
|
|
expect(find.text('Varningar (2)'), findsOneWidget);
|
|
expect(find.text('parse:low_confidence'), findsOneWidget);
|
|
expect(find.text('match:no_match'), findsOneWidget);
|
|
final detailScroll = find.byType(Scrollable).last;
|
|
await tester.scrollUntilVisible(
|
|
find.text('Model Output'),
|
|
200,
|
|
scrollable: detailScroll,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Model Output'), findsOneWidget);
|
|
expect(find.byType(SelectableText), findsWidgets);
|
|
expect(find.text('Visa hela outputen'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Visa hela outputen'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Visa mindre'), findsOneWidget);
|
|
|
|
final copyPrompt = find.byTooltip('Kopiera');
|
|
final copyOutput = find.byTooltip('Kopiera JSON');
|
|
final copyWarnings = find.byTooltip('Kopiera alla varningar');
|
|
expect(copyPrompt, findsOneWidget);
|
|
expect(copyOutput, findsOneWidget);
|
|
expect(copyWarnings, findsOneWidget);
|
|
|
|
await tester.tap(copyPrompt);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await tester.tap(copyOutput);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await tester.tap(copyWarnings);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
});
|
|
});
|
|
}
|