feat(learn): a Viewer opens a shared studyset's material read-only

Every route of a studyset says what the person may do there
(StudysetAccess, set in _inStudyset): whether it is shared with people,
and whether they only view and study it (Halkyon's ADR 0044, D35, D36).

A Viewer opens cards and notes on the same screens, read-only: the rich
fields show their content (LearnContentEditor shows LearnContentView),
the composer's plain fields are read-only and its changes go nowhere,
the note screen reads as a conflict leaves it, each with the line that
the studyset is theirs to view and study. The lists, the card browser
and a study session offer them nothing that writes: no new card, import,
edit, move, rename, reading deadline, reorder or delete. Their tags,
progress and reading stay theirs to change, being their own rows.

Burying and reporting a card are written on its row, which every member
shares, so they are hidden in shared studysets for everyone until they
become each person's own rows (D36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 17:21:28 +01:00
commit 8dd3ec0522
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
/// What a member of a shared studyset may do in it (Halkyon's ADR 0044,
/// D35, D36): a Viewer opens cards and notes read-only and is offered
/// nothing that writes; nobody buries or reports a card every member
/// shares; in the person's own studyset, all of it as before.
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late String cardId;
late String noteId;
setUp(() async {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
cardId = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('Capital of Japan?'),
back: RichContent.plain('Tokyo'),
),
);
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
noteId = (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.plain('は')),
)).noteId;
});
Future<void> showApp(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
noteGateway: noteGateway,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// The options of the card's row in the Flashcards tab, opened.
Future<void> openCardOptions(WidgetTester tester) async {
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Flashcards'));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(CardTile),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
}
void shared({required bool viewOnly}) =>
studysets.shared['japanese'] = (space: 'space-1', viewOnly: viewOnly);
testWidgets("a Viewer's card offers its info alone", (tester) async {
shared(viewOnly: true);
await openCardOptions(tester);
expect(find.text('More info'), findsOneWidget);
expect(find.text('Edit'), findsNothing);
expect(find.text('Delete'), findsNothing);
expect(find.text('Bury until tomorrow'), findsNothing);
expect(find.text('Report an error'), findsNothing);
});
testWidgets("an Editor's card is edited and deleted, never buried or "
'reported for everyone', (tester) async {
shared(viewOnly: false);
await openCardOptions(tester);
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
expect(find.text('Bury until tomorrow'), findsNothing);
expect(find.text('Report an error'), findsNothing);
});
testWidgets("the person's own card offers all of it", (tester) async {
await openCardOptions(tester);
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Bury until tomorrow'), findsOneWidget);
expect(find.text('Report an error'), findsOneWidget);
});
testWidgets('a Viewer opens a card read-only, adding none', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.card('japanese', cardId));
expect(
find.text('You can view and study this, not change it.'),
findsOneWidget,
);
expect(find.text('New flashcard'), findsNothing);
expect(find.byTooltip('More actions'), findsNothing);
expect(find.byType(RichContentEditor), findsNothing);
expect(
find.textContaining('Capital of Japan?', findRichText: true),
findsWidgets,
);
});
testWidgets('a Viewer opens a note read-only', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.note('japanese', noteId));
expect(
find.text('You can view and study this, not change it.'),
findsOneWidget,
);
expect(find.byType(RichContentEditor), findsNothing);
expect(
tester.widget<TextField>(find.byType(TextField).first).readOnly,
isTrue,
);
});
}