feat(app,learn,todo): the operator's "This instance" card, screen and badge (ADR 0021, step 1)
Whoever operates the instance approves, refuses and suspends accounts
and names other operators in the app (D21 to D24, D34):
- halkyon_app: InstanceOperatorRepository on
ServerpodInstanceOperatorGateway (the module's instanceOperator
endpoint; refusals as OperatorFailed: notFound, isOperator,
lastOperator, notAllowed for 401 and 403, failed), in AppDependencies.
OperatingCubit counts the accounts that wait: asked as the person's
app opens, after every change, and every 5 minutes only while they
operate the instance, so nobody else's app keeps asking.
InstanceOperatorCard (or its tiles) and WaitingAccountsBadge show
nothing at all for anyone else. InstanceOperatorScreen, laid out as
Settings: the accounts that wait, each showing its four words to read
out before Approve (D33), Refuse for new ones after asking; who
operates, Take back after asking, the last always kept; Name an
operator by handle; Find an account by the start of its handle, with
Suspend after asking and Name as operator. English and German texts
("Admin" as the role word in German).
- Learn: the card right after the Sync card in Settings, which the
cogs' count leads to as their dot leads to Sync; the count on the
Settings cogs of Home and Profile.
- Todo: the tiles in the account sheet and the count on its button.
Todo's server is a member, whose `operating()` is always null, so
nothing shows there until it holds the register.
Tests: the gateway's mapping, the repository, both cubits with their
denials, the card, the badge and the screen; Learn's Settings shows the
card and the count to an operator and neither to anyone else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7ac715cd97
commit
31b9c17850
6 changed files with 2999 additions and 28 deletions
|
|
@ -0,0 +1,182 @@
|
|||
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,92 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
/// How many accounts wait, for the badge on an operator's Settings and its
|
||||
/// "This instance" card (Halkyon Learn's ADR 0021, D34).
|
||||
void main() {
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
late InstanceOperatorRepository operators;
|
||||
|
||||
OperatedAccount waiting(String id) => OperatedAccount(
|
||||
id: id,
|
||||
handle: id,
|
||||
since: DateTime.utc(2026, 10, 24),
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(waiting('ben'));
|
||||
operators = InstanceOperatorRepository(gateway);
|
||||
});
|
||||
|
||||
int operatingCalls() =>
|
||||
gateway.calls.where((call) => call == 'operating').length;
|
||||
|
||||
test('asks as it opens, every 5 minutes, and after every change made '
|
||||
'here', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = OperatingCubit(operators);
|
||||
async.flushMicrotasks();
|
||||
expect(cubit.state, 1);
|
||||
|
||||
gateway.add(waiting('cleo'));
|
||||
async.elapse(const Duration(minutes: 5));
|
||||
expect(cubit.state, 2);
|
||||
|
||||
unawaited(operators.approve('ben'));
|
||||
async.flushMicrotasks();
|
||||
expect(cubit.state, 1);
|
||||
expect(operatingCalls(), 3);
|
||||
|
||||
unawaited(cubit.close());
|
||||
async.elapse(const Duration(minutes: 10));
|
||||
expect(operatingCalls(), 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("is null for someone who doesn't operate the instance, whose app "
|
||||
'asks once and no more', () {
|
||||
fakeAsync((async) {
|
||||
gateway.operates = false;
|
||||
final cubit = OperatingCubit(operators);
|
||||
async.elapse(const Duration(minutes: 30));
|
||||
|
||||
expect(cubit.state, isNull);
|
||||
expect(operatingCalls(), 1);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test('an operator whose role was taken back meanwhile sees the card '
|
||||
'go', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = OperatingCubit(operators);
|
||||
async.flushMicrotasks();
|
||||
expect(cubit.state, 1);
|
||||
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
|
||||
async.elapse(const Duration(minutes: 5));
|
||||
expect(cubit.state, isNull);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
|
||||
test('out of reach, what was known stays', () {
|
||||
fakeAsync((async) {
|
||||
final cubit = OperatingCubit(operators);
|
||||
async.flushMicrotasks();
|
||||
|
||||
gateway.failing = const OperatorFailed(OperatorProblem.failed);
|
||||
async.elapse(const Duration(minutes: 5));
|
||||
expect(cubit.state, 1);
|
||||
unawaited(cubit.close());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
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.
|
||||
void main() {
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
late InstanceOperatorRepository operators;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeInstanceOperatorGateway()
|
||||
..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, 24),
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
),
|
||||
);
|
||||
operators = InstanceOperatorRepository(gateway);
|
||||
addTearDown(operators.dispose);
|
||||
});
|
||||
|
||||
test('a handle is found and named with or without its @, and nothing '
|
||||
'typed asks nothing', () async {
|
||||
expect(
|
||||
(await operators.accountsStartingWith(' @be ')).map((each) => each.id),
|
||||
['ben'],
|
||||
);
|
||||
expect(await operators.accountsStartingWith(' @ '), isEmpty);
|
||||
await operators.appoint('@ben');
|
||||
|
||||
expect(gateway.calls, ['accounts be', 'appoint ben']);
|
||||
});
|
||||
|
||||
test('every change says so, for the count to be asked anew; reading '
|
||||
"doesn't", () async {
|
||||
final changes = <void>[];
|
||||
final listening = operators.changes.listen(changes.add);
|
||||
addTearDown(listening.cancel);
|
||||
|
||||
await operators.waiting();
|
||||
await operators.operators();
|
||||
await operators.approve('ben');
|
||||
await operators.suspend('ben');
|
||||
await operators.approve('ben');
|
||||
await operators.appoint('ben');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(changes, hasLength(4));
|
||||
});
|
||||
|
||||
test('a refused change says no change, and is refused', () async {
|
||||
final changes = <void>[];
|
||||
final listening = operators.changes.listen(changes.add);
|
||||
addTearDown(listening.cancel);
|
||||
|
||||
await expectLater(
|
||||
operators.takeBack('anna'),
|
||||
throwsA(const OperatorFailed(OperatorProblem.lastOperator)),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(changes, isEmpty);
|
||||
});
|
||||
|
||||
test("someone who doesn't operate the instance is refused everything "
|
||||
'but the question whether they do', () async {
|
||||
gateway.operates = false;
|
||||
|
||||
expect(await operators.operating(), isNull);
|
||||
await expectLater(
|
||||
operators.waiting(),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
|
||||
);
|
||||
await expectLater(
|
||||
operators.approve('ben'),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
|
||||
);
|
||||
expect(gateway.accounts['ben']!.waits, isTrue);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
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:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// An instance's halkyon_base as the network shows it to an operator: two
|
||||
/// accounts, one waiting; or each call refuses as [refusals] says, by its
|
||||
/// endpoint and method.
|
||||
final class _Instance() {
|
||||
static final anna = UuidValue.withValidation(
|
||||
'0191f0c4-1111-7000-8000-000000000001',
|
||||
);
|
||||
static final ben = UuidValue.withValidation(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
);
|
||||
|
||||
/// What each call refuses with: an HTTP status, or the module's
|
||||
/// exception.
|
||||
final refusals = <String, Object>{};
|
||||
|
||||
/// What each call sent, by its endpoint and method.
|
||||
final bodies = <String, String>{};
|
||||
|
||||
/// How many accounts wait; null as for someone who doesn't operate.
|
||||
int? waiting = 1;
|
||||
|
||||
static final overviews = [
|
||||
AccountOverview(
|
||||
account: anna,
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
suspended: false,
|
||||
),
|
||||
AccountOverview(
|
||||
account: ben,
|
||||
since: DateTime.utc(2026, 10, 24),
|
||||
isOperator: false,
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: ['abandon', 'ability', 'able', 'about'],
|
||||
suspended: false,
|
||||
),
|
||||
];
|
||||
|
||||
http.Client client() => MockClient((request) async {
|
||||
final call = request.url.path.substring(1);
|
||||
bodies[call] = request.body;
|
||||
if (refusals[call] case final refusal?) {
|
||||
return switch (refusal) {
|
||||
final int status => http.Response('', status),
|
||||
final OperatorRefusedException refused => http.Response(
|
||||
jsonEncode({
|
||||
'className': 'halkyon_base.OperatorRefusedException',
|
||||
'data': refused.toJson(),
|
||||
}),
|
||||
400,
|
||||
),
|
||||
_ => throw ArgumentError.value(refusal),
|
||||
};
|
||||
}
|
||||
return switch (call) {
|
||||
'halkyon_base.account/operating' => http.Response(
|
||||
jsonEncode(waiting),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.instanceOperator/waiting' ||
|
||||
'halkyon_base.instanceOperator/accounts' ||
|
||||
'halkyon_base.instanceOperator/operators' => http.Response(
|
||||
jsonEncode([for (final each in overviews) each.toJson()]),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.instanceOperator/approve' ||
|
||||
'halkyon_base.instanceOperator/refuse' ||
|
||||
'halkyon_base.instanceOperator/suspend' ||
|
||||
'halkyon_base.instanceOperator/appoint' ||
|
||||
'halkyon_base.instanceOperator/takeBack' => http.Response('null', 200),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Matcher failsWith(OperatorProblem problem) => throwsA(OperatorFailed(problem));
|
||||
|
||||
/// What an operator's app asks of the module (Halkyon Learn's ADR 0021,
|
||||
/// D21 to D24, D34), and its refusals in the app's terms.
|
||||
void main() {
|
||||
late _Instance instance;
|
||||
late BaseClient app;
|
||||
late ServerpodInstanceOperatorGateway gateway;
|
||||
|
||||
setUp(() {
|
||||
instance = _Instance();
|
||||
app = BaseClient(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: instance.client(),
|
||||
);
|
||||
gateway = ServerpodInstanceOperatorGateway(app.modules);
|
||||
addTearDown(app.close);
|
||||
});
|
||||
|
||||
test('counts the accounts that wait; null for someone who operates '
|
||||
'nothing', () async {
|
||||
expect(await gateway.operating(), 1);
|
||||
|
||||
instance.waiting = null;
|
||||
expect(await gateway.operating(), isNull);
|
||||
});
|
||||
|
||||
test('reads accounts as the instance knows them, words while they wait, '
|
||||
'and never more', () async {
|
||||
final waiting = await gateway.waiting();
|
||||
|
||||
expect(waiting, [
|
||||
OperatedAccount(
|
||||
id: '${_Instance.anna}',
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
),
|
||||
OperatedAccount(
|
||||
id: '${_Instance.ben}',
|
||||
since: DateTime.utc(2026, 10, 24),
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
),
|
||||
]);
|
||||
expect(waiting.last.waits, isTrue);
|
||||
expect(waiting.first.waits, isFalse);
|
||||
expect(await gateway.operators(), waiting);
|
||||
expect(await gateway.accountsStartingWith('an'), waiting);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.instanceOperator/accounts']!),
|
||||
{'startingWith': 'an'},
|
||||
);
|
||||
});
|
||||
|
||||
test('names accounts by their ID, and an operator to be by handle', () async {
|
||||
final ben = '${_Instance.ben}';
|
||||
await gateway.approve(ben);
|
||||
await gateway.refuse(ben);
|
||||
await gateway.suspend(ben);
|
||||
await gateway.takeBack(ben);
|
||||
await gateway.appoint('ben');
|
||||
|
||||
for (final call in ['approve', 'refuse', 'suspend', 'takeBack']) {
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.instanceOperator/$call']!),
|
||||
{'account': ben},
|
||||
reason: call,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.instanceOperator/appoint']!),
|
||||
{'handle': 'ben'},
|
||||
);
|
||||
});
|
||||
|
||||
test("an account ID that isn't one fails, and nothing is sent", () async {
|
||||
await expectLater(
|
||||
gateway.approve('ben'),
|
||||
failsWith(OperatorProblem.failed),
|
||||
);
|
||||
expect(instance.bodies, isEmpty);
|
||||
});
|
||||
|
||||
test("the instance's refusals, in the app's terms", () async {
|
||||
for (final (refusal, problem) in [
|
||||
(OperatorRefusal.notFound, OperatorProblem.notFound),
|
||||
(OperatorRefusal.isOperator, OperatorProblem.isOperator),
|
||||
(OperatorRefusal.lastOperator, OperatorProblem.lastOperator),
|
||||
]) {
|
||||
instance.refusals['halkyon_base.instanceOperator/suspend'] =
|
||||
OperatorRefusedException(reason: refusal);
|
||||
await expectLater(
|
||||
gateway.suspend('${_Instance.ben}'),
|
||||
failsWith(problem),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
instance.refusals['halkyon_base.instanceOperator/waiting'] = 401;
|
||||
await expectLater(gateway.waiting(), failsWith(OperatorProblem.notAllowed));
|
||||
});
|
||||
|
||||
test('an instance out of reach or failing fails, and says no more', () async {
|
||||
instance.refusals['halkyon_base.instanceOperator/approve'] = 500;
|
||||
await expectLater(
|
||||
gateway.approve('${_Instance.ben}'),
|
||||
failsWith(OperatorProblem.failed),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// "This instance" in an app's Settings and the count on its cogs, for
|
||||
/// whoever operates the instance alone (Halkyon Learn's ADR 0021, D34).
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
OperatedAccount waiting(String id) => OperatedAccount(
|
||||
id: id,
|
||||
handle: id,
|
||||
since: DateTime.utc(2026, 10, 24),
|
||||
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
dependencies = TestAppDependencies();
|
||||
dependencies.operatorGateway
|
||||
..operates = true
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'anna',
|
||||
handle: 'anna',
|
||||
since: DateTime.utc(2026, 10),
|
||||
isOperator: true,
|
||||
),
|
||||
)
|
||||
..add(waiting('ben'))
|
||||
..add(waiting('cleo'));
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: const [
|
||||
WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
|
||||
],
|
||||
),
|
||||
body: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [InstanceOperatorCard(below: 12), Text('Next card')],
|
||||
),
|
||||
),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('an operator sees how many accounts wait, on the card and the '
|
||||
'cog', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('This instance'), findsOneWidget);
|
||||
expect(find.text('Accounts and operators'), findsOneWidget);
|
||||
expect(find.text('2 accounts wait for approval'), findsOneWidget);
|
||||
expect(find.widgetWithText(CountBadge, '2'), findsOneWidget);
|
||||
expect(find.widgetWithText(Badge, '2'), findsOneWidget);
|
||||
expect(
|
||||
find.bySemanticsLabel(RegExp('2 accounts wait for approval')),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the card leads to the screen where they are approved', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.text('Accounts and operators'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Waiting'), findsOneWidget);
|
||||
expect(find.text('@ben'), findsOneWidget);
|
||||
expect(find.text('@cleo'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('nobody waiting, the card says so and the cog carries '
|
||||
'nothing', (tester) async {
|
||||
dependencies.operatorGateway.accounts
|
||||
..remove('ben')
|
||||
..remove('cleo');
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Nobody waits for approval'), findsOneWidget);
|
||||
expect(find.byType(CountBadge), findsNothing);
|
||||
expect(find.byType(Badge), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("someone who doesn't operate the instance sees neither card "
|
||||
'nor count, nor the room for them', (tester) async {
|
||||
dependencies.operatorGateway.operates = false;
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('This instance'), findsNothing);
|
||||
expect(find.byType(Badge), findsNothing);
|
||||
expect(tester.getTopLeft(find.text('Next card')).dy, kToolbarHeight);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
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.
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
late FakeInstanceOperatorGateway gateway;
|
||||
|
||||
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(() {
|
||||
dependencies = TestAppDependencies();
|
||||
gateway = dependencies.operatorGateway
|
||||
..operates = true
|
||||
..add(anna)
|
||||
..add(ben)
|
||||
..add(bea);
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(const InstanceOperatorScreen(), dependencies: dependencies),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> tap(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.tap(finder);
|
||||
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');
|
||||
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('Suspended since Oct 25, 2026'), findsOneWidget);
|
||||
expect(find.text('Since Oct 2, 2026 · Suspended'), findsOneWidget);
|
||||
|
||||
await tap(tester, find.text('Suspended since Oct 25, 2026'));
|
||||
expect(find.text('zoo'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Refuse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an operator is neither suspended nor offered it', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
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('a handle nobody has is said so', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'zed');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("No account's handle starts like this."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('naming an operator by handle, and taking the role back after '
|
||||
'asking', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
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'));
|
||||
|
||||
expect(gateway.accounts['bea']!.isOperator, isTrue);
|
||||
expect(find.text('Take back'), findsNWidgets(2));
|
||||
|
||||
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.accounts['bea']!.isOperator, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the last operator stays, and the screen says why', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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('@ben'), 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('@ben'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue