diff --git a/lib/collab/cloud_sync_error_message.dart b/lib/collab/cloud_sync_error_message.dart index b2a2b3c2..397b63b3 100644 --- a/lib/collab/cloud_sync_error_message.dart +++ b/lib/collab/cloud_sync_error_message.dart @@ -35,7 +35,8 @@ bool isSpecificAttentionReason(String error) { lower.contains(lineupOverlapMessage.toLowerCase()) || lower.contains(retiredLineupOpMessage.toLowerCase()) || lower.contains(teammateDeletedMessage.toLowerCase()) || - lower.contains(pageDeletedMessage.toLowerCase()); + lower.contains(pageDeletedMessage.toLowerCase()) || + lower.contains(pageTooLargeToCopyMessage.toLowerCase()); } /// A cloud change whose write to the durable outbox failed, or could not be @@ -125,6 +126,10 @@ String friendlyCloudSyncError(String raw) { return 'A saved cloud change is paused after repeated failures. Retry ' 'when the connection and account are healthy.'; } + if (lower.contains(pageTooLargeToCopyMessage.toLowerCase())) { + return 'A new page was too large for the cloud to copy, so it was not ' + 'added. Keep mine tries again; Use cloud drops it.'; + } if (lower.contains('too large for cloud sync')) { return 'A saved change is too large for cloud sync. It remains saved on ' 'this device. Reduce it, then choose Keep mine to retry, or Use ' diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index aa213a2d..a498c5b3 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -54,6 +54,11 @@ const teammateDeletedCannotRestoreMessage = /// sent again. const pageDeletedMessage = 'This page was deleted'; +/// The server's refusal of a page add whose copy of another page +/// (PageAddOp.copyContentFromPagePublicId) is too large to make in one go +/// (PAGE_TOO_LARGE_TO_COPY, convex/ops.ts). +const pageTooLargeToCopyMessage = 'This page is too large to copy.'; + /// How long the server keeps a deleted page restorable (the server's /// PAGE_TRASH_RETENTION_MS), for copy that promises it. const pageTrashRetentionDays = 30; @@ -323,13 +328,15 @@ sealed class StrategyOp { 'payload': payload, 'expectedStrategyRevision': expectedRevision, }, - PageAddOp() => { + PageAddOp(:final copyContentFromPagePublicId) => { 'opId': opId, 'type': type.wireName, 'pagePublicId': pagePublicId, 'payload': payload, 'sortIndex': sortIndex, 'expectedStrategyRevision': expectedRevision, + if (copyContentFromPagePublicId != null) + 'copyContentFromPagePublicId': copyContentFromPagePublicId, }, PagePatchOp() => { 'opId': opId, @@ -448,6 +455,8 @@ sealed class StrategyOp { sortIndex: _requiredInt(json['sortIndex']), expectedStrategyRevision: _requiredInt(json['expectedStrategyRevision']), + copyContentFromPagePublicId: + json['copyContentFromPagePublicId'] as String?, ), StrategyOpType.pagePatch => PagePatchOp( opId: opId, @@ -633,6 +642,7 @@ sealed class StrategyOp { :final payload, :final sortIndex, :final expectedStrategyRevision, + :final copyContentFromPagePublicId, ) => PageAddOp( opId: value, @@ -640,6 +650,7 @@ sealed class StrategyOp { payload: payload, sortIndex: sortIndex, expectedStrategyRevision: expectedStrategyRevision, + copyContentFromPagePublicId: copyContentFromPagePublicId, ), PagePatchOp( :final pagePublicId, @@ -821,6 +832,7 @@ final class PageAddOp extends StrategyOp { required this.payload, required this.sortIndex, required this.expectedStrategyRevision, + this.copyContentFromPagePublicId, }); @override final String opId; @@ -831,6 +843,10 @@ final class PageAddOp extends StrategyOp { @override final int sortIndex; final int expectedStrategyRevision; + + /// The page whose items and lineups the server copies onto this one as + /// it adds it ("+"), under copy ids (see page_copy_id.dart). + final String? copyContentFromPagePublicId; @override StrategyOpType get type => StrategyOpType.pageAdd; } diff --git a/lib/page_transition/transition_planner.dart b/lib/page_transition/transition_planner.dart index cc650e10..0384efa9 100644 --- a/lib/page_transition/transition_planner.dart +++ b/lib/page_transition/transition_planner.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -121,14 +123,23 @@ class TransitionPlanner { /// Whether the drawing layer changes between two pages, and therefore /// whether it should fade in early during the transition. Compares the /// serialized form so geometry/style edits count, not just added or - /// removed strokes. + /// removed strokes. A copied stroke counts as its original (see + /// page_copy_id.dart): a cloud page copy gives each its own id. static bool drawingsChanged( List prev, List next, ) { if (identical(prev, next)) return false; if (prev.length != next.length) return true; - return DrawingProvider.objectToJson(prev) != - DrawingProvider.objectToJson(next); + return _drawingsByRoot(prev) != _drawingsByRoot(next); } + + static String _drawingsByRoot(List drawings) => jsonEncode([ + for (final drawing + in jsonDecode(DrawingProvider.objectToJson(drawings)) as List) + { + ...drawing as Map, + if (drawing['id'] case final String id) 'id': pageCopyRoot(id), + }, + ]); } diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index cb680a69..b1c19432 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -2676,6 +2676,7 @@ class StrategyOpQueueNotifier extends Notifier { payload: {...existing.payload, ...desired.payload}, sortIndex: existing.sortIndex, expectedStrategyRevision: existing.expectedStrategyRevision, + copyContentFromPagePublicId: existing.copyContentFromPagePublicId, ); } } @@ -2937,13 +2938,19 @@ class StrategyOpQueueNotifier extends Notifier { payload: payload, expectedStrategyRevision: revision, ), - PageAddOp(:final pagePublicId, :final payload, :final sortIndex) => + PageAddOp( + :final pagePublicId, + :final payload, + :final sortIndex, + :final copyContentFromPagePublicId, + ) => PageAddOp( opId: opId, pagePublicId: pagePublicId, payload: payload, sortIndex: sortIndex, expectedStrategyRevision: revision, + copyContentFromPagePublicId: copyContentFromPagePublicId, ), PagePatchOp(:final pagePublicId, :final payload) => PagePatchOp( opId: opId, diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index a0f9b347..be1974af 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -78,6 +78,11 @@ enum PageCopyResult { unavailable, } +/// What a new cloud page may lack, and whether it has reached the cloud +/// yet (see StrategyProvider.addPage): edits to the page it copies that +/// the server refused, which its copy never had. +typedef NewPageGaps = ({bool unsavedEdits, bool waitingForCloud}); + class StrategyProvider extends Notifier { @override StrategyState build() { @@ -399,6 +404,34 @@ class StrategyProvider extends Notifier { ..setCloudSyncError(null); } + /// Queues [op], sends it, and returns the server's answer if it comes + /// within [wait] of the call, sending included. None when it doesn't + /// (offline, or behind other work): the op stays queued and lands later. + Future _sendAndAwaitAnswer(StrategyOp op, Duration wait) async { + final answered = Completer(); + void answer(OpAck? ack) { + if (!answered.isCompleted) answered.complete(ack); + } + + final subscription = ref.listen(strategyOpQueueProvider, (_, next) { + final ack = next.lastAcks.where((ack) => ack.opId == op.opId).firstOrNull; + if (ack != null) answer(ack); + }); + final deadline = Timer(wait, () => answer(null)); + unawaited(_enqueueCloudPageDescriptorOp(op).then( + (ack) { + if (ack != null) answer(ack); + }, + onError: (Object error) => log('Could not send ${op.opId}: $error'), + )); + try { + return await answered.future; + } finally { + deadline.cancel(); + subscription.close(); + } + } + Future _enqueueCloudPageDescriptorOp(StrategyOp op) async { await enqueueOps([op]); final queue = ref.read(strategyOpQueueProvider.notifier); @@ -1475,11 +1508,22 @@ class StrategyProvider extends Notifier { return null; } - Future addPage([String? name]) async { - if (!_currentStrategyCanEditPages()) return; + /// How long "+" waits for the server to add a cloud page before saying + /// it will appear once it gets there. + @visibleForTesting + static Duration cloudPageAddWait = const Duration(seconds: 5); + + /// Adds a copy of the page on screen after it, and turns to it. In the + /// cloud the server makes the copy (convex/lib/contentCopy.ts); what the + /// copy may lack, or that it hasn't landed yet, is returned for the + /// caller to say. + Future addPage([String? name]) async { + const none = (unsavedEdits: false, waitingForCloud: false); + if (!_currentStrategyCanEditPages()) return none; if (_currentStrategyIsCloud()) { + final strategyId = state.strategyId; final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; - if (snapshot == null) return; + if (strategyId == null || snapshot == null) return none; final pages = [...snapshot.pages] ..sortBySortIndex((item) => item.sortIndex); final pageID = const Uuid().v4(); @@ -1490,28 +1534,60 @@ class StrategyProvider extends Notifier { final sourceIndex = activeIndex >= 0 ? activeIndex : pages.length - 1; final nextIndex = sourceIndex + 1; final isAutoNamed = name == null; - final ack = await _enqueueCloudPageDescriptorOp(PageAddOp( - opId: const Uuid().v4(), - pagePublicId: pageID, - payload: { - 'name': name ?? 'Page ${nextIndex + 1}', - 'isAutoNamed': isAutoNamed, - 'isAttack': pages.isNotEmpty ? pages[sourceIndex].isAttack : true, - 'settings': ref.read(strategySettingsProvider).toJson(), - }, - sortIndex: nextIndex, - expectedStrategyRevision: snapshot.header.revision, - )); - if (ack?.isAck ?? false) { - await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); - await ref - .read(strategyPageSessionProvider.notifier) - .setActivePageAnimated( - pageID, - direction: PageTransitionDirection.forward, - ); + final sourcePageId = pages.isEmpty ? null : pages[sourceIndex].publicId; + // Edits made just before "+" go ahead of the add, so the server's + // copy has them. + await ref.read(strategyPageSessionProvider.notifier).flushCurrentPage(); + if (state.strategyId != strategyId) return none; + // Edits to the page the server refused: the copy, made from the + // server's page, doesn't have them. + bool sourceEditsRefused() => + activeIndex >= 0 && + ref + .read(strategyOpQueueProvider) + .attentionByEntityKey + .keys + .any((key) => key.pageId == sourcePageId); + final refusedBefore = sourceEditsRefused(); + final answer = await _sendAndAwaitAnswer( + PageAddOp( + opId: const Uuid().v4(), + pagePublicId: pageID, + payload: { + 'name': name ?? 'Page ${nextIndex + 1}', + 'isAutoNamed': isAutoNamed, + 'isAttack': pages.isNotEmpty ? pages[sourceIndex].isAttack : true, + 'settings': ref.read(strategySettingsProvider).toJson(), + }, + sortIndex: nextIndex, + expectedStrategyRevision: snapshot.header.revision, + copyContentFromPagePublicId: sourcePageId, + ), + cloudPageAddWait, + ); + if (answer == null) { + // Offline, or behind other work: the page lands later. + return (unsavedEdits: refusedBefore, waitingForCloud: true); } - return; + // A refusal waits in the sync panel like any other. + if (!answer.isAck || state.strategyId != strategyId) { + return (unsavedEdits: refusedBefore, waitingForCloud: false); + } + // Edits sent ahead of the add have their answers too by now, and the + // server may have refused some of them. + final landed = ( + unsavedEdits: sourceEditsRefused(), + waitingForCloud: false, + ); + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + if (state.strategyId != strategyId) return landed; + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); + return landed; } final box = Hive.box(HiveBoxNames.strategiesBox); @@ -1520,9 +1596,9 @@ class StrategyProvider extends Notifier { await _syncCurrentPageToHive(); final strategyId = state.strategyId; - if (strategyId == null) return; + if (strategyId == null) return none; final strat = box.get(strategyId); - if (strat == null || strat.pages.isEmpty) return; + if (strat == null || strat.pages.isEmpty) return none; final orderedPages = [...strat.pages] ..sortBySortIndex((item) => item.sortIndex); @@ -1552,6 +1628,7 @@ class StrategyProvider extends Notifier { await box.put(updated.id, updated); await setActivePageAnimated(newPage.id); + return none; } Future renamePage(String pageId, String newName) async { diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 4f3807af..dd21d740 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -22,6 +22,7 @@ import 'package:icarus/widgets/rotate_helpers.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:uuid/uuid.dart'; +import 'package:icarus/widgets/new_page_copy_toast.dart'; class GlobalShortcuts extends ConsumerStatefulWidget { const GlobalShortcuts({super.key, required this.child}); @@ -143,7 +144,9 @@ class _GlobalShortcutsState extends ConsumerState onInvoke: (intent) async { if (!capabilities.canAddPage) return null; _dismissDeleteMenu(); - await ref.read(strategyProvider.notifier).addPage(); + showNewPageGaps( + await ref.read(strategyProvider.notifier).addPage(), + ); return null; }, ), diff --git a/lib/widgets/new_page_copy_toast.dart b/lib/widgets/new_page_copy_toast.dart new file mode 100644 index 00000000..08554a6d --- /dev/null +++ b/lib/widgets/new_page_copy_toast.dart @@ -0,0 +1,21 @@ +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/strategy_provider.dart'; + +/// Says what a new page's copy lacks, and when the page hasn't reached +/// the cloud yet, if either (see StrategyProvider.addPage). +void showNewPageGaps(NewPageGaps gaps) { + if (gaps.waitingForCloud) { + Settings.showToast( + message: "The new page hasn't reached the cloud yet. It will appear " + 'once it does.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + if (gaps.unsavedEdits) { + Settings.showToast( + message: "Changes to the page you copied that didn't save won't be in " + 'the copy.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } +} diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 6c1fecdd..a5d6efa6 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -19,6 +19,7 @@ import 'package:icarus/widgets/dialogs/delete_page_dialog.dart'; import 'package:icarus/widgets/dialogs/recently_deleted_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; +import 'package:icarus/widgets/new_page_copy_toast.dart'; const double _pagesBarCornerRadius = 12; const double _pagesBarFooterHeight = 48; @@ -216,7 +217,9 @@ class _PagesBarState extends ConsumerState { Future _addPage() async { final caps = ref.read(currentStrategyCapabilitiesProvider); if (!caps.canAddPage) return; - await ref.read(strategyProvider.notifier).addPage(); + showNewPageGaps( + await ref.read(strategyProvider.notifier).addPage(), + ); } Future _selectPage(String id) async { diff --git a/test/collab/cloud_sync_error_message_test.dart b/test/collab/cloud_sync_error_message_test.dart index bf96130b..d568c1d5 100644 --- a/test/collab/cloud_sync_error_message_test.dart +++ b/test/collab/cloud_sync_error_message_test.dart @@ -72,6 +72,15 @@ void main() { ); }); + test('explains a page copy too large to make', () { + final message = friendlyCloudSyncError(pageTooLargeToCopyMessage); + + expect(message, contains('too large for the cloud to copy')); + expect(message, contains('Keep mine')); + expect(message, contains('Use cloud')); + expect(isSpecificAttentionReason(pageTooLargeToCopyMessage), isTrue); + }); + test('lineup refusals and oversized work are specific attention reasons', () { expect(isSpecificAttentionReason(lineupPageMismatchMessage), isTrue); expect(isSpecificAttentionReason(retiredLineupOpMessage), isTrue); diff --git a/test/collab_sync_models_test.dart b/test/collab_sync_models_test.dart index 65e975c3..a7aaaf13 100644 --- a/test/collab_sync_models_test.dart +++ b/test/collab_sync_models_test.dart @@ -62,6 +62,37 @@ void main() { expect(StrategyOp.fromJson(json), isA()); }); + test('a page add keeps the page it copies, through storage and retries', + () { + const op = PageAddOp( + opId: 'op-1', + pagePublicId: 'page-2', + payload: {'name': 'Page 2'}, + sortIndex: 1, + expectedStrategyRevision: 4, + copyContentFromPagePublicId: 'page-1', + ); + final json = op.toConvexJson(); + expect(json['copyContentFromPagePublicId'], 'page-1'); + final stored = StrategyOp.fromJson(json) as PageAddOp; + expect(stored.copyContentFromPagePublicId, 'page-1'); + expect( + (stored.withOpId('op-2') as PageAddOp).copyContentFromPagePublicId, + 'page-1', + ); + // A page added without copying sends no copy field, as before. + expect( + const PageAddOp( + opId: 'op-3', + pagePublicId: 'page-3', + payload: {}, + sortIndex: 2, + expectedStrategyRevision: 4, + ).toConvexJson().containsKey('copyContentFromPagePublicId'), + isFalse, + ); + }); + test('withOpId changes identity without changing typed intent', () { const original = LineupPatchOp( opId: 'op-2', diff --git a/test/page_copy_id_test.dart b/test/page_copy_id_test.dart index 4d137201..040dea00 100644 --- a/test/page_copy_id_test.dart +++ b/test/page_copy_id_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; @@ -138,4 +139,42 @@ void main() { expect(move.to, onPage3); }); }); + + group('drawings between pages', () { + Line stroke(String id, {Offset end = const Offset(100, 100)}) => Line( + id: id, + lineStart: const Offset(10, 10), + lineEnd: end, + colorValue: 0xFFFFFFFF, + isDotted: false, + hasArrow: false, + ); + + test('a copied stroke counts as its original', () { + expect( + TransitionPlanner.drawingsChanged( + [stroke('stroke-1')], + [stroke('stroke-1~cp1~$_occurrence')], + ), + isFalse, + ); + }); + + test('a copied stroke that moved, or another stroke, is a change', () { + expect( + TransitionPlanner.drawingsChanged( + [stroke('stroke-1')], + [stroke('stroke-1~cp1~$_occurrence', end: const Offset(200, 50))], + ), + isTrue, + ); + expect( + TransitionPlanner.drawingsChanged( + [stroke('stroke-1')], + [stroke('stroke-2')], + ), + isTrue, + ); + }); + }); } diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index c0df6cc7..251a34cc 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -157,6 +157,7 @@ void main() { }, sortIndex: 1, expectedStrategyRevision: 4, + copyContentFromPagePublicId: 'page-1', ), flushImmediately: false, ); @@ -171,6 +172,10 @@ void main() { expect(intent.value.pending.op.opId, 'add-page'); expect(intent.value.pending.op.kind, StrategyOpKind.add); expect(intent.value.pending.op.expectedRevision, 4); + expect( + (intent.value.pending.op as PageAddOp).copyContentFromPagePublicId, + 'page-1', + ); }); test('restart while in flight replays the same event key', () async { diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index bd066804..9653e621 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -683,6 +683,8 @@ class _ServerRepository implements ConvexStrategyRepository { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +const _copyUuid = '0f8fad5b-d9cb-469f-a165-70867728950e'; + RemotePage _page(String id, int index, {int revision = 1, String? name, bool isAttack = true}) { final now = DateTime.utc(2026); @@ -730,6 +732,7 @@ RemotePageSnapshot _pageSnapshot( CloudPayload settings = const {}, List? elements, List lineups = const [], + Map assetsById = const {}, }) { final now = DateTime.utc(2026); return RemotePageSnapshot( @@ -745,7 +748,7 @@ RemotePageSnapshot _pageSnapshot( ? const [] : [_textElement(page.publicId, 'text-${page.publicId}', text)]), lineups: lineups, - assetsById: const {}, + assetsById: assetsById, ); } @@ -1503,6 +1506,10 @@ void main() { )), queue: queue, ); + // This server never answers; "+" stops waiting at once. + StrategyProvider.cloudPageAddWait = Duration.zero; + addTearDown( + () => StrategyProvider.cloudPageAddWait = const Duration(seconds: 5)); await container.read(strategyProvider.notifier).addPage('Execute'); @@ -1527,9 +1534,250 @@ void main() { intent.key, EntitySyncKey.pageDescriptor(pending.op.entityPublicId!), ); + // The server copies the page on screen onto it. + expect((pending.op as PageAddOp).copyContentFromPagePublicId, 'page-1'); expect(queue.flushNowCount, 1); }); + group('"+" on a cloud strategy', () { + RemoteElement image(String pageId, String id, {String? assetId}) => + RemoteElement( + publicId: id, + strategyPublicId: 'cloud-strategy', + pagePublicId: pageId, + elementType: 'image', + payload: cloudElementPayload(kind: 'image', data: { + ...cloudImagePayloadFromPlacedImage(PlacedImage( + id: id, + position: const Offset(10, 20), + aspectRatio: 1, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + assetId: assetId, + )), + 'elementType': 'image', + }), + sortIndex: 0, + revision: 1, + deleted: false, + ); + + RemoteImageAsset asset(String id, String uploadStatus) => RemoteImageAsset( + publicId: id, + fileExtension: '.png', + width: 64, + height: 64, + url: uploadStatus == 'active' ? 'https://media.test/$id.png' : null, + legacyStoragePath: null, + provider: 'r2', + uploadStatus: uploadStatus, + ); + + /// Opens page 1, showing two images, one still uploading. [land] makes the server add the + /// page "+" sends, with the copy of page 1 it makes, and answers it. + Future< + ( + ProviderContainer, + _FakeStrategyOpQueueNotifier, + void Function(PageAddOp add, List copied) land, + )> open() async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + image('page-1', 'uploaded'), + image('page-1', 'uploading'), + ], + assetsById: { + 'uploaded': asset('uploaded', 'active'), + 'uploading': asset('uploading', 'pending'), + }, + ), + shellRevision: 8, + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + await _settle(); + expect(container.read(placedImageProvider).images, hasLength(2)); + void land(PageAddOp add, List copied) { + final added = _page(add.pagePublicId, 1); + remote.pageCatalog[added.publicId] = + _pageSnapshot(added, elements: copied); + remote.initialSnapshot = _editorSnapshot( + pages: [page, added], + activePage: remote.initialSnapshot.activePage!, + shellRevision: 9, + ); + queue.ackQueued(); + } + + return (container, queue, land); + } + + PageAddOp? queuedAdd(_FakeStrategyOpQueueNotifier queue) => + queue.state.queuedByEntityKey.values + .map((intent) => intent.pending.op) + .whereType() + .firstOrNull; + + tearDown( + () => StrategyProvider.cloudPageAddWait = const Duration(seconds: 5)); + + test('turns to the copy the server made', () async { + final (container, queue, land) = await open(); + PageAddOp? sent; + queue.onFlush = () async { + final add = queuedAdd(queue); + if (add == null) return queue.ackQueued(); + sent = add; + // The server copies both images, the one still uploading too: each + // copy shows its original's picture. + land(add, [ + image(add.pagePublicId, 'uploaded~cp1~$_copyUuid', + assetId: 'uploaded'), + image(add.pagePublicId, 'uploading~cp1~$_copyUuid', + assetId: 'uploading'), + ]); + }; + + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect(sent!.copyContentFromPagePublicId, 'page-1'); + expect( + container.read(strategyPageSessionProvider).activePageId, + sent!.pagePublicId, + ); + expect( + container.read(placedImageProvider).images.map((i) => i.pictureId), + unorderedEquals(['uploaded', 'uploading']), + ); + expect(gaps, (unsavedEdits: false, waitingForCloud: false)); + await _settle(); + }); + + test('an answer that comes after the first send still turns to the page', + () async { + final (container, queue, land) = await open(); + // The add waits behind another send; the server answers later. + PageAddOp? sent; + queue.onFlush = () async { + final add = queuedAdd(queue); + if (add == null) return queue.ackQueued(); + sent = add; + Timer(const Duration(milliseconds: 200), () { + land(add, [image(add.pagePublicId, 'uploaded~cp1~$_copyUuid')]); + }); + }; + + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect( + container.read(strategyPageSessionProvider).activePageId, + sent!.pagePublicId, + ); + expect(gaps.waitingForCloud, isFalse); + await _settle(); + }); + + test('says when the page has not reached the cloud yet', () async { + final (container, queue, _) = await open(); + StrategyProvider.cloudPageAddWait = const Duration(milliseconds: 200); + + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect(gaps, (unsavedEdits: false, waitingForCloud: true)); + expect( + container.read(strategyPageSessionProvider).activePageId, 'page-1'); + // The add stays queued, to land later. + expect(queuedAdd(queue), isNotNull); + await _settle(); + }); + + test('a send that stalls still answers "+" in time', () async { + final (container, queue, _) = await open(); + StrategyProvider.cloudPageAddWait = const Duration(milliseconds: 200); + // The send never comes back. + queue.onFlush = () => Completer().future; + + final started = DateTime.now(); + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect(DateTime.now().difference(started).inSeconds, lessThan(2)); + expect(gaps.waitingForCloud, isTrue); + }); + + test('says when edits to the page were refused, so are not in its copy', + () async { + final (container, queue, land) = await open(); + // The server refused an edit to page 1. + queue.state = queue.state.copyWith(attentionByEntityKey: { + const EntitySyncKey.element('page-1', 'uploaded'): + const QueuedEntityIntent( + entityKey: EntitySyncKey.element('page-1', 'uploaded'), + pending: PendingOp( + op: ElementDeleteOp( + opId: 'refused-op', + pagePublicId: 'page-1', + elementPublicId: 'uploaded', + expectedElementRevision: 1, + ), + clientId: 'test-client', + ), + ), + }); + queue.onFlush = () async { + final add = queuedAdd(queue); + if (add == null) return queue.ackQueued(); + land(add, [image(add.pagePublicId, 'uploaded~cp1~$_copyUuid')]); + }; + + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect(gaps.unsavedEdits, isTrue); + await _settle(); + }); + + test('names an edit to the page refused in the same send as the add', + () async { + final (container, queue, land) = await open(); + queue.onFlush = () async { + final add = queuedAdd(queue); + if (add == null) return queue.ackQueued(); + land(add, [image(add.pagePublicId, 'uploaded~cp1~$_copyUuid')]); + // The server refused an edit to page 1 sent ahead of the add. + queue.state = queue.state.copyWith(attentionByEntityKey: { + const EntitySyncKey.element('page-1', 'uploaded'): + const QueuedEntityIntent( + entityKey: EntitySyncKey.element('page-1', 'uploaded'), + pending: PendingOp( + op: ElementDeleteOp( + opId: 'refused-op', + pagePublicId: 'page-1', + elementPublicId: 'uploaded', + expectedElementRevision: 1, + ), + clientId: 'test-client', + ), + ), + }); + }; + + final gaps = await container.read(strategyProvider.notifier).addPage(); + + expect(gaps.unsavedEdits, isTrue); + await _settle(); + }); + }); + test('cloud page rename is persisted with the page revision', () async { final page = _page('page-1', 0, revision: 6); final queue = _FakeStrategyOpQueueNotifier();