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, String description = '', }) => studysets.receiveRemoteChange( id, encodeStudysetPayload( StudysetContent( title: title, description: description.isEmpty ? RichContent.empty() : RichContent.plain(description), 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 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 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.publishes.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("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); }); 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(find.widgetWithText(TextField, 'Name')).readOnly, isTrue, ); expect(find.text('Publish v3'), findsOneWidget); }); 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); await tester.ensureVisible(find.text('Take off Explore')); await tester.pumpAndSettle(); 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); }); 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 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, ); }); 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, ); final unsettled = find.descendant( of: find.byType(SheetScaffold), matching: find.text('猫'), ); await tester.ensureVisible(unsettled); await tester.pumpAndSettle(); await tester.tap(unsettled); await tester.pumpAndSettle(); expect(find.byType(SheetScaffold), findsNothing); expect(publishing.publishes, 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); }); }