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
13 changed files with 5348 additions and 2709 deletions
|
|
@ -4,8 +4,9 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
// The person's assistants in an app's settings, the operator's grants and
|
||||
// the use by month (Halkyon Learn's ADR 0021, step 2).
|
||||
// The person's assistants in an app's settings, the operator granting them
|
||||
// to several people at once with one budget each (D58), and the use by
|
||||
// month, the oldest on the left (Halkyon Learn's ADR 0021, step 2, D57).
|
||||
|
||||
const _claude = OfferedAssistant(
|
||||
id: 'claude',
|
||||
|
|
@ -34,6 +35,13 @@ const _setUp = AssistantsSetUp(
|
|||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
),
|
||||
SetUpAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -128,129 +136,163 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('AssistantGrantsCubit', () {
|
||||
group('AssistantGrantCubit', () {
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
late FakeInstanceOperatorGateway accounts;
|
||||
late AssistantOperatorRepository repository;
|
||||
late AssistantGrantsCubit cubit;
|
||||
|
||||
final anna = OperatedAccount(
|
||||
id: 'anna',
|
||||
handle: 'anna',
|
||||
since: DateTime(2026),
|
||||
);
|
||||
final ben = OperatedAccount(
|
||||
id: 'ben',
|
||||
handle: 'ben',
|
||||
since: DateTime(2026),
|
||||
);
|
||||
const budget = AssistantBudget(dailyCostMicros: 1000000, monthlyTokens: 9);
|
||||
|
||||
AssistantGrantCubit cubitOf({
|
||||
List<OperatedAccount> accounts = const [],
|
||||
AssistantGrantee? grantee,
|
||||
}) {
|
||||
final cubit = AssistantGrantCubit(
|
||||
repository,
|
||||
setUp: _setUp,
|
||||
accounts: accounts,
|
||||
grantee: grantee,
|
||||
);
|
||||
addTearDown(cubit.close);
|
||||
return cubit;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantOperatorGateway()
|
||||
..set = _setUp
|
||||
..accounts.addAll({'anna': 'anna'});
|
||||
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
|
||||
accounts = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(
|
||||
OperatedAccount(id: 'anna', handle: 'anna', since: DateTime(2026)),
|
||||
);
|
||||
..add(anna)
|
||||
..add(ben);
|
||||
repository = AssistantOperatorRepository(gateway, accounts);
|
||||
cubit = AssistantGrantsCubit(repository);
|
||||
addTearDown(() async {
|
||||
await cubit.close();
|
||||
await repository.dispose();
|
||||
});
|
||||
addTearDown(repository.dispose);
|
||||
});
|
||||
|
||||
test('reads the assistants set and the grants', () async {
|
||||
await cubit.load();
|
||||
test('grants each person each assistant chosen, all with one '
|
||||
'budget', () async {
|
||||
final cubit = cubitOf(accounts: [anna])
|
||||
..add(ben)
|
||||
..chooseAll(const ['claude', 'opus'], on: true);
|
||||
expect(cubit.state.canSave, isTrue);
|
||||
|
||||
expect(cubit.state.setUp, _setUp);
|
||||
expect(cubit.state.grants, isEmpty);
|
||||
expect(cubit.state.showsAnything, isTrue);
|
||||
expect(await cubit.save(budget), isTrue);
|
||||
|
||||
expect(gateway.calls.last, 'grant anna ben claude opus');
|
||||
expect(gateway.people['ben']!.budget, budget);
|
||||
expect(cubit.state.busy, isFalse);
|
||||
});
|
||||
|
||||
test('shows nothing without an assistant set or a grant', () async {
|
||||
gateway.set = const AssistantsSetUp();
|
||||
await cubit.load();
|
||||
test('a person is added once, and taken off again', () {
|
||||
final cubit = cubitOf()
|
||||
..add(anna)
|
||||
..add(anna)
|
||||
..add(ben)
|
||||
..remove('anna');
|
||||
|
||||
expect(cubit.state.showsAnything, isFalse);
|
||||
expect(cubit.state.accounts, [ben]);
|
||||
});
|
||||
|
||||
test('granting by handle reads the grants anew', () async {
|
||||
await cubit.load();
|
||||
final failed = await cubit.grant(
|
||||
test("names the people whose budget a grant replaces, as they're "
|
||||
'added', () async {
|
||||
await repository.grant(
|
||||
accounts: ['ben'],
|
||||
assistants: ['claude'],
|
||||
budget: budget,
|
||||
);
|
||||
final cubit = cubitOf(accounts: [anna]);
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.replaced, isEmpty);
|
||||
|
||||
cubit.add(ben);
|
||||
await pumpEventQueue();
|
||||
expect([for (final each in cubit.state.replaced) each.account], ['ben']);
|
||||
|
||||
cubit.remove('ben');
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.replaced, isEmpty);
|
||||
});
|
||||
|
||||
test('chooses assistants one by one, or a whole provider', () {
|
||||
final cubit = cubitOf()..choose('claude', on: true);
|
||||
expect(cubit.state.chosen, {'claude'});
|
||||
|
||||
cubit.chooseAll(const ['claude', 'opus'], on: true);
|
||||
expect(cubit.state.chosen, {'claude', 'opus'});
|
||||
|
||||
cubit.chooseAll(const ['claude', 'opus'], on: false);
|
||||
expect(cubit.state.chosen, isEmpty);
|
||||
});
|
||||
|
||||
test('saves nothing without a person and an assistant', () async {
|
||||
final nobody = cubitOf()..choose('claude', on: true);
|
||||
final nothing = cubitOf(accounts: [anna]);
|
||||
|
||||
for (final cubit in [nobody, nothing]) {
|
||||
expect(cubit.state.canSave, isFalse);
|
||||
expect(await cubit.save(budget), isFalse);
|
||||
}
|
||||
expect(gateway.calls.where((call) => call.startsWith('grant ')), isEmpty);
|
||||
});
|
||||
|
||||
test("the instance's refusal is the sheet's, and nothing is "
|
||||
'granted', () async {
|
||||
final cubit = cubitOf(accounts: [anna])..choose('claude', on: true);
|
||||
|
||||
expect(
|
||||
await cubit.save(const AssistantBudget(dailyTokens: -1)),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
expect(
|
||||
cubit.state.refused,
|
||||
const OperatorFailed(OperatorProblem.invalidLimit),
|
||||
);
|
||||
expect(cubit.state.busy, isFalse);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
test("changing starts from the person's assistants still set, and "
|
||||
'gives them exactly those chosen', () async {
|
||||
final grantee = AssistantGrantee(
|
||||
account: 'anna',
|
||||
handle: 'anna',
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
grants: [
|
||||
AssistantGrant(assistant: 'claude', grantedAt: DateTime(2026)),
|
||||
AssistantGrant(
|
||||
assistant: 'gone',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
budget: budget,
|
||||
);
|
||||
final cubit = cubitOf(grantee: grantee);
|
||||
expect(cubit.state.chosen, {'claude'});
|
||||
|
||||
expect(failed, isNull);
|
||||
expect(cubit.state.grants.single.account, 'anna');
|
||||
expect(cubit.state.busy, isNull);
|
||||
});
|
||||
cubit
|
||||
..choose('claude', on: false)
|
||||
..choose('opus', on: true);
|
||||
expect(await cubit.save(AssistantBudget.none), isTrue);
|
||||
|
||||
test("a handle nobody has is the sheet's to say, not the "
|
||||
"screen's", () async {
|
||||
await cubit.load();
|
||||
final failed = await cubit.grant(
|
||||
handle: 'bob',
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
);
|
||||
|
||||
expect(failed, const OperatorFailed(OperatorProblem.notFound));
|
||||
expect(cubit.state.problem, isNull);
|
||||
expect(cubit.state.grants, isEmpty);
|
||||
});
|
||||
|
||||
test('changes the limits, and takes a grant back', () async {
|
||||
await cubit.load();
|
||||
await cubit.grant(
|
||||
handle: 'anna',
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
);
|
||||
|
||||
expect(
|
||||
await cubit.changeLimits(
|
||||
cubit.state.grants.single,
|
||||
dailyLimit: 5,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(cubit.state.grants.single.dailyLimit, 5);
|
||||
expect(cubit.state.grants.single.monthlyLimit, isNull);
|
||||
|
||||
await cubit.revoke(cubit.state.grants.single);
|
||||
expect(cubit.state.grants, isEmpty);
|
||||
});
|
||||
|
||||
test("taking back what isn't granted any more is the screen's "
|
||||
'problem', () async {
|
||||
await cubit.load();
|
||||
await cubit.revoke(
|
||||
AssistantGrant(
|
||||
account: 'anna',
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime.utc(2026, 10, 25),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const OperatorFailed(OperatorProblem.notFound),
|
||||
);
|
||||
});
|
||||
|
||||
test("someone who doesn't operate the instance reads nothing", () async {
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
|
||||
await cubit.load();
|
||||
|
||||
expect(
|
||||
cubit.state.unreadable,
|
||||
const OperatorFailed(OperatorProblem.notAllowed),
|
||||
);
|
||||
expect(cubit.state.showsAnything, isFalse);
|
||||
expect(gateway.calls.last, 'change anna opus');
|
||||
expect(gateway.people['anna']!.budget, AssistantBudget.none);
|
||||
});
|
||||
});
|
||||
|
||||
group('AssistantUseCubit', () {
|
||||
test('offers this UTC month and the two before, and reads the one '
|
||||
'shown', () async {
|
||||
test('offers this UTC month and the two before, the oldest first, and '
|
||||
'reads the one shown', () async {
|
||||
final asked = <DateTime>[];
|
||||
final use = AssistantUse(
|
||||
assistant: 'claude',
|
||||
|
|
@ -267,10 +309,11 @@ void main() {
|
|||
addTearDown(cubit.close);
|
||||
|
||||
expect(cubit.state.months, [
|
||||
DateTime.utc(2026),
|
||||
DateTime.utc(2025, 12),
|
||||
DateTime.utc(2025, 11),
|
||||
DateTime.utc(2025, 12),
|
||||
DateTime.utc(2026),
|
||||
]);
|
||||
expect(cubit.state.month, DateTime.utc(2026));
|
||||
await cubit.show();
|
||||
expect(cubit.state.use, [use]);
|
||||
expect(cubit.state.loading, isFalse);
|
||||
|
|
|
|||
|
|
@ -266,8 +266,8 @@ void main() {
|
|||
(protocol.AssistantRefusal.notAgreed, AssistantProblem.notAgreed),
|
||||
(protocol.AssistantRefusal.limitReached, AssistantProblem.limitReached),
|
||||
(
|
||||
protocol.AssistantRefusal.limitOutdated,
|
||||
AssistantProblem.limitOutdated,
|
||||
protocol.AssistantRefusal.invalidEffort,
|
||||
AssistantProblem.invalidEffort,
|
||||
),
|
||||
(
|
||||
protocol.AssistantRefusal.imagesNotRead,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:halkyon_llm/halkyon_llm.dart';
|
|||
|
||||
// Who answers a person's question (Halkyon Learn's ADR 0021, points 7 and
|
||||
// 10): an assistant granted, through the instance, or their own key,
|
||||
// directly; and the operator granting assistants by handle (points 2, 6).
|
||||
// directly; and the operator granting assistants to several people at once,
|
||||
// with one budget each (points 2, 6, D58), suggesting only handles there are
|
||||
// (D61).
|
||||
|
||||
const _claude = OfferedAssistant(
|
||||
id: 'claude',
|
||||
|
|
@ -30,6 +32,7 @@ LlmRequest _request() => LlmRequest(
|
|||
maxOutputTokens: 400,
|
||||
system: 'You help with flashcards.',
|
||||
personId: 'person-1',
|
||||
effort: LlmEffort.high,
|
||||
);
|
||||
|
||||
void main() {
|
||||
|
|
@ -61,6 +64,7 @@ void main() {
|
|||
|
||||
expect(events.last, same(finished));
|
||||
expect(gateway.asked.single.$1, 'claude');
|
||||
expect(gateway.asked.single.$2.effort, LlmEffort.high);
|
||||
expect(llm.sent, isEmpty);
|
||||
});
|
||||
|
||||
|
|
@ -79,6 +83,7 @@ void main() {
|
|||
expect(endpoint.model, 'gpt-5');
|
||||
expect(endpoint.apiKey, 'sk-own');
|
||||
expect(request.personId, isNull);
|
||||
expect(request.effort, isNull, reason: 'own keys choose no effort');
|
||||
expect(request.system, 'You help with flashcards.');
|
||||
expect(request.maxOutputTokens, 400);
|
||||
expect(gateway.calls, isEmpty);
|
||||
|
|
@ -140,13 +145,57 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('the effort a question may ask at, and its ceiling (D63)', () {
|
||||
const thinking = OfferedAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
effort: LlmEffort.low,
|
||||
efforts: [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
|
||||
);
|
||||
|
||||
test("a granted assistant offers the operator's range, its default "
|
||||
'first', () {
|
||||
const choice = GrantedAssistant(thinking);
|
||||
expect(choice.efforts, [LlmEffort.low, LlmEffort.medium, LlmEffort.high]);
|
||||
expect(choice.effort, LlmEffort.low);
|
||||
});
|
||||
|
||||
test('a question asks for the ceiling of its effort, else of the '
|
||||
"assistant's", () {
|
||||
const choice = GrantedAssistant(thinking);
|
||||
expect(choice.outputCeilingAt(LlmEffort.high), 16384);
|
||||
expect(choice.outputCeilingAt(null), LlmEffort.low.outputCeiling);
|
||||
});
|
||||
|
||||
test("one without an effort asks for the models' default ceiling", () {
|
||||
const choice = GrantedAssistant(_claude);
|
||||
expect(choice.efforts, isEmpty);
|
||||
expect(choice.effort, isNull);
|
||||
expect(choice.outputCeilingAt(null), LlmEffort.defaultOutputCeiling);
|
||||
});
|
||||
|
||||
test('an own key offers no effort and asks for the small ceiling it '
|
||||
'always had, whatever its model allows', () {
|
||||
const choice = PersonalAssistant(_own);
|
||||
expect(choice.efforts, isEmpty);
|
||||
expect(choice.effort, isNull);
|
||||
expect(choice.outputCeilingAt(LlmEffort.max), 4096);
|
||||
});
|
||||
});
|
||||
|
||||
group('AssistantOperatorRepository', () {
|
||||
const anna = 'account-anna';
|
||||
const annabel = 'account-annabel';
|
||||
const ben = 'account-ben';
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
late FakeInstanceOperatorGateway accounts;
|
||||
late AssistantOperatorRepository repository;
|
||||
|
||||
const budget = AssistantBudget(dailyCostMicros: 1000000);
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantOperatorGateway()
|
||||
..set = const AssistantsSetUp(
|
||||
|
|
@ -160,9 +209,16 @@ void main() {
|
|||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
),
|
||||
SetUpAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
),
|
||||
],
|
||||
)
|
||||
..accounts.addAll({anna: 'Anna', annabel: 'annabel'});
|
||||
..accounts.addAll({anna: 'Anna', annabel: 'annabel', ben: 'ben'});
|
||||
accounts = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(OperatedAccount(id: anna, handle: 'Anna', since: DateTime(2026)))
|
||||
|
|
@ -172,50 +228,84 @@ void main() {
|
|||
handle: 'annabel',
|
||||
since: DateTime(2026),
|
||||
),
|
||||
);
|
||||
)
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'account-anders',
|
||||
handle: 'anders',
|
||||
since: DateTime(2026),
|
||||
waitingSince: DateTime(2026),
|
||||
),
|
||||
)
|
||||
..add(OperatedAccount(id: 'account-none', since: DateTime(2026)));
|
||||
repository = AssistantOperatorRepository(gateway, accounts);
|
||||
addTearDown(repository.dispose);
|
||||
});
|
||||
|
||||
test('grants by the exact handle, an @ before it and its case left '
|
||||
'out, and tells the screen', () async {
|
||||
test('grants several people several assistants at once, each the same '
|
||||
'budget, and tells the screens', () async {
|
||||
final changed = expectLater(repository.changes, emits(null));
|
||||
|
||||
await repository.grant(
|
||||
handle: ' @anna ',
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
accounts: [anna, ben],
|
||||
assistants: ['claude', 'opus'],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
await changed;
|
||||
final grant = gateway.granted.single;
|
||||
expect(grant.account, anna);
|
||||
expect(grant.unit, AssistantLimitUnit.money);
|
||||
expect(grant.dailyLimit, 1000000);
|
||||
expect(grant.monthlyLimit, isNull);
|
||||
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, budget);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a handle nobody has, or only the start of one, and grants '
|
||||
'nothing', () async {
|
||||
for (final handle in ['bob', 'ann', '@', '']) {
|
||||
await expectLater(
|
||||
repository.grant(
|
||||
handle: handle,
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
expect(gateway.granted, isEmpty);
|
||||
test('suggests approved accounts whose handle starts as typed, an @ and '
|
||||
'its case left out, at most as many as asked', () async {
|
||||
expect(
|
||||
accounts.calls.where((call) => call == 'accounts '),
|
||||
isEmpty,
|
||||
reason: 'nothing typed is never looked up',
|
||||
[for (final account in await repository.grantable(' @AN ')) account.id],
|
||||
[anna, annabel],
|
||||
reason: 'anders waits, and an account without a handle is none',
|
||||
);
|
||||
expect(await repository.grantable('an', count: 1), hasLength(1));
|
||||
expect(await repository.grantable('bob'), isEmpty);
|
||||
});
|
||||
|
||||
test('suggests nothing for nothing typed, and asks nothing', () async {
|
||||
for (final typed in ['', ' ', '@']) {
|
||||
expect(await repository.grantable(typed), isEmpty, reason: typed);
|
||||
}
|
||||
expect(accounts.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('reads the people a grant would replace the budget of; none asked, '
|
||||
'none read', () async {
|
||||
await repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['claude'],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
final asked = gateway.calls.length;
|
||||
expect(await repository.granteesOf(const []), isEmpty);
|
||||
expect(gateway.calls, hasLength(asked));
|
||||
expect(
|
||||
[
|
||||
for (final grantee in await repository.granteesOf([anna, ben]))
|
||||
grantee.account,
|
||||
],
|
||||
[anna],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads the people granted by the start of their handle, an @ left '
|
||||
'out, a page from where the last ended', () async {
|
||||
await repository.grantees(' @an', offset: 50);
|
||||
|
||||
expect(gateway.calls.single, 'grantees an 50');
|
||||
});
|
||||
|
||||
test("the instance's refusals pass through, and change nothing", () async {
|
||||
|
|
@ -223,57 +313,72 @@ void main() {
|
|||
repository.changes.listen((_) => changes++);
|
||||
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'gpt',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['gpt'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.assistantNotSet)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
repository.change(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: null,
|
||||
assistants: ['claude'],
|
||||
budget: const AssistantBudget(monthlyTokens: -1),
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.invalidLimit)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.grant(
|
||||
accounts: [anna, 'account-gone'],
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.revoke(ben),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(changes, 0);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
test('changing limits keeps the agreement; taking one back removes '
|
||||
'it', () async {
|
||||
await repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
test('changing keeps the agreements of the assistants kept; taking back '
|
||||
'removes every one and the budget', () async {
|
||||
await repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['claude', 'opus'],
|
||||
budget: budget,
|
||||
);
|
||||
gateway.granted[0] = AssistantGrant(
|
||||
final agreed = DateTime.utc(2026, 10, 25);
|
||||
gateway.people[anna] = AssistantGrantee(
|
||||
account: anna,
|
||||
handle: 'Anna',
|
||||
assistant: 'claude',
|
||||
grantedAt: gateway.granted[0].grantedAt,
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
agreedAt: DateTime.utc(2026, 10, 25),
|
||||
grants: [
|
||||
for (final grant in gateway.people[anna]!.grants)
|
||||
AssistantGrant(
|
||||
assistant: grant.assistant,
|
||||
grantedAt: grant.grantedAt,
|
||||
agreedAt: agreed,
|
||||
),
|
||||
],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
await repository.changeLimits(
|
||||
await repository.change(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 5,
|
||||
monthlyLimit: null,
|
||||
assistants: ['opus'],
|
||||
budget: AssistantBudget.none,
|
||||
);
|
||||
expect(gateway.granted.single.agreedAt, DateTime.utc(2026, 10, 25));
|
||||
expect(gateway.granted.single.dailyLimit, 5);
|
||||
final changed = gateway.people[anna]!;
|
||||
expect(changed.grants.single.assistant, 'opus');
|
||||
expect(changed.grants.single.agreedAt, agreed);
|
||||
expect(changed.budget, AssistantBudget.none);
|
||||
|
||||
await repository.revoke(account: anna, assistant: 'claude');
|
||||
expect(gateway.granted, isEmpty);
|
||||
await repository.revoke(anna);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
test('asks the figures of the UTC month a moment is in', () async {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@ import 'dart:convert';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
hide AssistantLimitUnit, AssistantsSetUp;
|
||||
hide AssistantGrantee, AssistantUse, AssistantsSetUp;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
as protocol
|
||||
show AssistantLimitUnit, AssistantsSetUp;
|
||||
show AssistantGrantee, AssistantUse, AssistantsSetUp;
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// An instance's assistants as the network shows them: Claude granted to
|
||||
/// the person, priced; or each call refuses as [refusals] says, by its
|
||||
/// the person, with a budget partly used; or each call refuses as [refusals] says, by its
|
||||
/// endpoint and method. Asking streams over a WebSocket, which this can't
|
||||
/// fake: `eventsOf` is tested on its own.
|
||||
final class _Instance() {
|
||||
|
|
@ -50,15 +51,15 @@ final class _Instance() {
|
|||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
agreedAt: DateTime.utc(2026, 10, 25, 9),
|
||||
limitUnit: protocol.AssistantLimitUnit.costMicros,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
usedThisMonth: 8100,
|
||||
outdated: false,
|
||||
effort: AssistantEffort.medium,
|
||||
efforts: [AssistantEffort.low, AssistantEffort.medium],
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
budget: AssistantLimits(dailyCostMicros: 1000000, monthlyTokens: 50000),
|
||||
usedToday: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
|
||||
usedThisMonth: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/setUp' => protocol.AssistantsSetUp(
|
||||
assistants: [
|
||||
|
|
@ -71,6 +72,8 @@ final class _Instance() {
|
|||
images: true,
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
effort: AssistantEffort.medium,
|
||||
maxEffort: AssistantEffort.high,
|
||||
),
|
||||
AssistantInfo(
|
||||
id: 'local',
|
||||
|
|
@ -83,17 +86,29 @@ final class _Instance() {
|
|||
],
|
||||
currency: 'EUR',
|
||||
ownKeys: false,
|
||||
people: 1,
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/grants' => [
|
||||
AssistantGrantOverview(
|
||||
'halkyon_base.assistantOperator/grantees' ||
|
||||
'halkyon_base.assistantOperator/granteesOf' => [
|
||||
protocol.AssistantGrantee(
|
||||
account: anna,
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
limitUnit: protocol.AssistantLimitUnit.tokens,
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
assistants: [
|
||||
AssistantGranted(
|
||||
assistant: 'claude',
|
||||
assistantSet: true,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
agreedAt: DateTime.utc(2026, 10, 21),
|
||||
),
|
||||
AssistantGranted(
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
),
|
||||
],
|
||||
budget: AssistantLimits(monthlyTokens: 2000000),
|
||||
usedToday: protocol.AssistantUse(costMicros: 0, tokens: 300),
|
||||
usedThisMonth: protocol.AssistantUse(costMicros: 900, tokens: 1500),
|
||||
).toJson(),
|
||||
],
|
||||
'halkyon_base.assistant/figures' ||
|
||||
|
|
@ -129,6 +144,7 @@ final class _Instance() {
|
|||
return switch (call) {
|
||||
'halkyon_base.assistant/agree' ||
|
||||
'halkyon_base.assistantOperator/grant' ||
|
||||
'halkyon_base.assistantOperator/change' ||
|
||||
'halkyon_base.assistantOperator/revoke' => http.Response('null', 200),
|
||||
_ when _answer(call) != null => http.Response(
|
||||
jsonEncode(_answer(call)),
|
||||
|
|
@ -159,7 +175,8 @@ void main() {
|
|||
|
||||
setUp(() => gateway = ServerpodAssistantGateway(app.modules));
|
||||
|
||||
test('reads what is offered, priced in money, with what is left', () async {
|
||||
test('reads what is offered, with its efforts, and the budget with '
|
||||
'what is left', () async {
|
||||
final offer = await gateway.offered();
|
||||
|
||||
expect(
|
||||
|
|
@ -174,18 +191,34 @@ void main() {
|
|||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
agreedAt: DateTime.utc(2026, 10, 25, 9),
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
usedThisMonth: 8100,
|
||||
effort: LlmEffort.medium,
|
||||
efforts: [LlmEffort.low, LlmEffort.medium],
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
budget: const AssistantBudget(
|
||||
dailyCostMicros: 1000000,
|
||||
monthlyTokens: 50000,
|
||||
),
|
||||
usedToday: const BudgetUse(costMicros: 8100, tokens: 1500),
|
||||
usedThisMonth: const BudgetUse(costMicros: 8100, tokens: 1500),
|
||||
),
|
||||
);
|
||||
expect(offer.assistants.single.leftToday, 991900);
|
||||
expect(offer.assistants.single.leftThisMonth, isNull);
|
||||
expect(offer.left, [
|
||||
(
|
||||
unit: AssistantLimitUnit.money,
|
||||
daily: true,
|
||||
limit: 1000000,
|
||||
left: 991900,
|
||||
),
|
||||
(
|
||||
unit: AssistantLimitUnit.tokens,
|
||||
daily: false,
|
||||
limit: 50000,
|
||||
left: 48500,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test("agrees by the assistant's ID, and reads its own figures", () async {
|
||||
|
|
@ -246,54 +279,102 @@ void main() {
|
|||
|
||||
setUp(() => gateway = ServerpodAssistantOperatorGateway(app.modules));
|
||||
|
||||
test('reads the assistants set, priced or not, without keys', () async {
|
||||
test('reads the assistants set, priced or not, with their efforts, '
|
||||
'without keys', () async {
|
||||
final setUp = await gateway.setUp();
|
||||
|
||||
expect(setUp.currency, 'EUR');
|
||||
expect(setUp.ownKeys, isFalse);
|
||||
expect(setUp.named('claude')?.unit, AssistantLimitUnit.money);
|
||||
expect(setUp.named('local')?.unit, AssistantLimitUnit.tokens);
|
||||
expect(setUp.people, 1);
|
||||
expect(setUp.named('claude')?.priced, isTrue);
|
||||
expect(setUp.named('claude')?.effort, LlmEffort.medium);
|
||||
expect(setUp.named('claude')?.maxEffort, LlmEffort.high);
|
||||
expect(setUp.named('local')?.priced, isFalse);
|
||||
expect(setUp.named('local')?.effort, isNull);
|
||||
expect(setUp.named('gpt'), isNull);
|
||||
});
|
||||
|
||||
test('reads grants, one of an assistant no longer set', () async {
|
||||
expect(await gateway.grants(), [
|
||||
AssistantGrant(
|
||||
test('reads the people granted, a page by the start of their handle, '
|
||||
'with their budget and use', () async {
|
||||
final grantees = await gateway.grantees(startingWith: 'an', offset: 50);
|
||||
|
||||
expect(grantees, [
|
||||
AssistantGrantee(
|
||||
account: '${_Instance.anna}',
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
grants: [
|
||||
AssistantGrant(
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
agreedAt: DateTime.utc(2026, 10, 21),
|
||||
),
|
||||
AssistantGrant(
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
),
|
||||
],
|
||||
budget: const AssistantBudget(monthlyTokens: 2000000),
|
||||
usedToday: const BudgetUse(tokens: 300),
|
||||
usedThisMonth: const BudgetUse(costMicros: 900, tokens: 1500),
|
||||
),
|
||||
]);
|
||||
expect(
|
||||
jsonDecode(
|
||||
instance.bodies['halkyon_base.assistantOperator/grantees']!,
|
||||
),
|
||||
{'startingWith': 'an', 'offset': 50},
|
||||
);
|
||||
|
||||
await gateway.granteesOf(['${_Instance.anna}']);
|
||||
expect(
|
||||
jsonDecode(
|
||||
instance.bodies['halkyon_base.assistantOperator/granteesOf']!,
|
||||
),
|
||||
{
|
||||
'accounts': ['${_Instance.anna}'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('grants and takes back by account ID, and asks a month in '
|
||||
'UTC', () async {
|
||||
test('grants several at once, changes and takes back by account ID, '
|
||||
'and asks a month in UTC', () async {
|
||||
final anna = '${_Instance.anna}';
|
||||
await gateway.grant(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
accounts: [anna],
|
||||
assistants: ['claude', 'local'],
|
||||
budget: const AssistantBudget(dailyCostMicros: 1000000),
|
||||
);
|
||||
await gateway.revoke(account: anna, assistant: 'claude');
|
||||
await gateway.change(
|
||||
account: anna,
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
);
|
||||
await gateway.revoke(anna);
|
||||
await gateway.figures(DateTime.utc(2026, 10));
|
||||
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/grant']!),
|
||||
{
|
||||
'accounts': [anna],
|
||||
'assistants': ['claude', 'local'],
|
||||
'budget': {
|
||||
'__className__': 'halkyon_base.AssistantLimits',
|
||||
'dailyCostMicros': 1000000,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/change']!),
|
||||
{
|
||||
'account': anna,
|
||||
'assistant': 'claude',
|
||||
'dailyLimit': 1000000,
|
||||
'monthlyLimit': null,
|
||||
'assistants': ['claude'],
|
||||
'budget': {'__className__': 'halkyon_base.AssistantLimits'},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/revoke']!),
|
||||
{'account': anna, 'assistant': 'claude'},
|
||||
{'account': anna},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/figures']!),
|
||||
|
|
@ -303,7 +384,7 @@ void main() {
|
|||
|
||||
test("an account ID that isn't one fails, and nothing is sent", () async {
|
||||
await expectLater(
|
||||
gateway.revoke(account: 'anna', assistant: 'claude'),
|
||||
gateway.revoke('anna'),
|
||||
throwsA(const OperatorFailed(OperatorProblem.failed)),
|
||||
);
|
||||
expect(instance.bodies, isEmpty);
|
||||
|
|
@ -319,10 +400,9 @@ void main() {
|
|||
OperatorRefusedException(reason: refusal);
|
||||
await expectLater(
|
||||
gateway.grant(
|
||||
account: '${_Instance.anna}',
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
accounts: ['${_Instance.anna}'],
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(OperatorFailed(problem)),
|
||||
reason: refusal.name,
|
||||
|
|
@ -332,9 +412,9 @@ void main() {
|
|||
|
||||
test("someone who doesn't operate the instance is not allowed", () async {
|
||||
for (final status in [401, 403]) {
|
||||
instance.refusals['halkyon_base.assistantOperator/grants'] = status;
|
||||
instance.refusals['halkyon_base.assistantOperator/grantees'] = status;
|
||||
await expectLater(
|
||||
gateway.grants(),
|
||||
gateway.grantees(startingWith: '', offset: 0),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
|
||||
reason: '$status',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
/// The operator's screen (Halkyon Learn's ADR 0021, D21 to D24): the
|
||||
/// accounts that wait, who operates, accounts found by handle, and each
|
||||
/// action one at a time.
|
||||
void main() {
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
late InstanceOperatorCubit cubit;
|
||||
|
||||
final anna = OperatedAccount(
|
||||
id: 'anna',
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
);
|
||||
final ben = OperatedAccount(
|
||||
id: 'ben',
|
||||
handle: 'ben',
|
||||
since: DateTime.utc(2026, 10, 24),
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
);
|
||||
final bea = OperatedAccount(
|
||||
id: 'bea',
|
||||
handle: 'bea',
|
||||
since: DateTime.utc(2026, 10, 2),
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(anna)
|
||||
..add(ben)
|
||||
..add(bea);
|
||||
final operators = InstanceOperatorRepository(gateway);
|
||||
cubit = InstanceOperatorCubit(operators);
|
||||
addTearDown(() async {
|
||||
await cubit.close();
|
||||
await operators.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('reads who waits and who operates', () async {
|
||||
expect(cubit.state.loading, isTrue);
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state.loading, isFalse);
|
||||
expect(cubit.state.waiting, [ben]);
|
||||
expect(cubit.state.operators, [anna]);
|
||||
});
|
||||
|
||||
test(
|
||||
"can't read them where the person doesn't operate the instance",
|
||||
() async {
|
||||
gateway.operates = false;
|
||||
await cubit.load();
|
||||
|
||||
expect(
|
||||
cubit.state.unreadable,
|
||||
const OperatorFailed(OperatorProblem.notAllowed),
|
||||
);
|
||||
expect(cubit.state.waiting, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('approving reads the lists anew: the account no longer waits', () async {
|
||||
await cubit.load();
|
||||
await cubit.approve('ben');
|
||||
|
||||
expect(cubit.state.waiting, isEmpty);
|
||||
expect(cubit.state.busy, isNull);
|
||||
expect(cubit.state.problem, isNull);
|
||||
expect(gateway.calls, contains('approve ben'));
|
||||
});
|
||||
|
||||
test('one action at a time: another asked meanwhile is ignored', () async {
|
||||
await cubit.load();
|
||||
final approving = cubit.approve('ben');
|
||||
expect(cubit.state.busy, 'ben');
|
||||
await cubit.suspend('bea');
|
||||
await approving;
|
||||
|
||||
expect(gateway.calls, isNot(contains('suspend bea')));
|
||||
expect(cubit.state.busy, isNull);
|
||||
});
|
||||
|
||||
test('a refusal is said, nothing changed, and the next action clears '
|
||||
'it', () async {
|
||||
await cubit.load();
|
||||
await cubit.suspend('anna');
|
||||
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const OperatorFailed(OperatorProblem.isOperator),
|
||||
);
|
||||
expect(cubit.state.operators, [anna]);
|
||||
|
||||
await cubit.takeBack('anna');
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const OperatorFailed(OperatorProblem.lastOperator),
|
||||
);
|
||||
|
||||
await cubit.approve('ben');
|
||||
expect(cubit.state.problem, isNull);
|
||||
});
|
||||
|
||||
test('finds accounts as a handle is typed, and finds them anew after an '
|
||||
'action', () async {
|
||||
await cubit.load();
|
||||
await cubit.find('be');
|
||||
expect(cubit.state.found, [bea, ben]);
|
||||
|
||||
await cubit.suspend('bea');
|
||||
expect(cubit.state.query, 'be');
|
||||
expect(cubit.state.found.first.suspended, isTrue);
|
||||
expect(cubit.state.waiting.map((each) => each.id), ['ben', 'bea']);
|
||||
});
|
||||
|
||||
test('an older search answering late is dropped', () async {
|
||||
await cubit.load();
|
||||
final first = cubit.find('b');
|
||||
final second = cubit.find('an');
|
||||
await second;
|
||||
await first;
|
||||
|
||||
expect(cubit.state.query, 'an');
|
||||
expect(cubit.state.found, [anna]);
|
||||
});
|
||||
|
||||
test('naming an operator by handle names and approves them', () async {
|
||||
await cubit.load();
|
||||
final naming = cubit.appoint('@ben');
|
||||
expect(cubit.state.busy, '@ben');
|
||||
await naming;
|
||||
|
||||
expect(cubit.state.operators.map((each) => each.id), ['anna', 'ben']);
|
||||
expect(cubit.state.waiting, isEmpty);
|
||||
});
|
||||
|
||||
test('a handle nobody has is refused', () async {
|
||||
await cubit.load();
|
||||
await cubit.appoint('cleo');
|
||||
|
||||
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.notFound));
|
||||
});
|
||||
|
||||
test('refusing a new account deletes it; a suspended one is never '
|
||||
'refused, only approved', () async {
|
||||
await cubit.load();
|
||||
await cubit.refuse('ben');
|
||||
expect(gateway.accounts, isNot(contains('ben')));
|
||||
|
||||
await cubit.suspend('bea');
|
||||
await cubit.refuse('bea');
|
||||
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.notFound));
|
||||
expect(gateway.accounts, contains('bea'));
|
||||
});
|
||||
|
||||
test('the instance out of reach fails as such', () async {
|
||||
await cubit.load();
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.failed);
|
||||
unawaited(cubit.approve('ben'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.failed));
|
||||
expect(
|
||||
cubit.state.unreadable,
|
||||
const OperatorFailed(OperatorProblem.failed),
|
||||
);
|
||||
|
||||
// Read again once it answers.
|
||||
gateway.failing = null;
|
||||
await cubit.load();
|
||||
expect(cubit.state.unreadable, isNull);
|
||||
expect(cubit.state.waiting, [ben]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
/// The operator's screens (Halkyon Learn's ADR 0021, D21 to D24, D61, D62):
|
||||
/// the overview's counts, each list a page after another, found by the
|
||||
/// start of a handle where it can be, each action one at a time with the
|
||||
/// account it changed in its place; and the handles suggested as they are
|
||||
/// typed, only of accounts there are.
|
||||
void main() {
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
late InstanceOperatorRepository operators;
|
||||
|
||||
OperatedAccount account(
|
||||
String handle, {
|
||||
bool operates = false,
|
||||
DateTime? waitingSince,
|
||||
}) => OperatedAccount(
|
||||
id: 'id-$handle',
|
||||
handle: handle,
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: operates,
|
||||
waitingSince: waitingSince,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeInstanceOperatorGateway()..operates = true;
|
||||
operators = InstanceOperatorRepository(gateway);
|
||||
addTearDown(operators.dispose);
|
||||
});
|
||||
|
||||
group('OperatorListCubit', () {
|
||||
late OperatorListCubit<OperatedAccount> cubit;
|
||||
|
||||
/// [count] accounts waiting, each a minute after the one before.
|
||||
void waiting(int count) {
|
||||
for (var each = 0; each < count; each++) {
|
||||
gateway.add(
|
||||
account(
|
||||
'p${'$each'.padLeft(3, '0')}',
|
||||
waitingSince: DateTime.utc(2026, 10, 1, 0, each),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
cubit = OperatorListCubit(
|
||||
(query, offset) =>
|
||||
operators.accountsStartingWith(query, offset: offset),
|
||||
(account) => account.id,
|
||||
);
|
||||
addTearDown(cubit.close);
|
||||
});
|
||||
|
||||
test('reads the first page, and says whether more may follow', () async {
|
||||
waiting(operatorPageSize + 10);
|
||||
expect(cubit.state.loading, isTrue);
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state.loading, isFalse);
|
||||
expect(cubit.state.items, hasLength(operatorPageSize));
|
||||
expect(cubit.state.more, isTrue);
|
||||
expect(gateway.calls, ['accounts 0']);
|
||||
});
|
||||
|
||||
test('reads the next page from where the last ended, then no '
|
||||
'more', () async {
|
||||
waiting(operatorPageSize + 10);
|
||||
await cubit.load();
|
||||
|
||||
await cubit.more();
|
||||
|
||||
expect(cubit.state.items, hasLength(operatorPageSize + 10));
|
||||
expect(cubit.state.items.last.handle, 'p059');
|
||||
expect(cubit.state.more, isFalse);
|
||||
expect(gateway.calls.last, 'accounts $operatorPageSize');
|
||||
|
||||
await cubit.more();
|
||||
expect(gateway.calls, hasLength(2), reason: 'nothing more to read');
|
||||
});
|
||||
|
||||
test('reads one page at a time, however often asked', () async {
|
||||
waiting(operatorPageSize * 3);
|
||||
await cubit.load();
|
||||
|
||||
await Future.wait<void>([cubit.more(), cubit.more()]);
|
||||
|
||||
expect(cubit.state.items, hasLength(operatorPageSize * 2));
|
||||
expect(gateway.calls, hasLength(2));
|
||||
});
|
||||
|
||||
test('a page fewer than full is the last', () async {
|
||||
waiting(3);
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state.more, isFalse);
|
||||
await cubit.more();
|
||||
expect(gateway.calls, hasLength(1));
|
||||
});
|
||||
|
||||
test('finds by the start of a handle, from its first page, and drops '
|
||||
'an answer come too late', () async {
|
||||
gateway
|
||||
..add(account('anna'))
|
||||
..add(account('ben'));
|
||||
await cubit.load();
|
||||
|
||||
await Future.wait<void>([cubit.find('a'), cubit.find('b')]);
|
||||
|
||||
expect(cubit.state.query, 'b');
|
||||
expect([for (final each in cubit.state.items) each.handle], ['ben']);
|
||||
});
|
||||
|
||||
test("can't read a list where the person doesn't operate the "
|
||||
'instance', () async {
|
||||
gateway.operates = false;
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(
|
||||
cubit.state.unreadable,
|
||||
const OperatorFailed(OperatorProblem.notAllowed),
|
||||
);
|
||||
expect(cubit.state.items, isEmpty);
|
||||
});
|
||||
|
||||
test('an action puts what it made of the account in its place, or takes '
|
||||
'it off the list; the next page reads on from there', () async {
|
||||
waiting(operatorPageSize + 1);
|
||||
final list = OperatorListCubit<OperatedAccount>(
|
||||
(query, offset) => operators.waiting(offset: offset),
|
||||
(account) => account.id,
|
||||
);
|
||||
addTearDown(list.close);
|
||||
await list.load();
|
||||
final [first, second, ...] = list.state.items;
|
||||
|
||||
await list.act(
|
||||
first.id,
|
||||
() => operators.approve(first.id),
|
||||
after: (account, failed) => null,
|
||||
);
|
||||
final renamed = account('renamed');
|
||||
await list.act(
|
||||
second.id,
|
||||
Future<void>.value,
|
||||
after: (account, failed) => renamed,
|
||||
);
|
||||
|
||||
expect(list.state.items, hasLength(operatorPageSize - 1));
|
||||
expect(list.state.items, isNot(contains(first)));
|
||||
expect(list.state.items.first, renamed);
|
||||
expect(list.state.busy, isNull);
|
||||
await list.more();
|
||||
expect(
|
||||
gateway.calls.last,
|
||||
'waiting ${operatorPageSize - 1}',
|
||||
reason: 'the instance lists one fewer too',
|
||||
);
|
||||
expect(list.state.items, hasLength(operatorPageSize));
|
||||
expect(list.state.more, isFalse);
|
||||
});
|
||||
|
||||
test('a failed action is the problem, and the account is as after '
|
||||
'it', () async {
|
||||
final anna = account('anna');
|
||||
gateway.add(anna);
|
||||
await cubit.load();
|
||||
|
||||
OperatorFailed? seen;
|
||||
await cubit.act(
|
||||
anna.id,
|
||||
() => operators.suspend('id-nobody'),
|
||||
after: (account, failed) {
|
||||
seen = failed;
|
||||
return account;
|
||||
},
|
||||
);
|
||||
|
||||
expect(seen, const OperatorFailed(OperatorProblem.notFound));
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const OperatorFailed(OperatorProblem.notFound),
|
||||
);
|
||||
expect(cubit.state.items, [anna]);
|
||||
});
|
||||
|
||||
test('one action at a time', () async {
|
||||
final anna = account('anna');
|
||||
gateway.add(anna);
|
||||
await cubit.load();
|
||||
final release = Completer<void>();
|
||||
|
||||
final first = cubit.act(
|
||||
anna.id,
|
||||
() => release.future,
|
||||
after: (account, failed) => account,
|
||||
);
|
||||
expect(cubit.state.busy, anna.id);
|
||||
var second = false;
|
||||
await cubit.act(
|
||||
anna.id,
|
||||
() async => second = true,
|
||||
after: (a, f) => a,
|
||||
);
|
||||
release.complete();
|
||||
await first;
|
||||
|
||||
expect(second, isFalse);
|
||||
expect(cubit.state.busy, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('OperatorOverviewCubit', () {
|
||||
late FakeAssistantOperatorGateway assistants;
|
||||
late AssistantOperatorRepository assistantOperators;
|
||||
late OperatorOverviewCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
gateway
|
||||
..add(account('anna', operates: true))
|
||||
..add(account('ben', waitingSince: DateTime.utc(2026, 10, 24)))
|
||||
..add(account('bea'));
|
||||
assistants = FakeAssistantOperatorGateway()
|
||||
..set = const AssistantsSetUp(
|
||||
assistants: [
|
||||
SetUpAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
),
|
||||
],
|
||||
)
|
||||
..accounts.addAll({'id-bea': 'bea'});
|
||||
assistantOperators = AssistantOperatorRepository(assistants, gateway);
|
||||
cubit = OperatorOverviewCubit(operators, assistantOperators);
|
||||
addTearDown(() async {
|
||||
await cubit.close();
|
||||
await assistantOperators.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('counts who waits, operates and has an account, and the '
|
||||
'assistants set', () async {
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state.loading, isFalse);
|
||||
expect(
|
||||
cubit.state.counts,
|
||||
const OperatorCounts(waiting: 1, operators: 1, accounts: 3),
|
||||
);
|
||||
expect(cubit.state.setUp.assistants.single.id, 'claude');
|
||||
expect(cubit.state.setUp.people, 0);
|
||||
});
|
||||
|
||||
test('counts anew after each change made on its screens', () async {
|
||||
await cubit.load();
|
||||
|
||||
await operators.approve('id-ben');
|
||||
await assistantOperators.grant(
|
||||
accounts: ['id-bea'],
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.counts.waiting, 0);
|
||||
expect(cubit.state.setUp.people, 1);
|
||||
});
|
||||
|
||||
test("reads nothing where the person doesn't operate the "
|
||||
'instance', () async {
|
||||
gateway.operates = false;
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(
|
||||
cubit.state.unreadable,
|
||||
const OperatorFailed(OperatorProblem.notAllowed),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AccountSuggestionsCubit', () {
|
||||
late AccountSuggestionsCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
gateway
|
||||
..add(account('anna', operates: true))
|
||||
..add(account('annabel'))
|
||||
..add(account('ben'));
|
||||
cubit = AccountSuggestionsCubit(operators.appointable);
|
||||
addTearDown(cubit.close);
|
||||
});
|
||||
|
||||
test('suggests as typed, and matches only a handle there is', () async {
|
||||
await cubit.type('ann');
|
||||
|
||||
expect(
|
||||
[for (final each in cubit.state.found) each.handle],
|
||||
[
|
||||
'annabel',
|
||||
],
|
||||
reason: 'anna operates already',
|
||||
);
|
||||
expect(cubit.state.match, isNull, reason: 'only the start of one');
|
||||
|
||||
await cubit.type('@Annabel ');
|
||||
expect(cubit.state.match?.id, 'id-annabel');
|
||||
|
||||
await cubit.type('annabelle');
|
||||
expect(cubit.state.found, isEmpty);
|
||||
expect(cubit.state.match, isNull);
|
||||
});
|
||||
|
||||
test('drops an answer come too late, and suggests nothing for nothing '
|
||||
'typed', () async {
|
||||
await Future.wait<void>([cubit.type('ann'), cubit.type('')]);
|
||||
|
||||
expect(cubit.state.typed, '');
|
||||
expect(cubit.state.found, isEmpty);
|
||||
});
|
||||
|
||||
test('a failure suggests nothing', () async {
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.failed);
|
||||
|
||||
await cubit.type('ann');
|
||||
|
||||
expect(cubit.state.found, isEmpty);
|
||||
expect(cubit.state.match, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
/// What the app's screens ask of the instance's operators' calls (Halkyon
|
||||
/// Learn's ADR 0021, D21 to D24), and the changes they say were made.
|
||||
/// Learn's ADR 0021, D21 to D24, D61, D62), and the changes they say were
|
||||
/// made.
|
||||
void main() {
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
late InstanceOperatorRepository operators;
|
||||
|
|
@ -32,16 +33,48 @@ void main() {
|
|||
addTearDown(operators.dispose);
|
||||
});
|
||||
|
||||
test('a handle is found and named with or without its @, and nothing '
|
||||
'typed asks nothing', () async {
|
||||
test('a handle is found and named with or without its @; nothing typed '
|
||||
'lists every account, a page from where the last ended', () async {
|
||||
expect(
|
||||
(await operators.accountsStartingWith(' @be ')).map((each) => each.id),
|
||||
['ben'],
|
||||
);
|
||||
expect(await operators.accountsStartingWith(' @ '), isEmpty);
|
||||
expect(
|
||||
(await operators.accountsStartingWith(' @ ')).map((each) => each.id),
|
||||
['anna', 'ben'],
|
||||
);
|
||||
await operators.accountsStartingWith('', offset: 50);
|
||||
await operators.waiting(offset: 50);
|
||||
await operators.appoint('@ben');
|
||||
|
||||
expect(gateway.calls, ['accounts be', 'appoint ben']);
|
||||
expect(gateway.calls, [
|
||||
'accounts be 0',
|
||||
'accounts 0',
|
||||
'accounts 50',
|
||||
'waiting 50',
|
||||
'appoint ben',
|
||||
]);
|
||||
});
|
||||
|
||||
test('counts who waits, operates and has an account', () async {
|
||||
expect(
|
||||
await operators.counts(),
|
||||
const OperatorCounts(waiting: 1, operators: 1, accounts: 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('suggests the accounts to name an operator as a handle is typed, '
|
||||
'not those operating already; nothing typed asks nothing', () async {
|
||||
expect(
|
||||
(await operators.appointable('@B')).map((each) => each.id),
|
||||
['ben'],
|
||||
reason: 'one that waits is approved with it',
|
||||
);
|
||||
expect(await operators.appointable('an'), isEmpty);
|
||||
gateway.calls.clear();
|
||||
|
||||
expect(await operators.appointable(' @ '), isEmpty);
|
||||
expect(gateway.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('every change says so, for the count to be asked anew; reading '
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import 'dart:convert';
|
|||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
hide OperatorCounts;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
as protocol
|
||||
show OperatorCounts;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
|
|
@ -66,6 +70,12 @@ final class _Instance() {
|
|||
jsonEncode(waiting),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.instanceOperator/counts' => http.Response(
|
||||
jsonEncode(
|
||||
protocol.OperatorCounts(waiting: 1, operators: 1, accounts: 2),
|
||||
),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.instanceOperator/waiting' ||
|
||||
'halkyon_base.instanceOperator/accounts' ||
|
||||
'halkyon_base.instanceOperator/operators' => http.Response(
|
||||
|
|
@ -111,7 +121,7 @@ void main() {
|
|||
|
||||
test('reads accounts as the instance knows them, words while they wait, '
|
||||
'and never more', () async {
|
||||
final waiting = await gateway.waiting();
|
||||
final waiting = await gateway.waiting(offset: 0);
|
||||
|
||||
expect(waiting, [
|
||||
OperatedAccount(
|
||||
|
|
@ -130,10 +140,24 @@ void main() {
|
|||
expect(waiting.last.waits, isTrue);
|
||||
expect(waiting.first.waits, isFalse);
|
||||
expect(await gateway.operators(), waiting);
|
||||
expect(await gateway.accountsStartingWith('an'), waiting);
|
||||
expect(await gateway.accountsStartingWith('an', offset: 50), waiting);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.instanceOperator/accounts']!),
|
||||
{'startingWith': 'an'},
|
||||
{'startingWith': 'an', 'offset': 50},
|
||||
);
|
||||
});
|
||||
|
||||
test('reads a page of those waiting from where the last ended, and how '
|
||||
'many there are of each', () async {
|
||||
await gateway.waiting(offset: 100);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.instanceOperator/waiting']!),
|
||||
{'offset': 100},
|
||||
);
|
||||
|
||||
expect(
|
||||
await gateway.counts(),
|
||||
const OperatorCounts(waiting: 1, operators: 1, accounts: 2),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -186,10 +210,16 @@ void main() {
|
|||
test("someone who doesn't operate the instance, or no longer, is not "
|
||||
'allowed: without the scope, or without a session', () async {
|
||||
instance.refusals['halkyon_base.instanceOperator/waiting'] = 403;
|
||||
await expectLater(gateway.waiting(), failsWith(OperatorProblem.notAllowed));
|
||||
await expectLater(
|
||||
gateway.waiting(offset: 0),
|
||||
failsWith(OperatorProblem.notAllowed),
|
||||
);
|
||||
|
||||
instance.refusals['halkyon_base.instanceOperator/waiting'] = 401;
|
||||
await expectLater(gateway.waiting(), failsWith(OperatorProblem.notAllowed));
|
||||
await expectLater(
|
||||
gateway.waiting(offset: 0),
|
||||
failsWith(OperatorProblem.notAllowed),
|
||||
);
|
||||
});
|
||||
|
||||
test('an instance out of reach or failing fails, and says no more', () async {
|
||||
|
|
|
|||
|
|
@ -81,8 +81,10 @@ void main() {
|
|||
|
||||
await tester.tap(find.text('Accounts and operators'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('2 accounts wait for approval'), findsOneWidget);
|
||||
await tester.tap(find.text('Waiting'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Waiting'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(find.text('@cleo'), findsOneWidget);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The operator's screen (Halkyon Learn's ADR 0021, D21 to D24, D33):
|
||||
/// approving once the words read out were confirmed, refusing, suspending,
|
||||
/// naming operators and taking the role back.
|
||||
/// The operator's screens (Halkyon Learn's ADR 0021, D21 to D24, D33, D61,
|
||||
/// D62): an overview with a row for each list, however long; approving
|
||||
/// once the words read out were confirmed, refusing, suspending, naming
|
||||
/// operators by a handle suggested and taking the role back; and every
|
||||
/// list read a page at a time.
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
|
|
@ -54,167 +56,317 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('lists who waits, since when, and who operates', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('This instance'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(find.text('New, waiting since Oct 24, 2026'), findsOneWidget);
|
||||
expect(find.text('@anna'), findsOneWidget);
|
||||
expect(find.text('Take back'), findsOneWidget);
|
||||
expect(find.text('abandon'), findsNothing, reason: 'shown on asking');
|
||||
});
|
||||
|
||||
testWidgets('a waiting account shows its words to read out, and is '
|
||||
'approved only once asked to (D33)', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
for (final word in ben.words!) {
|
||||
expect(find.text(word), findsOneWidget);
|
||||
}
|
||||
expect(
|
||||
find.text(
|
||||
'Read them these four words, and approve only once they confirm '
|
||||
'these are on their screen.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.text('Cancel'));
|
||||
expect(gateway.calls, isNot(contains('approve ben')));
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
await tap(tester, find.text('Approve'));
|
||||
|
||||
expect(gateway.calls, contains('approve ben'));
|
||||
expect(find.text('Nobody waits.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('refusing a new account asks first, then deletes it', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
await tap(tester, find.widgetWithText(TextButton, 'Refuse'));
|
||||
expect(find.text('Refuse this account?'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'It is deleted with everything it has. They can sign up again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Refuse'));
|
||||
|
||||
expect(gateway.accounts, isNot(contains('ben')));
|
||||
expect(find.text('Nobody waits.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a suspended account is found, suspended after asking, and '
|
||||
'waits; it can be approved, never refused', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'bea');
|
||||
Future<void> back(WidgetTester tester) async {
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Since Oct 2, 2026'), findsOneWidget);
|
||||
}
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
await tap(tester, find.text('Suspend').last);
|
||||
expect(find.text('Suspend this account?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Suspend'));
|
||||
group('the overview', () {
|
||||
testWidgets('counts who waits, operates and has an account, a row each '
|
||||
'leading to its list', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
expect(gateway.calls, contains('suspend bea'));
|
||||
expect(find.text('Suspended since Oct 25, 2026'), findsOneWidget);
|
||||
expect(find.text('Since Oct 2, 2026 · Suspended'), findsOneWidget);
|
||||
expect(find.text('This instance'), findsOneWidget);
|
||||
expect(find.text('1 account waits for approval'), findsOneWidget);
|
||||
expect(find.text('1 operator'), findsOneWidget);
|
||||
expect(find.text('3 accounts'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsNothing, reason: 'on its own screen');
|
||||
expect(find.text('Assistants'), findsNothing, reason: 'none set up');
|
||||
});
|
||||
|
||||
await tap(tester, find.text('Suspended since Oct 25, 2026'));
|
||||
expect(find.text('zoo'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Refuse'), findsNothing);
|
||||
testWidgets('a row for the assistants where one is set up', (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.assistantOperatorGateway.set = const AssistantsSetUp(
|
||||
assistants: [
|
||||
SetUpAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
),
|
||||
],
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Assistants'), findsOneWidget);
|
||||
expect(find.text('1 assistant · nobody granted'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("someone who doesn't operate the instance is shown nothing "
|
||||
'of its accounts', (tester) async {
|
||||
gateway.operates = false;
|
||||
await show(tester);
|
||||
|
||||
expect(
|
||||
find.text("Only the instance's operators can do this."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Waiting'), findsNothing);
|
||||
expect(find.text('Try again'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an instance out of reach is said so, and read again', (
|
||||
tester,
|
||||
) async {
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.failed);
|
||||
await show(tester);
|
||||
expect(find.text("Couldn't reach the server."), findsOneWidget);
|
||||
|
||||
gateway.failing = null;
|
||||
await tap(tester, find.text('Try again'));
|
||||
expect(find.text('1 account waits for approval'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('an operator is neither suspended nor offered it', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
group('waiting', () {
|
||||
testWidgets('lists who waits and since when; the words only on '
|
||||
'asking', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Waiting'));
|
||||
|
||||
await tester.enterText(find.byType(TextField), '@an');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(find.text('New, waiting since Oct 24, 2026'), findsOneWidget);
|
||||
expect(find.text('abandon'), findsNothing);
|
||||
});
|
||||
|
||||
expect(find.text('Since Oct 1, 2026 · Operator'), findsOneWidget);
|
||||
expect(find.byTooltip('Show menu'), findsNothing);
|
||||
testWidgets('a waiting account shows its words to read out, and is '
|
||||
'approved only once asked to (D33)', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Waiting'));
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
for (final word in ben.words!) {
|
||||
expect(find.text(word), findsOneWidget);
|
||||
}
|
||||
expect(
|
||||
find.text(
|
||||
'Read them these four words, and approve only once they confirm '
|
||||
'these are on their screen.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.text('Cancel'));
|
||||
expect(gateway.calls, isNot(contains('approve ben')));
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
await tap(tester, find.text('Approve'));
|
||||
|
||||
expect(gateway.calls, contains('approve ben'));
|
||||
expect(find.text('Nobody waits.'), findsOneWidget);
|
||||
await back(tester);
|
||||
expect(find.text('Nobody waits for approval'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('refusing a new account asks first, then deletes it', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Waiting'));
|
||||
|
||||
await tap(tester, find.text('@ben'));
|
||||
await tap(tester, find.widgetWithText(TextButton, 'Refuse'));
|
||||
expect(find.text('Refuse this account?'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'It is deleted with everything it has. They can sign up again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Refuse'));
|
||||
|
||||
expect(gateway.accounts, isNot(contains('ben')));
|
||||
expect(find.text('Nobody waits.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a long list is read a page at a time, the next as its end '
|
||||
'comes near', (tester) async {
|
||||
for (var each = 0; each < operatorPageSize + 20; each++) {
|
||||
gateway.add(
|
||||
OperatedAccount(
|
||||
id: 'p$each',
|
||||
handle: 'p${'$each'.padLeft(3, '0')}',
|
||||
since: DateTime.utc(2026, 10),
|
||||
waitingSince: DateTime.utc(2026, 10, 25, 0, each),
|
||||
),
|
||||
);
|
||||
}
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Waiting'));
|
||||
|
||||
expect(gateway.calls.where((call) => call.startsWith('waiting ')), [
|
||||
'waiting 0',
|
||||
]);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.calls.where((call) => call.startsWith('waiting ')), [
|
||||
'waiting 0',
|
||||
'waiting $operatorPageSize',
|
||||
]);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@p069'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a handle nobody has is said so', (tester) async {
|
||||
await show(tester);
|
||||
group('accounts', () {
|
||||
testWidgets('lists every account by handle, and finds them by its '
|
||||
'start', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Accounts'));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
for (final handle in ['@anna', '@bea', '@ben']) {
|
||||
expect(find.text(handle), findsOneWidget);
|
||||
}
|
||||
|
||||
expect(find.text("No account's handle starts like this."), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), '@be');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@anna'), findsNothing);
|
||||
expect(find.text('@bea'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text("No account's handle starts like this."),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an account is suspended after asking, in its place, and '
|
||||
'then waits', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Accounts'));
|
||||
await tester.enterText(find.byType(TextField), 'bea');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Since Oct 2, 2026'), findsOneWidget);
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
await tap(tester, find.text('Suspend').last);
|
||||
expect(find.text('Suspend this account?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Suspend'));
|
||||
|
||||
expect(gateway.calls, contains('suspend bea'));
|
||||
expect(find.text('Since Oct 2, 2026 · Suspended'), findsOneWidget);
|
||||
|
||||
await back(tester);
|
||||
expect(find.text('2 accounts wait for approval'), findsOneWidget);
|
||||
await tap(tester, find.text('Waiting'));
|
||||
await tap(tester, find.text('Suspended since Oct 25, 2026'));
|
||||
expect(find.text('zoo'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Refuse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an account is named an operator from its menu', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Accounts'));
|
||||
await tester.enterText(find.byType(TextField), 'bea');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tap(tester, find.byTooltip('Show menu'));
|
||||
await tap(tester, find.text('Name as operator'));
|
||||
|
||||
expect(gateway.accounts['bea']!.isOperator, isTrue);
|
||||
expect(find.text('Since Oct 2, 2026 · Operator'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an operator is neither suspended nor offered it', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Accounts'));
|
||||
await tester.enterText(find.byType(TextField), '@an');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Since Oct 1, 2026 · Operator'), findsOneWidget);
|
||||
expect(find.byTooltip('Show menu'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('naming an operator by handle, and taking the role back after '
|
||||
'asking', (tester) async {
|
||||
await show(tester);
|
||||
group('operators', () {
|
||||
testWidgets('names an operator only by a handle suggested as it is typed '
|
||||
'(D61), then takes the role back after asking', (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Operators'));
|
||||
|
||||
await tap(tester, find.text('Name an operator'));
|
||||
expect(
|
||||
find.text(
|
||||
'They approve, refuse and suspend accounts here, and name '
|
||||
'operators, as you do.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).last, 'bea');
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Name'));
|
||||
await tap(tester, find.text('Name an operator'));
|
||||
expect(
|
||||
find.text(
|
||||
'They approve, refuse and suspend accounts here, and name '
|
||||
'operators, as you do.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final name = find.widgetWithText(FilledButton, 'Name');
|
||||
await tester.enterText(find.byType(TextField).last, 'be');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('@bea'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget, reason: 'approved with it');
|
||||
expect(tester.widget<FilledButton>(name).onPressed, isNull);
|
||||
|
||||
expect(gateway.accounts['bea']!.isOperator, isTrue);
|
||||
expect(find.text('Take back'), findsNWidgets(2));
|
||||
await tap(tester, find.text('@bea'));
|
||||
expect(tester.widget<FilledButton>(name).onPressed, isNotNull);
|
||||
await tap(tester, name);
|
||||
|
||||
await tap(tester, find.text('Take back').last);
|
||||
expect(find.text('Take back the role?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
expect(gateway.calls, contains('appoint bea'));
|
||||
expect(gateway.accounts['bea']!.isOperator, isTrue);
|
||||
expect(find.text('Take back'), findsNWidgets(2));
|
||||
|
||||
expect(gateway.accounts['bea']!.isOperator, isFalse);
|
||||
});
|
||||
await tap(tester, find.text('Take back').last);
|
||||
expect(find.text('Take back the role?'), findsOneWidget);
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
|
||||
testWidgets('the last operator stays, and the screen says why', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
expect(gateway.accounts['bea']!.isOperator, isFalse);
|
||||
expect(find.text('Take back'), findsOneWidget);
|
||||
});
|
||||
|
||||
await tap(tester, find.text('Take back'));
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
testWidgets('a handle typed whole is the one picked; one nobody has '
|
||||
"can't be named", (tester) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Operators'));
|
||||
await tap(tester, find.text('Name an operator'));
|
||||
final name = find.widgetWithText(FilledButton, 'Name');
|
||||
|
||||
expect(
|
||||
find.text('The instance needs at least one operator.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(gateway.accounts['anna']!.isOperator, isTrue);
|
||||
});
|
||||
await tester.enterText(find.byType(TextField).last, 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text("No account's handle starts like this."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<FilledButton>(name).onPressed, isNull);
|
||||
|
||||
testWidgets("someone who doesn't operate the instance is shown nothing of "
|
||||
'its accounts', (tester) async {
|
||||
gateway.operates = false;
|
||||
await show(tester);
|
||||
await tester.enterText(find.byType(TextField).last, '@Bea');
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<FilledButton>(name).onPressed, isNotNull);
|
||||
|
||||
expect(
|
||||
find.text("Only the instance's operators can do this."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('@ben'), findsNothing);
|
||||
expect(find.text('Try again'), findsOneWidget);
|
||||
});
|
||||
await tester.enterText(find.byType(TextField).last, 'anna');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<FilledButton>(name).onPressed,
|
||||
isNull,
|
||||
reason: 'operates already',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an instance out of reach is said so, and read again', (
|
||||
tester,
|
||||
) async {
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.failed);
|
||||
await show(tester);
|
||||
expect(find.text("Couldn't reach the server."), findsOneWidget);
|
||||
testWidgets('the last operator stays, and the screen says why', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
await tap(tester, find.text('Operators'));
|
||||
|
||||
gateway.failing = null;
|
||||
await tap(tester, find.text('Try again'));
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
await tap(tester, find.text('Take back'));
|
||||
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
|
||||
|
||||
expect(
|
||||
find.text('The instance needs at least one operator.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(gateway.accounts['anna']!.isOperator, isTrue);
|
||||
expect(find.text('@anna'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue