348 lines
10 KiB
Dart
348 lines
10 KiB
Dart
|
|
import 'package:flutter_test/flutter_test.dart';
|
||
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
||
|
|
import 'package:halkyon_app/testing.dart';
|
||
|
|
import 'package:halkyon_llm/halkyon_llm.dart';
|
||
|
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||
|
|
import 'package:material_ui/material_ui.dart';
|
||
|
|
|
||
|
|
/// An app's settings' Assistants card (Halkyon Learn's ADR 0021, points 6,
|
||
|
|
/// 9 and 10): what is granted and left, agreeing to what goes where once,
|
||
|
|
/// the person's use, and assistants with their own key where allowed.
|
||
|
|
void main() {
|
||
|
|
late TestAppDependencies dependencies;
|
||
|
|
late FakeAssistantGateway gateway;
|
||
|
|
late MemoryOwnAssistantStore store;
|
||
|
|
|
||
|
|
const claude = OfferedAssistant(
|
||
|
|
id: 'claude',
|
||
|
|
name: 'Claude',
|
||
|
|
protocol: 'anthropic',
|
||
|
|
host: 'api.anthropic.com',
|
||
|
|
model: 'claude-sonnet-5-5',
|
||
|
|
images: true,
|
||
|
|
unit: AssistantLimitUnit.money,
|
||
|
|
dailyLimit: 1000000,
|
||
|
|
usedToday: 8100,
|
||
|
|
);
|
||
|
|
|
||
|
|
setUp(() {
|
||
|
|
dependencies = TestAppDependencies();
|
||
|
|
gateway = dependencies.assistantGateway
|
||
|
|
..offer = const AssistantsOffer(
|
||
|
|
assistants: [claude],
|
||
|
|
ownKeys: true,
|
||
|
|
currency: 'EUR',
|
||
|
|
);
|
||
|
|
store = MemoryOwnAssistantStore();
|
||
|
|
});
|
||
|
|
|
||
|
|
Future<void> show(WidgetTester tester, {bool keepsOwn = true}) async {
|
||
|
|
tester.view.physicalSize = const Size(412, 1600);
|
||
|
|
tester.view.devicePixelRatio = 1;
|
||
|
|
addTearDown(tester.view.reset);
|
||
|
|
await tester.pumpWidget(
|
||
|
|
testApp(
|
||
|
|
Scaffold(
|
||
|
|
body: SingleChildScrollView(
|
||
|
|
child: AssistantsCard(ownAssistants: keepsOwn ? store : null),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
dependencies: dependencies,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
}
|
||
|
|
|
||
|
|
Finder inSheet(Finder finder) =>
|
||
|
|
find.descendant(of: find.byType(SheetScaffold), matching: finder);
|
||
|
|
|
||
|
|
Future<void> tap(WidgetTester tester, Finder finder) async {
|
||
|
|
await tester.ensureVisible(finder);
|
||
|
|
await tester.tap(finder);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
}
|
||
|
|
|
||
|
|
testWidgets('shows nothing where nothing is offered and own keys are not '
|
||
|
|
'allowed', (tester) async {
|
||
|
|
gateway.offer = AssistantsOffer.none;
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
expect(find.text('Assistants'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets("shows nothing where the offer can't be read", (tester) async {
|
||
|
|
gateway.failing = const AssistantFailed(AssistantProblem.failed);
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
expect(find.text('Assistants'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('shows who answers and what is left of its limits', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
expect(find.text('Assistants'), findsOneWidget);
|
||
|
|
expect(find.text('Claude'), findsOneWidget);
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
'claude-sonnet-5-5 at api.anthropic.com\n€0.99 of €1.00 left today\n'
|
||
|
|
'Read and agree',
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(find.text('Your use'), findsOneWidget);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('a limit in tokens names them once', (tester) async {
|
||
|
|
gateway.offer = const AssistantsOffer(
|
||
|
|
assistants: [
|
||
|
|
OfferedAssistant(
|
||
|
|
id: 'local',
|
||
|
|
name: 'Local',
|
||
|
|
protocol: 'openai',
|
||
|
|
host: 'models.example.org',
|
||
|
|
model: 'qwen',
|
||
|
|
monthlyLimit: 2000000,
|
||
|
|
usedThisMonth: 350000,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
'qwen at models.example.org\n1,650,000 of 2,000,000 tokens left this '
|
||
|
|
'month\nRead and agree',
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('says what goes where before agreeing, and agrees only when '
|
||
|
|
'asked to (D40)', (tester) async {
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Claude'));
|
||
|
|
expect(find.text('Before you ask Claude'), findsOneWidget);
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
'It answers with the model claude-sonnet-5-5 at '
|
||
|
|
'api.anthropic.com.',
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(find.text('It reads the images you add.'), findsOneWidget);
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
'Whoever runs test.example.org sees how much you use it (requests, '
|
||
|
|
'tokens, cost), never what you ask.',
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(gateway.calls, isNot(contains('agree claude')));
|
||
|
|
|
||
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
||
|
|
|
||
|
|
expect(gateway.calls, contains('agree claude'));
|
||
|
|
expect(find.text('Before you ask Claude'), findsNothing);
|
||
|
|
expect(find.textContaining('Read and agree'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('once agreed, the sheet says since when and asks nothing', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
gateway.offer = AssistantsOffer(
|
||
|
|
assistants: [
|
||
|
|
OfferedAssistant(
|
||
|
|
id: claude.id,
|
||
|
|
name: claude.name,
|
||
|
|
protocol: claude.protocol,
|
||
|
|
host: claude.host,
|
||
|
|
model: claude.model,
|
||
|
|
agreedAt: DateTime.utc(2026, 10, 25, 12),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Claude'));
|
||
|
|
|
||
|
|
expect(find.text('You agreed on Oct 25, 2026.'), findsOneWidget);
|
||
|
|
expect(find.widgetWithText(FilledButton, 'Agree'), findsNothing);
|
||
|
|
expect(find.text('It reads the images you add.'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('a refused agreement is said so', (tester) async {
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Claude'));
|
||
|
|
gateway.failing = const AssistantFailed(AssistantProblem.notGranted);
|
||
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
||
|
|
|
||
|
|
expect(
|
||
|
|
find.text("This assistant isn't granted to you any more."),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets("the person's use, by UTC month", (tester) async {
|
||
|
|
final month = utcMonthOf(DateTime.now());
|
||
|
|
gateway.use = [
|
||
|
|
AssistantUse(
|
||
|
|
assistant: 'claude',
|
||
|
|
month: month,
|
||
|
|
requests: 1,
|
||
|
|
inputTokens: 900,
|
||
|
|
outputTokens: 100,
|
||
|
|
costMicros: 4500,
|
||
|
|
),
|
||
|
|
];
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Your use'));
|
||
|
|
|
||
|
|
expect(inSheet(find.text('Claude')), findsOneWidget);
|
||
|
|
expect(
|
||
|
|
inSheet(find.text('1 request · 1,000 tokens · €0.00')),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('adds an assistant with an own key, kept in the settings', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
||
|
|
'My GPT',
|
||
|
|
);
|
||
|
|
await tap(tester, find.text('OpenAI Responses'));
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
||
|
|
'gpt-5',
|
||
|
|
);
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Key')),
|
||
|
|
'sk-own',
|
||
|
|
);
|
||
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
||
|
|
|
||
|
|
final own = store.assistants.single;
|
||
|
|
expect(own.name, 'My GPT');
|
||
|
|
expect(own.protocol, LlmProtocol.openaiResponses);
|
||
|
|
expect(own.baseUrl, isNull);
|
||
|
|
expect(own.model, 'gpt-5');
|
||
|
|
expect(own.apiKey, 'sk-own');
|
||
|
|
expect(find.text('My GPT'), findsOneWidget);
|
||
|
|
expect(find.text('gpt-5 at api.openai.com'), findsOneWidget);
|
||
|
|
expect(find.text('sk-own'), findsNothing, reason: 'never shown');
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets("an address that isn't https is refused before anything is "
|
||
|
|
'kept', (tester) async {
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
||
|
|
'Remote',
|
||
|
|
);
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Address')),
|
||
|
|
'http://models.example.org/v1',
|
||
|
|
);
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
||
|
|
'qwen',
|
||
|
|
);
|
||
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
||
|
|
|
||
|
|
expect(
|
||
|
|
find.text('Only https, or http on your own network'),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(store.assistants, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets("a key isn't kept where this device can't seal the "
|
||
|
|
'settings', (tester) async {
|
||
|
|
store.unsealed = true;
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
||
|
|
'Mine',
|
||
|
|
);
|
||
|
|
await tester.enterText(
|
||
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
||
|
|
'claude-sonnet-5-5',
|
||
|
|
);
|
||
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
||
|
|
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
"The key wasn't kept: this device can't encrypt your settings yet. "
|
||
|
|
"Open your account's keys here first.",
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(store.assistants, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('removing an own assistant asks first', (tester) async {
|
||
|
|
await store.save(
|
||
|
|
const OwnAssistant(
|
||
|
|
id: 'own-1',
|
||
|
|
name: 'My GPT',
|
||
|
|
protocol: LlmProtocol.openai,
|
||
|
|
model: 'gpt-5',
|
||
|
|
apiKey: 'sk-own',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
await tap(tester, find.text('My GPT'));
|
||
|
|
await tap(tester, find.text('Remove'));
|
||
|
|
expect(find.text('Remove My GPT?'), findsOneWidget);
|
||
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Remove'));
|
||
|
|
|
||
|
|
expect(store.assistants, isEmpty);
|
||
|
|
expect(find.text('My GPT'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets("where own keys aren't allowed, those kept are said to go "
|
||
|
|
'unasked, and none can be added (D6)', (tester) async {
|
||
|
|
gateway.offer = AssistantsOffer.none;
|
||
|
|
await store.save(
|
||
|
|
const OwnAssistant(
|
||
|
|
id: 'own-1',
|
||
|
|
name: 'My GPT',
|
||
|
|
protocol: LlmProtocol.openai,
|
||
|
|
model: 'gpt-5',
|
||
|
|
apiKey: 'sk-own',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
await show(tester);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
find.text(
|
||
|
|
"Whoever runs test.example.org doesn't allow own keys here, so the "
|
||
|
|
"app doesn't ask these.",
|
||
|
|
),
|
||
|
|
findsOneWidget,
|
||
|
|
);
|
||
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('an app keeping no own assistants offers none', (tester) async {
|
||
|
|
await show(tester, keepsOwn: false);
|
||
|
|
|
||
|
|
expect(find.text('Claude'), findsOneWidget);
|
||
|
|
expect(find.text('With your own key'), findsNothing);
|
||
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
||
|
|
});
|
||
|
|
}
|