halkyon/packages/halkyon_app/test/assistant/widget/assistants_card_test.dart
Tizian 「ティツィアーン」 5641285d31
feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)
- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 05:19:18 +01:00

427 lines
13 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, D58, D60): what is granted, by provider, and left of the
/// budget for them all, 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,
);
setUp(() {
dependencies = TestAppDependencies();
gateway = dependencies.assistantGateway
..offer = const AssistantsOffer(
assistants: [claude],
ownKeys: true,
currency: 'EUR',
budget: AssistantBudget(dailyCostMicros: 1000000),
usedToday: BudgetUse(costMicros: 8100, tokens: 900),
usedThisMonth: BudgetUse(costMicros: 8100, tokens: 900),
);
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 the budget for them '
'all', (tester) async {
await show(tester);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('Your budget, for all of them'), findsOneWidget);
expect(find.text('€0.99 of €1.00 left today'), findsOneWidget);
expect(find.text('Anthropic'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
expect(
find.text('claude-sonnet-5-5 at api.anthropic.com\nRead and agree'),
findsOneWidget,
);
expect(find.text('Your use'), findsOneWidget);
});
testWidgets('a budget in tokens names them once', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [claude],
budget: AssistantBudget(dailyTokens: 100000, monthlyTokens: 2000000),
usedToday: BudgetUse(tokens: 350000),
usedThisMonth: BudgetUse(tokens: 350000),
);
await show(tester);
expect(
find.text(
'0 of 100,000 tokens left today\n'
'1,650,000 of 2,000,000 tokens left this month',
),
findsOneWidget,
);
});
testWidgets('a budget without limits says so', (tester) async {
gateway.offer = const AssistantsOffer(assistants: [claude]);
await show(tester);
expect(find.text('No limits'), findsOneWidget);
});
testWidgets('groups the assistants by provider, in the order the operator '
'set them', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
claude,
OfferedAssistant(
id: 'gemini',
name: 'Gemini',
protocol: 'openai',
host: 'generativelanguage.googleapis.com',
model: 'gemini-3-pro',
),
OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
);
await show(tester);
final labels = [
for (final text in tester.widgetList<Text>(
find.descendant(
of: find.byType(AssistantsCard),
matching: find.byType(Text),
),
))
if (const {
'Anthropic',
'Google',
'Claude',
'Opus',
'Gemini',
}.contains(text.data))
text.data,
];
expect(labels, ['Anthropic', 'Claude', 'Opus', 'Google', 'Gemini']);
});
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('one agreement holds for every assistant of its provider, '
'which the sheet says (D66)', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
claude,
OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
OfferedAssistant(
id: 'gemini',
name: 'Gemini',
protocol: 'openai',
host: 'generativelanguage.googleapis.com',
model: 'gemini-3-pro',
),
],
);
await show(tester);
expect(find.textContaining('Read and agree'), findsNWidgets(3));
await tap(tester, find.text('Claude'));
expect(
find.text('It holds for every assistant at api.anthropic.com.'),
findsOneWidget,
);
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
expect(find.textContaining('Read and agree'), findsOneWidget);
expect(gateway.calls.where((call) => call.startsWith('agree')), [
'agree claude',
]);
});
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);
});
}