feat(learn): a studyset's subject, picked from a fixed list (ADR 0041)
A top studyset is filed under one of 44 subjects in six groups (the developer's decisions of 9 and 10 Nov 2026): languages as one, computing in five, Other / not sure last. Each has a Lucide icon and a default colour, which picking it gives the studyset, its own to change from then on. The studyset's sheet picks it (pickSubject); a sub-studyset shows its top studyset's (subjectOf) and offers none. The payload keeps it as `subject` (schema 8), so archives and published studysets carry it; an ID this version doesn't know is kept and reads as none. Whats-New: A studyset can now be filed under a subject, which gives it that subject's colour. Whats-New-De: Ein Lernset lässt sich jetzt einem Fach zuordnen, das ihm dessen Farbe gibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a37ca123b1
commit
9868dc27c6
5 changed files with 1766 additions and 10 deletions
|
|
@ -46,11 +46,27 @@ void main() {
|
|||
licenseYear: 2006,
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
subject: 'languages',
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('keeps a subject this app version does not know, as a newer one '
|
||||
'may add subjects', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Origami',
|
||||
description: RichContent.empty(),
|
||||
subject: 'paper-folding',
|
||||
);
|
||||
|
||||
expect(json(encodeStudysetPayload(content))['subject'], 'paper-folding');
|
||||
expect(
|
||||
decodeStudysetPayload(encodeStudysetPayload(content)).subject,
|
||||
'paper-folding',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves out what is not given', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
|
|
@ -59,7 +75,7 @@ void main() {
|
|||
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
|
||||
});
|
||||
|
||||
test('names its schema version: the seventh, which exam plans need', () {
|
||||
test('names its schema version: the eighth, which subjects need', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
|
|
@ -68,17 +84,17 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
expect(studysetSchemaVersion, 7);
|
||||
expect(studysetSchemaVersion, 8);
|
||||
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
|
||||
});
|
||||
|
||||
test('without study options, a bibliography or exam plans writes the '
|
||||
'seventh schema all the same', () {
|
||||
test('without study options, a bibliography, exam plans or a subject '
|
||||
'writes the eighth schema all the same', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
);
|
||||
|
||||
expect(json(payload)['schemaVersion'], 7);
|
||||
expect(json(payload)['schemaVersion'], 8);
|
||||
expect(json(payload).keys, isNot(contains('study')));
|
||||
expect(json(payload).keys, isNot(contains('bibliography')));
|
||||
expect(json(payload).keys, isNot(contains('examPlans')));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_editor/halkyon_editor.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
|
||||
Studyset studyset(
|
||||
String id, {
|
||||
|
|
@ -196,6 +197,32 @@ void main() {
|
|||
expect(colorNameOf('german', all), isNull);
|
||||
});
|
||||
|
||||
test("a studyset's subject is its top studyset's, which alone has one "
|
||||
"(the developer's decision of 10 Nov 2026)", () {
|
||||
Studyset of(String id, {String? subject, String? parent}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
subject: subject,
|
||||
),
|
||||
parentId: parent,
|
||||
);
|
||||
final all = [
|
||||
of('japanese', subject: 'languages'),
|
||||
of('kana', parent: 'japanese'),
|
||||
// Left from an import: a sub-studyset's own subject counts for nothing.
|
||||
of('hiragana', subject: 'music', parent: 'kana'),
|
||||
of('origami', subject: 'paper-folding'),
|
||||
of('notes'),
|
||||
];
|
||||
|
||||
expect(subjectOf('hiragana', all), Subject.languages);
|
||||
expect(subjectOf('japanese', all), Subject.languages);
|
||||
expect(subjectOf('origami', all), isNull, reason: 'one of a newer app');
|
||||
expect(subjectOf('notes', all), isNull);
|
||||
});
|
||||
|
||||
test('a studyset without languages of its own takes those of the nearest '
|
||||
'studyset above it that gives any, both together', () {
|
||||
Studyset speaking(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The subjects a studyset is filed under (the developer's decisions of 9
|
||||
/// and 10 Nov 2026): a fixed list, each with its icon and default colour.
|
||||
void main() {
|
||||
test('the IDs studysets keep are these, never renamed: a renamed one '
|
||||
'would read as one of a newer app', () {
|
||||
expect(
|
||||
[for (final subject in Subject.values) subject.id],
|
||||
[
|
||||
'biology',
|
||||
'chemistry',
|
||||
'physics',
|
||||
'astronomy',
|
||||
'earth-sciences',
|
||||
'environmental-science',
|
||||
'medicine',
|
||||
'nursing',
|
||||
'pharmacy',
|
||||
'nutrition',
|
||||
'psychology',
|
||||
'sports-science',
|
||||
'mathematics',
|
||||
'statistics',
|
||||
'computer-science',
|
||||
'software-engineering',
|
||||
'data-science-ai',
|
||||
'it-operations',
|
||||
'security-privacy',
|
||||
'engineering',
|
||||
'architecture',
|
||||
'history',
|
||||
'geography',
|
||||
'politics',
|
||||
'law',
|
||||
'economics',
|
||||
'business',
|
||||
'marketing',
|
||||
'accounting-finance',
|
||||
'sociology',
|
||||
'anthropology',
|
||||
'education',
|
||||
'media-studies',
|
||||
'philosophy',
|
||||
'religious-studies',
|
||||
'languages',
|
||||
'literature',
|
||||
'linguistics',
|
||||
'art-design',
|
||||
'music',
|
||||
'theatre-film',
|
||||
'cooking-hospitality',
|
||||
'driving-licences',
|
||||
'other',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('read back by ID; one it does not know is none', () {
|
||||
expect(Subject.byId('languages'), Subject.languages);
|
||||
expect(Subject.byId('paper-folding'), isNull);
|
||||
expect(Subject.byId(null), isNull);
|
||||
});
|
||||
|
||||
test('every group holds some, in the order the list has them; Other, '
|
||||
'without a colour of its own, comes last', () {
|
||||
for (final group in SubjectGroup.values) {
|
||||
expect(Subject.values.where((s) => s.group == group), isNotEmpty);
|
||||
}
|
||||
expect(Subject.values.last, Subject.other);
|
||||
expect(Subject.other.color, isNull);
|
||||
expect(Subject.languages.color, PaletteColor.cerise);
|
||||
});
|
||||
|
||||
test('every subject and group is named in English and German, each name '
|
||||
'its own', () async {
|
||||
for (final locale in AppLocalizations.supportedLocales) {
|
||||
final l10n = await AppLocalizations.delegate.load(locale);
|
||||
final names = {for (final s in Subject.values) s.nameIn(l10n)};
|
||||
expect(names, hasLength(Subject.values.length), reason: '$locale');
|
||||
expect(
|
||||
{for (final g in SubjectGroup.values) g.nameIn(l10n)},
|
||||
hasLength(SubjectGroup.values.length),
|
||||
);
|
||||
}
|
||||
final german = await AppLocalizations.delegate.load(const Locale('de'));
|
||||
expect(Subject.computerScience.nameIn(german), 'Informatik');
|
||||
});
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/subject_picker.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -74,6 +75,7 @@ void main() {
|
|||
DateTime? archivedAt,
|
||||
DateTime? starredAt,
|
||||
String? publishedAt,
|
||||
String? subject,
|
||||
bool private = false,
|
||||
}) => gateway.receiveRemoteChange(
|
||||
id,
|
||||
|
|
@ -86,6 +88,7 @@ void main() {
|
|||
? null
|
||||
: const PublishedRef(publication: 'p', item: 'i', version: 1),
|
||||
publishedAt: publishedAt,
|
||||
subject: subject,
|
||||
),
|
||||
),
|
||||
archivedAt: archivedAt,
|
||||
|
|
@ -267,6 +270,109 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
group("a studyset's subject (the developer's decisions of 9 and 10 Nov "
|
||||
'2026)', () {
|
||||
Future<void> pickSubject(WidgetTester tester, String name) async {
|
||||
await tester.tap(find.text('Choose a subject'));
|
||||
await tester.pumpAndSettle();
|
||||
final row = find.widgetWithText(ListTile, name);
|
||||
await tester.scrollUntilVisible(
|
||||
row,
|
||||
200,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(SheetScaffold).last,
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(row);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The subject the sheet's field shows.
|
||||
Finder subjectShown(String name) => find.descendant(
|
||||
of: find.ancestor(
|
||||
of: find.byType(SubjectBadge),
|
||||
matching: find.byType(ListTile),
|
||||
),
|
||||
matching: find.text(name),
|
||||
);
|
||||
|
||||
testWidgets('is picked from the subjects in their groups, and gives the '
|
||||
'studyset its colour', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||
|
||||
await tester.tap(find.text('Choose a subject'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sciences and health'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
await pickSubject(tester, 'Languages');
|
||||
|
||||
expect(subjectShown('Languages'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Color: Cerise'), findsOneWidget);
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
final content = decodeStudysetPayload(gateway.writes.single.$2);
|
||||
expect(content.subject, 'languages');
|
||||
expect(content.colorName, 'cerise');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets("its colour stays the studyset's to change", (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Rust');
|
||||
await pickSubject(tester, 'Software engineering');
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Color: Enzian'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.bySemanticsLabel('Fern'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final content = decodeStudysetPayload(gateway.writes.single.$2);
|
||||
expect(content.subject, 'software-engineering');
|
||||
expect(content.colorName, 'fern');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('editing shows the one chosen, and keeps one this app '
|
||||
'version does not know', (tester) async {
|
||||
stored('origami', 'Origami', subject: 'paper-folding');
|
||||
stored('japanese', 'Japanese', subject: 'languages');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Edit');
|
||||
expect(subjectShown('Languages'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await choose(tester, 'Origami', 'Edit');
|
||||
expect(find.text('Choose a subject'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Origami'),
|
||||
'Paper',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
decodeStudysetPayload(gateway.writes.last.$2).subject,
|
||||
'paper-folding',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('creating waits for a title', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
|
|
|
|||
|
|
@ -396,6 +396,11 @@ void main() {
|
|||
tester.getRect(find.text('Inherit')).left,
|
||||
greaterThan(tester.getRect(find.byType(PaletteColorPicker)).right),
|
||||
);
|
||||
expect(
|
||||
find.text('Choose a subject'),
|
||||
findsNothing,
|
||||
reason: "a sub-studyset's subject is its top studyset's",
|
||||
);
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kana');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue