feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|
|
|
|
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/profile/model/public_profile.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.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:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_auth_gateway.dart';
|
|
|
|
|
import '../../support/fake_card_gateway.dart';
|
|
|
|
|
import '../../support/fake_profile_gateway.dart';
|
|
|
|
|
import '../../support/fake_publish_gateway.dart';
|
|
|
|
|
import '../../support/fake_studyset_gateway.dart';
|
|
|
|
|
import '../../support/pump_app.dart';
|
|
|
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
const _profile = PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late FakeStudysetGateway studysets;
|
|
|
|
|
late FakeCardGateway cards;
|
|
|
|
|
late FakePublishGateway publishing;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
studysets = FakeStudysetGateway();
|
|
|
|
|
cards = FakeCardGateway();
|
|
|
|
|
publishing = FakePublishGateway();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// A studyset on this device before the app starts.
|
feat(explore): cards as the library's, in its colour, with a description of their own
Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.
The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:47:29 +02:00
|
|
|
void stored(
|
|
|
|
|
String id,
|
|
|
|
|
String title, {
|
|
|
|
|
String? license,
|
|
|
|
|
String? parent,
|
|
|
|
|
String description = '',
|
|
|
|
|
}) => studysets.receiveRemoteChange(
|
|
|
|
|
id,
|
|
|
|
|
encodeStudysetPayload(
|
|
|
|
|
StudysetContent(
|
|
|
|
|
title: title,
|
|
|
|
|
description: description.isEmpty
|
|
|
|
|
? RichContent.empty()
|
|
|
|
|
: RichContent.plain(description),
|
|
|
|
|
license: license,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
parentId: parent,
|
|
|
|
|
);
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
|
|
|
|
|
/// Opens the screen of the studyset [id] as a person with an identity
|
|
|
|
|
/// ([identified]), whose profile is [profile], in a window [width] wide.
|
|
|
|
|
Future<void> showStudyset(
|
|
|
|
|
WidgetTester tester,
|
|
|
|
|
String id, {
|
|
|
|
|
PublicProfile? profile = _profile,
|
|
|
|
|
bool identified = true,
|
|
|
|
|
double width = 1200,
|
|
|
|
|
}) async {
|
|
|
|
|
tester.view.physicalSize = Size(width, 1000);
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
await pumpApp(
|
|
|
|
|
tester,
|
|
|
|
|
testDependencies(
|
|
|
|
|
authGateway: FakeAuthGateway(
|
|
|
|
|
status: SignedIn(
|
|
|
|
|
authUserId: 'github-person',
|
|
|
|
|
isIdentified: identified,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
studysetGateway: studysets,
|
|
|
|
|
cardGateway: cards,
|
|
|
|
|
profileGateway: FakeProfileGateway(profile: profile),
|
|
|
|
|
publishGateway: publishing,
|
|
|
|
|
),
|
|
|
|
|
location: RoutePaths.studyset(id),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> openSheet(WidgetTester tester) async {
|
|
|
|
|
await tester.tap(find.text('Share'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testWidgets('a private studyset is published under a name made from its '
|
|
|
|
|
"title, at its author's handle", (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-SA-4.0');
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
expect(find.text('Private · only you can see it'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Found at halkyon.eu/@raisondetredev/japanese'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Allows publishing.'), findsOneWidget);
|
|
|
|
|
await tester.enterText(
|
|
|
|
|
find.widgetWithText(TextField, 'What changed'),
|
|
|
|
|
'The first lessons.',
|
|
|
|
|
);
|
|
|
|
|
await tester.tap(find.text('Publish v1'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
2026-10-10 05:17:10 +02:00
|
|
|
expect(publishing.publishes.single, (
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
studysetId: 'japanese',
|
|
|
|
|
slug: 'japanese',
|
|
|
|
|
notes: 'The first lessons.',
|
|
|
|
|
));
|
|
|
|
|
expect(find.text('Published v1.'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('Published · v1'), findsOneWidget);
|
|
|
|
|
expect(find.text('halkyon.eu/@raisondetredev/japanese'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Fixed since you first published: others refer to it.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Publish v2'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
feat(explore): cards as the library's, in its colour, with a description of their own
Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.
The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:47:29 +02:00
|
|
|
testWidgets("Explore's description starts as the studyset's own and goes "
|
|
|
|
|
'with the release; the next publish starts from it', (tester) async {
|
|
|
|
|
stored(
|
|
|
|
|
'japanese',
|
|
|
|
|
'Japanese',
|
|
|
|
|
license: 'CC-BY-SA-4.0',
|
|
|
|
|
description: 'From N5 on.',
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Description on Explore'), findsOneWidget);
|
|
|
|
|
expect(find.text('From N5 on.'), findsWidgets);
|
|
|
|
|
await tester.tap(find.text('Publish v1'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(publishing.descriptions.single?.plainText, 'From N5 on.');
|
|
|
|
|
expect(find.text('Publish v2'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
testWidgets('a published studyset shows a globe, its releases and where '
|
|
|
|
|
'it is', (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-NC-ND-4.0');
|
|
|
|
|
publishing = FakePublishGateway(
|
|
|
|
|
publications: {
|
|
|
|
|
'japanese': Publication(
|
|
|
|
|
publicId: 'p',
|
|
|
|
|
slug: 'nihongo',
|
|
|
|
|
location: 'halkyon.eu/@raisondetredev/nihongo',
|
|
|
|
|
releases: [
|
|
|
|
|
Release(
|
|
|
|
|
version: 2,
|
|
|
|
|
releasedAt: DateTime.utc(2026, 10, 9),
|
|
|
|
|
license: 'CC-BY-NC-ND-4.0',
|
|
|
|
|
notes: 'N4 kanji',
|
|
|
|
|
),
|
|
|
|
|
Release(
|
|
|
|
|
version: 1,
|
|
|
|
|
releasedAt: DateTime.utc(2026, 10, 2),
|
|
|
|
|
license: 'CC-BY-NC-ND-4.0',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
expect(find.byIcon(LucideIcons.globe), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Releases'), findsOneWidget);
|
|
|
|
|
expect(find.text('N4 kanji'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text(
|
|
|
|
|
'Allows publishing as it is: learners keep their changes to '
|
|
|
|
|
'themselves.',
|
|
|
|
|
),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
tester.widget<TextField>(find.widgetWithText(TextField, 'Name')).readOnly,
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Publish v3'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
feat(explore): the Explore tab, and adding a published studyset as a copy
Explore lists what is published on the instance as the old app's feed:
search over titles and authors, filters for languages and kinds of
content, the newest release first, the next page only when asked for;
each studyset opens a summary with all of it. Anonymous people explore
too.
Add to library makes a copy in the app, as an import: through the
person's own repositories, the files among their own files, the tags by
name among their own tags, everything read first, so a release in a
schema this app doesn't know is refused whole. The server never writes
into a learner's space. Every item of a copy names where it came from
(publication, item, release: card schema 9, note 3, step 3, path 2,
PDF 2, studyset 5), merged per side like the rest, for later releases to
be taken from there.
The Publish sheet takes a studyset off Explore again, after asking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 23:15:28 +02:00
|
|
|
testWidgets('a published studyset is taken off Explore, after asking', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
publishing = FakePublishGateway(
|
|
|
|
|
publications: {
|
|
|
|
|
'japanese': Publication(
|
|
|
|
|
publicId: 'p',
|
|
|
|
|
slug: 'japanese',
|
|
|
|
|
location: 'halkyon.eu/@raisondetredev/japanese',
|
|
|
|
|
releases: [
|
|
|
|
|
Release(
|
|
|
|
|
version: 1,
|
|
|
|
|
releasedAt: DateTime.utc(2026, 10, 9),
|
|
|
|
|
license: 'CC-BY-4.0',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
feat(explore): how many learn it here, opt-in; copies published within their licence
Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.
A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.
ADR 0005 gets an addendum with stage 3's decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 00:18:29 +02:00
|
|
|
await tester.ensureVisible(find.text('Take off Explore'));
|
|
|
|
|
await tester.pumpAndSettle();
|
feat(explore): the Explore tab, and adding a published studyset as a copy
Explore lists what is published on the instance as the old app's feed:
search over titles and authors, filters for languages and kinds of
content, the newest release first, the next page only when asked for;
each studyset opens a summary with all of it. Anonymous people explore
too.
Add to library makes a copy in the app, as an import: through the
person's own repositories, the files among their own files, the tags by
name among their own tags, everything read first, so a release in a
schema this app doesn't know is refused whole. The server never writes
into a learner's space. Every item of a copy names where it came from
(publication, item, release: card schema 9, note 3, step 3, path 2,
PDF 2, studyset 5), merged per side like the rest, for later releases to
be taken from there.
The Publish sheet takes a studyset off Explore again, after asking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 23:15:28 +02:00
|
|
|
await tester.tap(find.text('Take off Explore'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Take it off Explore?'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.text('Take off Explore').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Taken off Explore.'), findsOneWidget);
|
|
|
|
|
expect(find.text('Withdrawn · copies get no updates'), findsOneWidget);
|
|
|
|
|
expect(find.text('Take off Explore'), findsNothing);
|
|
|
|
|
expect(find.text('Publish v2'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
testWidgets('without a public profile it asks for one first, and leads '
|
|
|
|
|
'there', (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC0-1.0');
|
|
|
|
|
await showStudyset(tester, 'japanese', profile: null);
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
expect(
|
|
|
|
|
find.text('A release names you by your public profile. Make one first.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(find.textContaining('Publish v'), findsNothing);
|
|
|
|
|
await tester.tap(find.text('Make a public profile'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Public profile'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("a licence that doesn't allow passing it on is said, and the "
|
|
|
|
|
'studysets inside the instance refuses are named', (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
stored('kanji', 'Kanji', parent: 'japanese');
|
|
|
|
|
publishing.refusals.add(
|
|
|
|
|
PublishRefused(PublishProblem.licenseNotOpen, items: ['kanji']),
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
await tester.tap(find.text('Publish v1'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
// Tests speak American English.
|
|
|
|
|
find.text("These studysets' licenses don't allow passing them on: Kanji"),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(explore): how many learn it here, opt-in; copies published within their licence
Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.
A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.
ADR 0005 gets an addendum with stage 3's decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 00:18:29 +02:00
|
|
|
testWidgets('a copy keeps the licence its original asks for', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
publishing.refusals.add(
|
|
|
|
|
PublishRefused(PublishProblem.licenseMustStay, license: 'CC-BY-SA-4.0'),
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
await tester.tap(find.text('Publish v1'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text(
|
|
|
|
|
'Its original asks adaptations to stay under CC-BY-SA-4.0: choose '
|
|
|
|
|
'it under Edit, for every studyset in it.',
|
|
|
|
|
),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
testWidgets('a studyset without a licence says so', (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese');
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('None'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text("Doesn't allow passing it on. Choose another under Edit."),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a card changed differently on two devices is named, and '
|
|
|
|
|
'opens', (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
final repository = CardRepository(cards);
|
|
|
|
|
final id = await repository.create(
|
|
|
|
|
'japanese',
|
|
|
|
|
CardContent(
|
|
|
|
|
front: RichContent.plain('猫'),
|
|
|
|
|
back: RichContent.plain('cat'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final created = (await repository.cardsOf('japanese')).single;
|
|
|
|
|
await repository.save(
|
|
|
|
|
created,
|
|
|
|
|
CardContent(
|
|
|
|
|
front: RichContent.plain('猫'),
|
|
|
|
|
back: RichContent.plain('a cat'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
cards.receiveRemoteRevision(
|
|
|
|
|
id: 'rev-elsewhere',
|
|
|
|
|
cardId: id,
|
|
|
|
|
parents: created.heads,
|
|
|
|
|
payload: encodeCardPayload(
|
|
|
|
|
CardContent(
|
|
|
|
|
front: RichContent.plain('猫'),
|
|
|
|
|
back: RichContent.plain('the cat'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
publishing.refusals.add(
|
|
|
|
|
PublishRefused(PublishProblem.conflicts, items: [id]),
|
|
|
|
|
);
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
await tester.tap(find.text('Publish v1'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(
|
|
|
|
|
find.text(
|
|
|
|
|
'These were changed differently on two devices. Settle them first:',
|
|
|
|
|
),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
feat(explore): cards as the library's, in its colour, with a description of their own
Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.
The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:47:29 +02:00
|
|
|
final unsettled = find.descendant(
|
|
|
|
|
of: find.byType(SheetScaffold),
|
|
|
|
|
matching: find.text('猫'),
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
);
|
feat(explore): cards as the library's, in its colour, with a description of their own
Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.
The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:47:29 +02:00
|
|
|
await tester.ensureVisible(unsettled);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(unsettled);
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.byType(SheetScaffold), findsNothing);
|
2026-10-10 05:17:10 +02:00
|
|
|
expect(publishing.publishes, hasLength(1));
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('on a phone it is an icon that says the studyset is private', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
await showStudyset(tester, 'japanese', width: 400);
|
|
|
|
|
|
|
|
|
|
expect(find.byTooltip('Share: private'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.byTooltip('Share: private'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Private · only you can see it'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('an anonymous person is told how to publish, and the instance '
|
|
|
|
|
"isn't asked", (tester) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
publishing.fail = Exception('must not be asked');
|
|
|
|
|
await showStudyset(tester, 'japanese', identified: false);
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
|
|
|
|
|
expect(
|
feat(auth): sign in with an email address, keeping only a keyed hash of it
People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.
It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).
Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.
The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 19:42:21 +02:00
|
|
|
find.text('Sign in to have a public profile and publish.'),
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('without the instance, it says so and tries again', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
stored('japanese', 'Japanese', license: 'CC-BY-4.0');
|
|
|
|
|
publishing.fail = Exception('offline');
|
|
|
|
|
await showStudyset(tester, 'japanese');
|
|
|
|
|
|
|
|
|
|
await openSheet(tester);
|
|
|
|
|
expect(find.text("Publishing can't be reached right now."), findsOneWidget);
|
|
|
|
|
publishing.fail = null;
|
|
|
|
|
await tester.tap(find.text('Retry'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Publish v1'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
}
|