feat(app): the operator's overview of screens, grants to several people at once with one budget each, assistants by provider
Halkyon Learn's ADR 0021, D57 to D63, on halkyon_base's budgets and effort: - The operator's screen is an overview (Waiting, Operators, Accounts, Assistants, each with its count) whose rows open screens of their own, read 50 at a time as they scroll (OperatorListCubit, OperatorPage), so hundreds of accounts push nothing else down (D62). - A handle is picked among the accounts there are (AccountSuggestionField): appointing an operator and granting take no other (D61). - AssistantGrantsScreen replaces AssistantGrantsCard: the assistants set up by provider, with their efforts and prices; the people granted, found by their handle's start; one sheet granting several assistants, a provider's at once, to several people, each with one budget in money and tokens a day and a month, or changing one person's (D58, D59). - Assistants are grouped by provider wherever they are listed, each under its name with a dot in its colour (ProviderLabel, D60). - AssistantsCard shows what is left of the person's budget for all their assistants together; the months of the figures run oldest first (D57). - AssistantChoice says the efforts a question may ask at and the ceiling on output each asks for (D63); a person's own key gets none and keeps 4,096 tokens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
24ed63585e
commit
cc87120bc8
3 changed files with 5348 additions and 2709 deletions
|
|
@ -1,327 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The operator's screen's Assistants card (Halkyon Learn's ADR 0021,
|
||||
/// points 2, 6 and 8): grants by handle with their limits, granting,
|
||||
/// changing and taking back, and everyone's use by month.
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
|
||||
const claude = SetUpAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
);
|
||||
const local = SetUpAssistant(
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
protocol: 'openai',
|
||||
host: 'models.example.org',
|
||||
model: 'qwen',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
dependencies = TestAppDependencies();
|
||||
dependencies.operatorGateway
|
||||
..operates = true
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'anna',
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'ben',
|
||||
handle: 'ben',
|
||||
since: DateTime.utc(2026, 10, 2),
|
||||
),
|
||||
);
|
||||
gateway = dependencies.assistantOperatorGateway
|
||||
..set = const AssistantsSetUp(
|
||||
assistants: [claude, local],
|
||||
currency: 'EUR',
|
||||
)
|
||||
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(const InstanceOperatorScreen(), dependencies: dependencies),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// [finder]'s match in the sheet, where the screen behind has its own.
|
||||
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 of assistants where none is set and none '
|
||||
'granted', (tester) async {
|
||||
gateway.set = const AssistantsSetUp();
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Assistants'), findsNothing);
|
||||
expect(find.text('Grant an assistant'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("shows nothing of them where they can't be read", (
|
||||
tester,
|
||||
) async {
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Assistants'), findsNothing);
|
||||
expect(find.text('Find an account'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists each grant with its assistant, limits and whether its '
|
||||
'person agreed', (tester) async {
|
||||
gateway.granted.addAll([
|
||||
AssistantGrant(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: 10000000,
|
||||
),
|
||||
AssistantGrant(
|
||||
account: 'anna',
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
agreedAt: DateTime.utc(2026, 10, 21),
|
||||
),
|
||||
]);
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Assistants'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Claude · €1.00 a day · €10.00 a month · Not agreed yet'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Local · No limits · Agreed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("grants by handle, in its pricing's unit, and lists it", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('Grant an assistant'));
|
||||
await tester.enterText(
|
||||
inSheet(find.widgetWithText(TextField, 'Handle')),
|
||||
'ben',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Limit a day'),
|
||||
'2,5',
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
|
||||
|
||||
final grant = gateway.granted.single;
|
||||
expect(grant.account, 'ben');
|
||||
expect(grant.dailyLimit, 2500000);
|
||||
expect(grant.monthlyLimit, isNull);
|
||||
expect(find.text('Claude · €2.50 a day · Not agreed yet'), findsOneWidget);
|
||||
|
||||
await tap(tester, find.text('Grant an assistant'));
|
||||
await tap(tester, find.text('Local'));
|
||||
await tester.enterText(
|
||||
inSheet(find.widgetWithText(TextField, 'Handle')),
|
||||
'@Ben',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Limit a month'),
|
||||
'1.000.000',
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
|
||||
|
||||
expect(gateway.granted.last.assistant, 'local');
|
||||
expect(gateway.granted.last.monthlyLimit, 1000000);
|
||||
expect(
|
||||
find.text('Local · 1,000,000 tokens a month · Not agreed yet'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a limit that isn't one is said so, and nothing is sent", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('Grant an assistant'));
|
||||
await tester.enterText(
|
||||
inSheet(find.widgetWithText(TextField, 'Handle')),
|
||||
'ben',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Limit a month'),
|
||||
'ten',
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
|
||||
|
||||
expect(find.text('Not a number this takes'), findsOneWidget);
|
||||
expect(gateway.calls.where((call) => call.startsWith('grant ')), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a handle nobody has is said so in the sheet, and nothing is '
|
||||
'granted', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('Grant an assistant'));
|
||||
await tester.enterText(
|
||||
inSheet(find.widgetWithText(TextField, 'Handle')),
|
||||
'zed',
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
|
||||
|
||||
expect(find.text('No account has this handle.'), findsOneWidget);
|
||||
expect(gateway.granted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an account found offers its grant, its handle filled in, an '
|
||||
'operator too', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Handle'), 'an');
|
||||
await tester.pumpAndSettle();
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
expect(find.text('Suspend'), findsNothing, reason: 'an operator');
|
||||
await tap(tester, find.text('Grant an assistant').last);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.text('anna'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
|
||||
expect(gateway.granted.single.account, 'anna');
|
||||
});
|
||||
|
||||
testWidgets('changes the limits; taking it back asks first', (
|
||||
tester,
|
||||
) async {
|
||||
gateway.granted.add(
|
||||
AssistantGrant(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
),
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu').last);
|
||||
await tap(tester, find.text('Change limits'));
|
||||
expect(find.widgetWithText(TextField, '1'), findsOneWidget);
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Limit a day'), '');
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Save'));
|
||||
expect(gateway.granted.single.dailyLimit, isNull);
|
||||
expect(find.text('Claude · No limits · Not agreed yet'), findsOneWidget);
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu').last);
|
||||
await tap(tester, find.text('Take back').last);
|
||||
expect(find.text('Take back Claude?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(TextButton, 'Cancel'));
|
||||
expect(gateway.granted, hasLength(1));
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu').last);
|
||||
await tap(tester, find.text('Take back').last);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
expect(gateway.granted, isEmpty);
|
||||
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a grant of an assistant no longer set says so, and can only '
|
||||
'be taken back', (tester) async {
|
||||
gateway.granted.add(
|
||||
AssistantGrant(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
assistant: 'gpt',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
),
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
expect(
|
||||
find.text('gpt · No longer set up · Not agreed yet'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.byTooltip('Show menu').last);
|
||||
expect(find.text('Change limits'), findsNothing);
|
||||
expect(
|
||||
find.text('Take back'),
|
||||
findsNWidgets(2),
|
||||
reason: "the operators' card's own, and the menu's",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("everyone's use by UTC month, never what was asked", (
|
||||
tester,
|
||||
) async {
|
||||
gateway.use[utcMonthOf(DateTime.now())] = [
|
||||
AssistantUse(
|
||||
assistant: 'claude',
|
||||
month: utcMonthOf(DateTime.now()),
|
||||
requests: 3,
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
costMicros: 8100,
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
),
|
||||
];
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('Use by month'));
|
||||
|
||||
expect(find.text('Use of assistants'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Claude · 3 requests · 1,500 tokens · €0.01'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(SegmentedButton<DateTime>), findsOneWidget);
|
||||
|
||||
final before = utcMonthOf(DateTime.now());
|
||||
await tap(
|
||||
tester,
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(SegmentedButton<DateTime>),
|
||||
matching: find.byType(Text),
|
||||
)
|
||||
.last,
|
||||
);
|
||||
expect(gateway.calls.last, isNot('figures ${before.toIso8601String()}'));
|
||||
expect(find.text('Nothing used in this month.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
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';
|
||||
|
||||
/// The operator's assistants (Halkyon Learn's ADR 0021, points 2, 6 and 8,
|
||||
/// D57 to D63): those set up by provider, granted to several people at
|
||||
/// once with one budget each, by handles suggested as they are typed; the
|
||||
/// people granted, changed and taken back; and everyone's use by month,
|
||||
/// the oldest on the left.
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
|
||||
const claude = SetUpAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
effort: LlmEffort.medium,
|
||||
maxEffort: LlmEffort.high,
|
||||
);
|
||||
const opus = SetUpAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
priceInput: 5,
|
||||
priceOutput: 25,
|
||||
);
|
||||
const local = SetUpAssistant(
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
protocol: 'openai',
|
||||
host: 'models.example.org',
|
||||
model: 'qwen',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
dependencies = TestAppDependencies();
|
||||
dependencies.operatorGateway
|
||||
..operates = true
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'anna',
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'ben',
|
||||
handle: 'ben',
|
||||
since: DateTime.utc(2026, 10, 2),
|
||||
),
|
||||
)
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'bob',
|
||||
handle: 'bob',
|
||||
since: DateTime.utc(2026, 10, 3),
|
||||
waitingSince: DateTime.utc(2026, 10, 3),
|
||||
),
|
||||
);
|
||||
gateway = dependencies.assistantOperatorGateway
|
||||
..set = const AssistantsSetUp(
|
||||
assistants: [claude, opus, local],
|
||||
currency: 'EUR',
|
||||
)
|
||||
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
|
||||
});
|
||||
|
||||
/// The operator's screen, its assistants opened.
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(const InstanceOperatorScreen(), dependencies: dependencies),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Assistants'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// [finder]'s match in the sheet, where the screen behind has its own.
|
||||
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();
|
||||
}
|
||||
|
||||
/// Adds the person [typed] suggests in the sheet.
|
||||
Future<void> addPerson(WidgetTester tester, String typed) async {
|
||||
await tester.enterText(inSheet(find.byType(TextField)).first, typed);
|
||||
await tester.pumpAndSettle();
|
||||
await tap(tester, inSheet(find.widgetWithText(ListTile, '@$typed')));
|
||||
}
|
||||
|
||||
Future<void> type(WidgetTester tester, String field, String text) async {
|
||||
await tester.enterText(
|
||||
inSheet(find.widgetWithText(TextField, field)),
|
||||
text,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder saveButton() => inSheet(find.byType(FilledButton));
|
||||
|
||||
testWidgets('lists those set up by provider, with their efforts and '
|
||||
'whether they have prices', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Anthropic'), findsOneWidget);
|
||||
expect(find.text('models.example.org'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'claude-sonnet-5-5 at api.anthropic.com · Effort medium, up to high',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('claude-opus-5-5 at api.anthropic.com'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'qwen at models.example.org · No prices: counts its tokens only',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('grants several people a whole provider at once, with one '
|
||||
'budget each, and lists them', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
expect(tester.widget<FilledButton>(saveButton()).onPressed, isNull);
|
||||
|
||||
await addPerson(tester, 'anna');
|
||||
await addPerson(tester, 'ben');
|
||||
expect(inSheet(find.widgetWithText(InputChip, '@anna')), findsOneWidget);
|
||||
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
|
||||
await tap(
|
||||
tester,
|
||||
inSheet(find.widgetWithText(CheckboxListTile, 'Anthropic')),
|
||||
);
|
||||
await type(tester, 'Money a day', '1,5');
|
||||
await type(tester, 'Tokens a month', '2,000,000');
|
||||
await tap(tester, saveButton());
|
||||
|
||||
for (final account in ['anna', 'ben']) {
|
||||
final grantee = gateway.people[account]!;
|
||||
expect(
|
||||
[for (final grant in grantee.grants) grant.assistant],
|
||||
[
|
||||
'claude',
|
||||
'opus',
|
||||
],
|
||||
);
|
||||
expect(
|
||||
grantee.budget,
|
||||
const AssistantBudget(dailyCostMicros: 1500000, monthlyTokens: 2000000),
|
||||
);
|
||||
}
|
||||
expect(find.byType(SheetScaffold), findsNothing);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Claude, Opus\n'
|
||||
'€1.50 of €1.50 left today\n'
|
||||
'2,000,000 of 2,000,000 tokens left this month\n'
|
||||
'2 not agreed to yet',
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('3 assistants · 2 people'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('suggests only handles there are of accounts approved '
|
||||
'(D61)', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
|
||||
await tester.enterText(inSheet(find.byType(TextField)).first, 'b');
|
||||
await tester.pumpAndSettle();
|
||||
expect(inSheet(find.text('@ben')), findsOneWidget);
|
||||
expect(inSheet(find.text('@bob')), findsNothing, reason: 'bob waits');
|
||||
|
||||
await tester.enterText(inSheet(find.byType(TextField)).first, 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
inSheet(find.text("No account's handle starts like this.")),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(inSheet(find.byType(InputChip)), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a handle typed whole is added by Enter', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
|
||||
await tester.enterText(inSheet(find.byType(TextField)).first, '@Ben');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('names whose budget a grant replaces before it does', (
|
||||
tester,
|
||||
) async {
|
||||
gateway.people['ben'] = AssistantGrantee(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
grants: [AssistantGrant(assistant: 'local', grantedAt: DateTime(2026))],
|
||||
budget: const AssistantBudget(dailyCostMicros: 1000000),
|
||||
);
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
|
||||
await addPerson(tester, 'anna');
|
||||
expect(inSheet(find.textContaining('replaces')), findsNothing);
|
||||
await addPerson(tester, 'ben');
|
||||
|
||||
expect(
|
||||
inSheet(find.text('Granting replaces the budget this person has:')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(inSheet(find.text('@ben: €1.00 a day')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a limit that isn't one is said so, and nothing is sent", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
await addPerson(tester, 'ben');
|
||||
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Local')));
|
||||
|
||||
await type(tester, 'Tokens a day', 'a lot');
|
||||
await tap(tester, saveButton());
|
||||
|
||||
expect(inSheet(find.text('Not a number this takes')), findsOneWidget);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("changes a person's assistants and budget, and takes every "
|
||||
'one back after asking', (tester) async {
|
||||
gateway.people['ben'] = AssistantGrantee(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
grants: [
|
||||
AssistantGrant(
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime(2026),
|
||||
agreedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
budget: const AssistantBudget(dailyTokens: 1000),
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
expect(inSheet(find.text('@ben')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<CheckboxListTile>(
|
||||
inSheet(find.widgetWithText(CheckboxListTile, 'Claude')),
|
||||
)
|
||||
.value,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(
|
||||
inSheet(find.widgetWithText(TextField, 'Tokens a day')),
|
||||
)
|
||||
.controller!
|
||||
.text,
|
||||
'1000',
|
||||
);
|
||||
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Claude')));
|
||||
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Local')));
|
||||
await type(tester, 'Tokens a day', '');
|
||||
await tap(tester, saveButton());
|
||||
|
||||
expect(gateway.calls, contains('change ben local'));
|
||||
expect(find.text('Local\nNo limits\n1 not agreed to yet'), findsOneWidget);
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
await tap(tester, find.text('Take back').last);
|
||||
expect(find.text('Take back every assistant?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
|
||||
expect(gateway.people, isEmpty);
|
||||
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('one granted an assistant no longer set says so, and changing '
|
||||
'takes it back', (tester) async {
|
||||
gateway.people['ben'] = AssistantGrantee(
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
grants: [
|
||||
AssistantGrant(assistant: 'claude', grantedAt: DateTime(2026)),
|
||||
AssistantGrant(
|
||||
assistant: 'gpt',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
expect(find.textContaining('gpt (no longer set up)'), findsOneWidget);
|
||||
await tap(tester, find.text('@ben'));
|
||||
expect(
|
||||
inSheet(find.text('Saving takes back what is no longer set up: gpt.')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('finds the people granted by the start of their handle', (
|
||||
tester,
|
||||
) async {
|
||||
for (final account in ['anna', 'ben']) {
|
||||
gateway.people[account] = AssistantGrantee(
|
||||
account: account,
|
||||
handle: account,
|
||||
grants: [AssistantGrant(assistant: 'local', grantedAt: DateTime(2026))],
|
||||
);
|
||||
}
|
||||
await show(tester);
|
||||
expect(find.text('@anna'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'People'), 'be');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@anna'), findsNothing);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'People'), 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Nobody with an assistant has a handle starting like this.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an account found offers its grant, the person in the sheet '
|
||||
'already', (tester) async {
|
||||
tester.view.physicalSize = const Size(412, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(const InstanceOperatorScreen(), dependencies: dependencies),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tap(tester, find.text('Accounts'));
|
||||
await tester.enterText(find.byType(TextField), 'ben');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
await tap(tester, find.text('Grant assistants'));
|
||||
|
||||
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("everyone's use by UTC month, the oldest on the left, never "
|
||||
'what was asked', (tester) async {
|
||||
final now = utcMonthOf(DateTime.now());
|
||||
gateway.use[now] = [
|
||||
AssistantUse(
|
||||
assistant: 'claude',
|
||||
month: now,
|
||||
requests: 3,
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
costMicros: 8100,
|
||||
account: 'ben',
|
||||
handle: 'ben',
|
||||
),
|
||||
];
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('Use by month'));
|
||||
|
||||
expect(find.text('Use of assistants'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Claude · 3 requests · 1,500 tokens · €0.01'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final months = find.descendant(
|
||||
of: find.byType(SegmentedButton<DateTime>),
|
||||
matching: find.byType(Text),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SegmentedButton<DateTime>>(
|
||||
find.byType(SegmentedButton<DateTime>),
|
||||
)
|
||||
.selected,
|
||||
{now},
|
||||
);
|
||||
|
||||
await tap(tester, months.first);
|
||||
expect(
|
||||
gateway.calls.last,
|
||||
'figures ${DateTime.utc(now.year, now.month - 2).toIso8601String()}',
|
||||
);
|
||||
expect(find.text('Nothing used in this month.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@ 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.
|
||||
/// 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;
|
||||
|
|
@ -20,9 +21,6 @@ void main() {
|
|||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
|
|
@ -32,6 +30,9 @@ void main() {
|
|||
assistants: [claude],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
budget: AssistantBudget(dailyCostMicros: 1000000),
|
||||
usedToday: BudgetUse(costMicros: 8100, tokens: 900),
|
||||
usedThisMonth: BudgetUse(costMicros: 8100, tokens: 900),
|
||||
);
|
||||
store = MemoryOwnAssistantStore();
|
||||
});
|
||||
|
|
@ -77,48 +78,89 @@ void main() {
|
|||
expect(find.text('Assistants'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows who answers and what is left of its limits', (
|
||||
tester,
|
||||
) async {
|
||||
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\n€0.99 of €1.00 left today\n'
|
||||
'Read and agree',
|
||||
),
|
||||
find.text('claude-sonnet-5-5 at api.anthropic.com\nRead and agree'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Your use'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a limit in tokens names them once', (tester) async {
|
||||
testWidgets('a budget 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,
|
||||
),
|
||||
],
|
||||
assistants: [claude],
|
||||
budget: AssistantBudget(dailyTokens: 100000, monthlyTokens: 2000000),
|
||||
usedToday: BudgetUse(tokens: 350000),
|
||||
usedThisMonth: BudgetUse(tokens: 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',
|
||||
'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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue