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>
This commit is contained in:
parent
7f6147d689
commit
f635b0816a
1 changed files with 20287 additions and 77 deletions
|
|
@ -0,0 +1,319 @@
|
|||
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.
|
||||
void stored(String id, String title, {String? license, String? parent}) =>
|
||||
studysets.receiveRemoteChange(
|
||||
id,
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: RichContent.empty(),
|
||||
license: license,
|
||||
),
|
||||
),
|
||||
parentId: parent,
|
||||
);
|
||||
|
||||
/// 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();
|
||||
|
||||
expect(publishing.published.single, (
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.text('猫'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SheetScaffold), findsNothing);
|
||||
expect(publishing.published, hasLength(1));
|
||||
});
|
||||
|
||||
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(
|
||||
find.text('Sign in with GitHub to have a public profile and publish.'),
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue