feat(app): studyset payload schema 2, stars and the archive

A studyset is now its content (StudysetContent, everything the synced payload
holds) and the columns that change on their own: where it sits (parent,
position), and when it was archived or starred. Like a card's content, the
payload's part is edited as one value; the columns are written one by one,
so starring or archiving on one device and editing on another all survive a
sync (ADR 0004).

The payload's schema 2 adds the secondary title, the colour (a palette name),
the licence (SPDX identifier or name, holder, year) and the languages of the
cards' two sides (BCP 47), each left out when not given. Schema 1 reads as
schema 2 without them, and a field of the wrong kind makes the payload
unreadable rather than half read. Older app versions skip schema 2 studysets,
which costs nothing before the first release; fields foreseeable for the
first release's features are in, so none of them needs a schema 3 later.

The repository stars, unstars, archives and unarchives (at clock time, in
UTC), writing only that column. A new sync test (run against a server)
checks that a star and an archive on one device and a rename on another all
arrive on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 00:33:58 +02:00
commit b171b8e1d4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 528 additions and 136 deletions

View file

@ -64,7 +64,9 @@ class StudysetEditBloc(
on<StudysetEditEventTitleChanged>(
(event, emit) => emit(
StudysetEditState(
studyset: state.studyset.copyWith(title: event.title),
studyset: state.studyset.withContent(
state.studyset.content.copyWith(title: event.title),
),
saved: state.saved,
),
),
@ -72,7 +74,9 @@ class StudysetEditBloc(
on<StudysetEditEventDescriptionChanged>(
(event, emit) => emit(
StudysetEditState(
studyset: state.studyset.copyWith(description: event.description),
studyset: state.studyset.withContent(
state.studyset.content.copyWith(description: event.description),
),
saved: state.saved,
),
),
@ -86,7 +90,7 @@ class StudysetEditBloc(
Emitter<StudysetEditState> emit,
) async {
final studyset = state.studyset;
await _studysets.update(studyset);
await _studysets.updateContent(studyset.id, studyset.content);
emit(StudysetEditState(studyset: studyset, saved: studyset));
}

View file

@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
/// What happens to the list of studysets.
sealed class const StudysetListEvent() with Equatable {
@ -67,6 +68,6 @@ class StudysetListBloc(final StudysetRepository _studysets)
StudysetListEventCreateRequested event,
Emitter<StudysetListState> emit,
) async {
await _studysets.create(title: event.title);
await _studysets.create(StudysetContent.titled(event.title));
}
}

View file

@ -1,20 +1,38 @@
import 'dart:typed_data';
/// A studyset's row as the device's database stores it.
typedef StudysetRow = ({String id, ByteData payload});
/// A studyset's row as the device's database stores it: the payload, and the
/// columns that change on their own (ADR 0004).
typedef StudysetRow = ({
String id,
ByteData payload,
String? parentId,
String position,
DateTime? archivedAt,
DateTime? starredAt,
});
/// The boundary to the studysets in this person's local, synced database.
/// Tests replace it with a fake.
///
/// Every update writes only its own column, so a change of another column on
/// another device survives the merge (ADR 0001, rule 1).
abstract interface class StudysetGateway() {
/// All studysets on this device, oldest first.
Future<List<StudysetRow>> all();
/// Stores a new studyset and returns its id, a UUID v7 made on the device.
/// Stores a new studyset at the top level and returns its id, a UUID v7
/// made on the device.
Future<String> insert(ByteData payload);
/// Replaces a studyset's payload; the only column that changes.
/// Replaces a studyset's payload.
Future<void> updatePayload(String id, ByteData payload);
/// Sets or clears when a studyset was starred.
Future<void> updateStarredAt(String id, DateTime? starredAt);
/// Sets or clears when a studyset was archived.
Future<void> updateArchivedAt(String id, DateTime? archivedAt);
/// Deletes a studyset's row.
Future<void> delete(String id);

View file

@ -2,20 +2,30 @@ import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
// A studyset's content as its synced row stores it (see core/content/payload).
//
// Schema 1 held the title and the description. Schema 2 added the secondary
// title, the colour, the licence (name, holder, year) and the languages of
// the cards' sides, each left out when not given; schema 1 reads as schema 2
// without them.
/// The schema this app writes.
const studysetSchemaVersion = 1;
/// A studyset's content.
typedef StudysetContent = ({String title, RichContent description});
const studysetSchemaVersion = 2;
/// Encodes a studyset's content into its payload.
ByteData encodeStudysetPayload(StudysetContent content) =>
encodePayload(studysetSchemaVersion, {
'title': content.title,
'description': content.description.toJson(),
'secondaryTitle': ?content.secondaryTitle,
'color': ?content.colorName,
'license': ?content.license,
'licenseHolder': ?content.licenseHolder,
'licenseYear': ?content.licenseYear,
'frontLanguage': ?content.frontLanguage,
'backLanguage': ?content.backLanguage,
});
/// Decodes a studyset's payload.
@ -28,7 +38,26 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
'title': final String title,
'description': final Map<String, Object?> description,
}) {
return (title: title, description: RichContent.fromJson(description));
return StudysetContent(
title: title,
description: RichContent.fromJson(description),
secondaryTitle: _optional(json, 'secondaryTitle'),
colorName: _optional(json, 'color'),
license: _optional(json, 'license'),
licenseHolder: _optional(json, 'licenseHolder'),
licenseYear: _optional(json, 'licenseYear'),
frontLanguage: _optional(json, 'frontLanguage'),
backLanguage: _optional(json, 'backLanguage'),
);
}
throw const FormatException('Not a studyset payload.');
}
/// The optional field [key] of [json]: null when absent, and a
/// [FormatException] when it holds something other than a [T].
T? _optional<T extends Object>(Map<String, Object?> json, String key) =>
switch (json[key]) {
null => null,
final T value => value,
_ => throw FormatException('A studyset payload has a malformed $key.'),
};

View file

@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
/// The person's studysets: read from this device, changed offline, and synced
/// with their other devices in the background.
@ -37,24 +38,40 @@ class StudysetRepository(final StudysetGateway _gateway) {
for (final row in await _gateway.all()) ?_decode(row),
];
/// Creates a studyset with an empty description; returns its id.
Future<String> create({required String title}) async {
final id = await _gateway.insert(
encodeStudysetPayload((title: title, description: RichContent.empty())),
);
/// Creates a studyset with [content] at the top level; returns its id.
Future<String> create(StudysetContent content) async {
final id = await _gateway.insert(encodeStudysetPayload(content));
await _refresh();
return id;
}
/// Stores the studyset's title and description.
Future<void> update(Studyset studyset) async {
await _gateway.updatePayload(
studyset.id,
encodeStudysetPayload((
title: studyset.title,
description: studyset.description,
)),
);
/// Stores the studyset [id]'s new [content].
Future<void> updateContent(String id, StudysetContent content) async {
await _gateway.updatePayload(id, encodeStudysetPayload(content));
await _refresh();
}
/// Stars the studyset [id], keeping it at the top of the library.
Future<void> star(String id) async {
await _gateway.updateStarredAt(id, clock.now().toUtc());
await _refresh();
}
/// Takes the studyset [id]'s star away.
Future<void> unstar(String id) async {
await _gateway.updateStarredAt(id, null);
await _refresh();
}
/// Archives the studyset [id], and so everything inside it.
Future<void> archive(String id) async {
await _gateway.updateArchivedAt(id, clock.now().toUtc());
await _refresh();
}
/// Takes the studyset [id] out of the archive.
Future<void> unarchive(String id) async {
await _gateway.updateArchivedAt(id, null);
await _refresh();
}
@ -96,11 +113,13 @@ class StudysetRepository(final StudysetGateway _gateway) {
/// written by a newer app version, or one being deleted.
Studyset? _decode(StudysetRow row) {
try {
final content = decodeStudysetPayload(row.payload);
return Studyset(
id: row.id,
title: content.title,
description: content.description,
content: decodeStudysetPayload(row.payload),
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
starredAt: row.starredAt,
);
} on FormatException catch (error) {
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');

View file

@ -19,7 +19,14 @@ class SyncStudysetGateway(
_db,
orderBy: (t) => t.id,
))
(id: studyset.id!.uuid, payload: studyset.payload),
(
id: studyset.id!.uuid,
payload: studyset.payload,
parentId: studyset.parentId?.uuid,
position: studyset.position,
archivedAt: studyset.archivedAt,
starredAt: studyset.starredAt,
),
];
@override
@ -34,21 +41,38 @@ class SyncStudysetGateway(
@override
Future<void> updatePayload(String id, ByteData payload) async {
final studyset = await protocol.Studyset.db.findById(
_db,
protocol.UuidValue.withValidation(id),
);
if (studyset == null) {
return;
if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow(
_db,
studyset.copyWith(payload: payload),
columns: (t) => [t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow(
_db,
studyset.copyWith(starredAt: starredAt),
columns: (t) => [t.starredAt],
);
_sync.requestSync();
}
}
@override
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow(
_db,
studyset.copyWith(archivedAt: archivedAt),
columns: (t) => [t.archivedAt],
);
_sync.requestSync();
}
// Only the named column changes, so a concurrent edit of another column
// on another device survives the merge (ADR 0001, rule 1).
await protocol.Studyset.db.updateRow(
_db,
studyset.copyWith(payload: payload),
columns: (t) => [t.payload],
);
_sync.requestSync();
}
@override
@ -62,4 +86,12 @@ class SyncStudysetGateway(
@override
Stream<void> get remoteChanges => _sync.merges;
/// The studyset [id], or null once it is gone. Updates name the columns
/// they write, so a concurrent change of another column on another device
/// survives the merge (ADR 0001, rule 1).
Future<protocol.Studyset?> _find(String id) => protocol.Studyset.db.findById(
_db,
protocol.UuidValue.withValidation(id),
);
}

View file

@ -1,24 +1,50 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
/// A studyset: the cards, notes and paths that belong together.
final class const Studyset({
/// A UUID v7, made on the device that created it.
required final String id,
/// The name people see in lists.
required final String title,
/// What the person wrote about it.
required final StudysetContent content,
/// What the studyset is about, as rich text.
required final RichContent description,
/// The studyset it sits in; null at the top level.
final String? parentId,
/// Where it stands among its siblings: a fractional index.
final String position = 'a0',
/// When the person archived it, in UTC; null while it is active.
final DateTime? archivedAt,
/// When the person starred it, in UTC; null when it isn't starred.
final DateTime? starredAt,
}) with Equatable {
@override
List<Object?> get props => [id, title, description];
/// Whether it was archived itself; a studyset inside an archived one rests
/// in the archive too.
bool get isArchived => archivedAt != null;
/// This studyset with some fields replaced.
Studyset copyWith({String? title, RichContent? description}) => Studyset(
/// Whether it is starred, to stay at the top of the library.
bool get isStarred => starredAt != null;
@override
List<Object?> get props => [
id,
content,
parentId,
position,
archivedAt,
starredAt,
];
/// This studyset with [content] instead of its own.
Studyset withContent(StudysetContent content) => Studyset(
id: id,
title: title ?? this.title,
description: description ?? this.description,
content: content,
parentId: parentId,
position: position,
archivedAt: archivedAt,
starredAt: starredAt,
);
}

View file

@ -0,0 +1,72 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// What a person writes about a studyset: everything its synced payload
/// holds. Where it sits and whether it is starred or archived are columns of
/// their own (see `Studyset`), so that changing one of those on one device
/// and this on another don't overwrite each other.
final class const StudysetContent({
/// The name people see.
required final String title,
/// What the studyset is about, as rich text.
required final RichContent description,
/// A short qualifier shown above the title, such as "JLPT N5"; null when
/// there is none.
final String? secondaryTitle,
/// The name of its colour in the app's palette; null for the default. A
/// name this app version doesn't know counts as the default too.
final String? colorName,
/// The licence its content is under: an SPDX identifier where there is one
/// (`CC-BY-SA-4.0`), otherwise the licence's name; null when none is given.
final String? license,
/// Who holds the rights, as the licence's attribution names them.
final String? licenseHolder,
/// The year of the copyright notice.
final int? licenseYear,
/// The language of the cards' fronts, as a BCP 47 tag (`ja`); null when not
/// given.
final String? frontLanguage,
/// The language of the cards' backs, like [frontLanguage].
final String? backLanguage,
}) with Equatable {
/// Content with only a [title] and an empty description, as a new
/// studyset starts.
factory titled(String title) =>
StudysetContent(title: title, description: RichContent.empty());
@override
List<Object?> get props => [
title,
description,
secondaryTitle,
colorName,
license,
licenseHolder,
licenseYear,
frontLanguage,
backLanguage,
];
/// This content with a new [title] or [description]. The other fields can
/// be cleared, so they change by building new content.
StudysetContent copyWith({String? title, RichContent? description}) =>
StudysetContent(
title: title ?? this.title,
description: description ?? this.description,
secondaryTitle: secondaryTitle,
colorName: colorName,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
frontLanguage: frontLanguage,
backLanguage: backLanguage,
);
}

View file

@ -36,7 +36,7 @@ class const _StudysetListView() extends StatelessWidget {
children: [
for (final studyset in studysets)
ListTile(
title: Text(studyset.title),
title: Text(studyset.content.title),
onTap: () => context.go(RoutePaths.studyset(studyset.id)),
),
],

View file

@ -53,7 +53,7 @@ class const StudysetScreen({
padding: const EdgeInsets.all(16),
children: [
TextFormField(
initialValue: studyset.title,
initialValue: studyset.content.title,
decoration: const InputDecoration(labelText: 'Title'),
onChanged: (title) => context.read<StudysetEditBloc>().add(
StudysetEditEventTitleChanged(title),
@ -67,7 +67,7 @@ class const StudysetScreen({
SizedBox(
height: 320,
child: RichContentEditor(
initialContent: studyset.description,
initialContent: studyset.content.description,
onChanged: (description) => context
.read<StudysetEditBloc>()
.add(StudysetEditEventDescriptionChanged(description)),

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_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:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
@ -15,10 +16,12 @@ void main() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload((
title: 'Japanese',
description: RichContent.empty(),
)),
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
),
),
);
});

View file

@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import '../../../support/fake_auth_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
@ -161,7 +162,7 @@ void main() {
setUp(() async {
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
await studysets.create(title: 'Japanese');
await studysets.create(StudysetContent.titled('Japanese'));
bloc = (await ready())..add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.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 '../../../support/fake_auth_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
@ -20,10 +21,12 @@ void main() {
for (var i = 1; i <= studysets; i++) {
gateway.receiveRemoteChange(
'studyset-$i',
encodeStudysetPayload((
title: 'Studyset $i',
description: RichContent.empty(),
)),
encodeStudysetPayload(
StudysetContent(
title: 'Studyset $i',
description: RichContent.empty(),
),
),
);
}
await pumpApp(

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.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/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
@ -51,10 +52,12 @@ void main() {
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload((
title: 'Japanese',
description: RichContent.empty(),
)),
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
),
),
);
await pumpApp(
tester,

View file

@ -1,11 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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 '../../../support/fake_card_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
@ -35,7 +35,8 @@ void main() {
bloc.stream,
emits(
isA<StudysetListStateLoaded>().having(
(state) => state.studysets.map((studyset) => studyset.title),
(state) =>
state.studysets.map((studyset) => studyset.content.title),
'titles',
['Japanese'],
),
@ -68,12 +69,8 @@ void main() {
setUp(() async {
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
final id = await repository.create(title: 'Japanese');
studyset = Studyset(
id: id,
title: 'Japanese',
description: RichContent.empty(),
);
final id = await repository.create(StudysetContent.titled('Japanese'));
studyset = Studyset(id: id, content: StudysetContent.titled('Japanese'));
bloc = StudysetEditBloc(repository, cards, studyset);
});
tearDown(() async {
@ -108,7 +105,10 @@ void main() {
),
),
);
expect((await repository.watchAll().first).single.title, 'Japanese');
expect(
(await repository.watchAll().first).single.content.title,
'Japanese',
);
bloc.add(const StudysetEditEventSaveRequested());
await expectLater(
@ -121,7 +121,7 @@ void main() {
),
),
);
expect((await repository.watchAll().first).single.title, '日本語');
expect((await repository.watchAll().first).single.content.title, '日本語');
});
test('deletes the studyset and says so', () async {

View file

@ -5,11 +5,16 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_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';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
Map<String, Object?> json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
final description = RichContent({
'document': {
'type': 'page',
@ -27,22 +32,62 @@ void main() {
});
group('The studyset payload', () {
test('survives a round trip, Japanese included', () {
final content = (title: '日本語', description: description);
test('survives a round trip with everything set, Japanese included', () {
final content = StudysetContent(
title: '日本語',
description: description,
secondaryTitle: 'JLPT N5',
colorName: 'cobalt',
license: 'CC-BY-SA-4.0',
licenseHolder: 'Tae Kim',
licenseYear: 2006,
frontLanguage: 'ja',
backLanguage: 'en',
);
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
});
test('names its schema version', () {
final payload = encodeStudysetPayload((
title: 'Japanese',
description: RichContent.empty(),
));
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
test('leaves out what is not given', () {
final payload = encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
);
expect(json['schemaVersion'], studysetSchemaVersion);
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
});
test('names its schema version, the second', () {
final payload = encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
);
expect(studysetSchemaVersion, 2);
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
});
test('reads the first schema, which had only a title and a '
'description', () {
final payload = bytes({
'schemaVersion': 1,
'title': 'Japanese',
'description': description.toJson(),
});
expect(
decodeStudysetPayload(payload),
StudysetContent(title: 'Japanese', description: description),
);
});
test('refuses a field of the wrong kind', () {
final payload = bytes({
'schemaVersion': 2,
'title': 'Japanese',
'description': description.toJson(),
'licenseYear': 'long ago',
});
expect(() => decodeStudysetPayload(payload), throwsFormatException);
});
test('refuses a schema newer than this app knows', () {

View file

@ -1,12 +1,14 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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 '../../../support/fake_studyset_gateway.dart';
@ -20,11 +22,14 @@ void main() {
});
tearDown(() => repository.dispose());
StudysetContent titled(String title) =>
StudysetContent(title: title, description: RichContent.empty());
Iterable<String> titles(List<Studyset> studysets) =>
studysets.map((studyset) => studyset.title);
studysets.map((studyset) => studyset.content.title);
test('lists what is stored, then every change', () async {
await repository.create(title: 'Japanese');
await repository.create(titled('Japanese'));
final lists = repository.watchAll();
final expectation = expectLater(
@ -35,44 +40,78 @@ void main() {
]),
);
await Future<void>.delayed(Duration.zero);
await repository.create(title: 'Kanji');
await repository.create(titled('Kanji'));
await expectation;
});
test('reads the studysets on this device once', () async {
await repository.create(title: 'Japanese');
await repository.create(title: 'Kanji');
await repository.create(titled('Japanese'));
await repository.create(titled('Kanji'));
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
});
test('creates studysets with an empty description', () async {
final id = await repository.create(title: 'Japanese');
test('creates studysets at the top level, neither starred nor '
'archived', () async {
final content = StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
secondaryTitle: 'JLPT N5',
colorName: 'cobalt',
);
final studysets = await repository.watchAll().first;
expect(studysets, [
Studyset(id: id, title: 'Japanese', description: RichContent.empty()),
final id = await repository.create(content);
expect(await repository.watchAll().first, [
Studyset(id: id, content: content),
]);
});
test('stores changes to title and description', () async {
final id = await repository.create(title: 'Japanese');
final changed = Studyset(
id: id,
test('stores changed content', () async {
final id = await repository.create(titled('Japanese'));
final changed = StudysetContent(
title: '日本語',
description: RichContent({
'document': {'type': 'page'},
'note': 'changed',
}),
licenseYear: 2026,
);
await repository.update(changed);
await repository.updateContent(id, changed);
expect(await repository.watchAll().first, [changed]);
expect(await repository.watchAll().first, [
Studyset(id: id, content: changed),
]);
});
test('stars and unstars, writing nothing but the star', () async {
final starredAt = DateTime.utc(2026, 10, 1, 9);
final id = await repository.create(titled('Japanese'));
await withClock(Clock.fixed(starredAt), () => repository.star(id));
expect((await repository.all()).single.starredAt, starredAt);
await repository.unstar(id);
expect((await repository.all()).single.isStarred, isFalse);
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
});
test('archives and unarchives, writing nothing but the archive '
'date', () async {
final archivedAt = DateTime.utc(2026, 10, 1, 9);
final id = await repository.create(titled('Japanese'));
await withClock(Clock.fixed(archivedAt), () => repository.archive(id));
expect((await repository.all()).single.archivedAt, archivedAt);
await repository.unarchive(id);
expect((await repository.all()).single.isArchived, isFalse);
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
});
test('blanks the content before deleting', () async {
final id = await repository.create(title: 'Secret');
final id = await repository.create(titled('Secret'));
await repository.delete(id);
@ -94,16 +133,13 @@ void main() {
gateway.receiveRemoteChange(
'laptop-1',
encodeStudysetPayload((
title: 'From the laptop',
description: RichContent.empty(),
)),
encodeStudysetPayload(titled('From the laptop')),
);
await expectation;
});
test('reports a failed read and recovers with the next change', () async {
await repository.create(title: 'Japanese');
await repository.create(titled('Japanese'));
gateway.failReads = Exception('Disk unavailable');
final lists = repository.watchAll();
final expectation = expectLater(
@ -116,12 +152,12 @@ void main() {
await Future<void>.delayed(Duration.zero);
gateway.failReads = null;
await repository.create(title: 'Kanji');
await repository.create(titled('Kanji'));
await expectation;
});
test('skips studysets this app version cannot read', () async {
await repository.create(title: 'Readable');
await repository.create(titled('Readable'));
gateway.receiveRemoteChange(
'future-1',
ByteData.sublistView(

View file

@ -7,7 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway
/// [receiveRemoteChange] and [receiveRemoteDeletion] play a sync that brought
/// changes.
class FakeStudysetGateway() implements StudysetGateway {
final _rows = <String, ByteData>{};
final _rows = <String, StudysetRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
@ -21,9 +21,23 @@ class FakeStudysetGateway() implements StudysetGateway {
Exception? failReads;
/// Stores [payload] as if another device had written it and a sync had
/// brought it here.
void receiveRemoteChange(String id, ByteData payload) {
_rows[id] = payload;
/// brought it here, with the other columns given.
void receiveRemoteChange(
String id,
ByteData payload, {
String? parentId,
String position = 'a0',
DateTime? archivedAt,
DateTime? starredAt,
}) {
_rows[id] = (
id: id,
payload: payload,
parentId: parentId,
position: position,
archivedAt: archivedAt,
starredAt: starredAt,
);
_remoteChanges.add(null);
}
@ -39,28 +53,67 @@ class FakeStudysetGateway() implements StudysetGateway {
if (failReads case final error?) {
throw error;
}
return [
for (final MapEntry(key: id, value: payload) in _rows.entries)
(id: id, payload: payload),
];
return [..._rows.values];
}
@override
Future<String> insert(ByteData payload) async {
final id = 'studyset-${_nextId++}';
_rows[id] = payload;
_rows[id] = (
id: id,
payload: payload,
parentId: null,
position: 'a0',
archivedAt: null,
starredAt: null,
);
writes.add((id, payload));
return id;
}
@override
Future<void> updatePayload(String id, ByteData payload) async {
if (_rows.containsKey(id)) {
_rows[id] = payload;
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
starredAt: row.starredAt,
);
writes.add((id, payload));
}
}
@override
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
starredAt: starredAt,
);
}
}
@override
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: archivedAt,
starredAt: row.starredAt,
);
}
}
@override
Future<void> delete(String id) async {
_rows.remove(id);

View file

@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
@ -59,7 +60,9 @@ void main() {
/// A studyset with one card, made on [phone] and synced to [laptop];
/// returns their ids.
Future<(String, String)> shared(SyncDevice phone, SyncDevice laptop) async {
final studyset = await phone.studysets.create(title: 'Japanese');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final created = await phone.cards.create(studyset, card('猫', 'cat'));
await phone.sync();
await laptop.sync();

View file

@ -13,6 +13,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
@ -60,23 +61,62 @@ void main() {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(title: 'Japanese');
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
await phone.sync();
await laptop.sync();
final onLaptop = await laptop.studyset(id);
expect(onLaptop?.title, 'Japanese');
expect(onLaptop?.content.title, 'Japanese');
await laptop.studysets.update(onLaptop!.copyWith(title: '日本語'));
await laptop.studysets.updateContent(
id,
onLaptop!.content.copyWith(title: '日本語'),
);
await laptop.sync();
await phone.sync();
expect((await phone.studyset(id))?.title, '日本語');
expect((await phone.studyset(id))?.content.title, '日本語');
});
test('starring and archiving on one device and renaming on another all '
'survive', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
await phone.sync();
await laptop.sync();
await phone.studysets.star(id);
await phone.studysets.archive(id);
await laptop.studysets.updateContent(
id,
StudysetContent.titled('日本語'),
);
await phone.sync();
await laptop.sync();
await phone.sync();
for (final device in [phone, laptop]) {
final studyset = await device.studyset(id);
expect(studyset?.content.title, '日本語');
expect(studyset?.isStarred, isTrue);
expect(studyset?.isArchived, isTrue);
}
await laptop.studysets.unstar(id);
await laptop.studysets.unarchive(id);
await laptop.sync();
await phone.sync();
final onPhone = await phone.studyset(id);
expect(onPhone?.isStarred, isFalse);
expect(onPhone?.isArchived, isFalse);
});
test('a deleted studyset leaves the other devices and is blank on the '
'server', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(title: 'Private notes');
final id = await phone.studysets.create(
StudysetContent.titled('Private notes'),
);
await phone.sync();
await laptop.sync();
@ -103,7 +143,9 @@ void main() {
await bobsPhone.sync();
// Alice's device, signed in as her, but its database claims Bob's space.
final forged = await device(alice, 'forged', space: bob.authUserId);
final intruder = await forged.studysets.create(title: 'Intruder');
final intruder = await forged.studysets.create(
StudysetContent.titled('Intruder'),
);
// The server announces which spaces a device syncs (only Alice's), so the
// forged row is never sent. A modified client sending it anyway would
@ -126,7 +168,9 @@ void main() {
"claiming another person's space shows none of their studysets",
() async {
final bobsPhone = await device(bob, 'bobs-phone');
final bobs = await bobsPhone.studysets.create(title: "Bob's");
final bobs = await bobsPhone.studysets.create(
StudysetContent.titled("Bob's"),
);
await bobsPhone.sync();
// Alice's device claims Bob's space but writes nothing, so nothing is