diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 06b4a0fe..c8f5af24 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/weapons.dart'; +import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -372,6 +373,64 @@ class LineUpGraph { ); } + /// The lineups [linkIds] and the spots they aim at, ready to be put on + /// another page, each under a new id that carries its original's (see + /// page_copy_id.dart). Spots they share stay shared in the copy. + LineUpGraph copyOfLinks(Set linkIds) { + final newIds = {}; + String renamed(String id) => newIds[id] ??= newPageCopyId(id); + final part = linksWithSpots(linkIds); + return LineUpGraph( + origins: [ + for (final origin in part.origins) + LineUpOrigin( + id: renamed(origin.id), + agent: origin.agent + .deepCopy() + .copyWith(lineUpID: renamed(origin.id)), + ), + ], + landings: [ + for (final landing in part.landings) + LineUpLanding( + id: renamed(landing.id), + ability: landing.ability + .deepCopy() + .copyWith(lineUpID: renamed(landing.id)), + ), + ], + links: [ + for (final link in part.links) + link.deepCopy().copyWith( + id: renamed(link.id), + originId: renamed(link.originId), + landingId: renamed(link.landingId), + ), + ], + ); + } + + /// The lineups [linkIds] and the spots they aim at. + LineUpGraph linksWithSpots(Set linkIds) { + final picked = [ + for (final link in links) + if (linkIds.contains(link.id)) link, + ]; + final originIds = {for (final link in picked) link.originId}; + final landingIds = {for (final link in picked) link.landingId}; + return LineUpGraph( + origins: [ + for (final origin in origins) + if (originIds.contains(origin.id)) origin, + ], + landings: [ + for (final landing in landings) + if (landingIds.contains(landing.id)) landing, + ], + links: picked, + ); + } + Map toJson() { return { 'lineUpOrigins': origins.map((origin) => origin.toJson()).toList(), diff --git a/lib/const/page_copy_id.dart b/lib/const/page_copy_id.dart index 2cb735ed..43f0ddad 100644 --- a/lib/const/page_copy_id.dart +++ b/lib/const/page_copy_id.dart @@ -9,6 +9,10 @@ import 'package:uuid/uuid.dart'; /// of the item first copied, and a copy of a copy keeps that root, so ids /// never nest. The transition pairs items by root when their ids differ. /// +/// A copied lineup and its spots get these ids on local strategies too: a +/// cloud lineup group needs an id no other group in the strategy has, and +/// one rule for both keeps a copied lineup the same wherever it lives. +/// /// Storage, ops, edits, deletes and undo use the full id. The root is only /// read to pair items across pages and to see whether a page already has one. const _copyMark = '~cp1~'; diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 9a46ea79..a045a427 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -38,6 +38,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; import 'package:icarus/collab/strategy_capabilities.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -1114,6 +1115,301 @@ class StrategyProvider extends Notifier { return elements; } + /// The pages next to the one on screen that the lineups [linkIds] can be + /// copied to, as for a placed item: a local neighbour that already has + /// one of them, or a copy of it, is left out. Only the page on screen of + /// a cloud strategy is read, so there the check waits for the copy. + List copyDirectionsForLineUps( + Set linkIds, + ) { + if (linkIds.isEmpty) return const []; + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || !_currentStrategyCanEditPages()) return const []; + return [ + for (final direction in PageTransitionDirection.values) + if (_adjacentCloudPageId(snapshot, direction) case final pageId?) + if (_lineUpCopyFitsOutbox(pageId, linkIds)) direction, + ]; + } + final pages = _orderedLocalPages(); + final currentIndex = pages.indexWhere( + (page) => page.id == ref.read(strategyPageSessionProvider).activePageId, + ); + if (currentIndex < 0) return const []; + final roots = {for (final id in linkIds) pageCopyRoot(id)}; + bool has(StrategyPage page) => + page.lineUpLinks.any((link) => roots.contains(pageCopyRoot(link.id))); + return [ + if (currentIndex > 0 && !has(pages[currentIndex - 1])) + PageTransitionDirection.backward, + if (currentIndex < pages.length - 1 && !has(pages[currentIndex + 1])) + PageTransitionDirection.forward, + ]; + } + + /// Whether a cloud copy of the lineups [linkIds] onto [pageId] can be + /// stored to send. Hive refuses keys over 255 characters, and an op is + /// stored under one naming its account, strategy, page and group, whose id + /// is one of the copies' lineup ids. Only a lineup imported with an + /// unusually long id fails this, and it is then not offered. + bool _lineUpCopyFitsOutbox(String pageId, Set linkIds) { + final accountId = ref.read(strategyOpQueueProvider).accountId; + final strategyId = state.strategyId; + return accountId != null && + strategyId != null && + linkIds.every( + (id) => + DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.lineup(pageId, newPageCopyId(id)), + ).length <= + 255, + ); + } + + /// The local strategy's pages in order, or none. + List _orderedLocalPages() { + final strategyId = state.strategyId; + if (strategyId == null || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) { + return const []; + } + final strat = + Hive.box(HiveBoxNames.strategiesBox).get(strategyId); + if (strat == null) return const []; + return [...strat.pages]..sortBySortIndex((item) => item.sortIndex); + } + + /// Copies the lineups [linkIds] on screen, and the spots they aim at, to + /// the next or previous page, as copying a placed item does. Each copied + /// lineup and spot gets an id that carries the original's (see + /// page_copy_id.dart), so a page that already has one of them, or a copy + /// of it, gets no other. [linkIds] share a spot, as the menus pick them; + /// on a cloud strategy lineups that don't are not copied. The copies keep + /// their media: an image is only cleaned up once nothing in the strategy + /// shows it. The lineups are taken as they are when the user asks. + Future copyLineUpsToAdjacentPage({ + required Set linkIds, + required PageTransitionDirection direction, + }) async { + final strategyId = state.strategyId; + final copy = ref.read(lineUpProvider).graph.copyOfLinks(linkIds); + if (strategyId == null || copy.links.isEmpty) { + return PageCopyResult.unavailable; + } + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final pageId = + snapshot == null ? null : _adjacentCloudPageId(snapshot, direction); + if (pageId == null || !_currentStrategyCanEditPages()) { + return PageCopyResult.unavailable; + } + final result = _cloudCopies.then( + (_) => _copyLineUpsToCloudPage( + strategyId: strategyId, + pageId: pageId, + lineUps: copy, + ), + ); + _cloudCopies = result.then((_) {}, onError: (_) {}); + return result; + } + return _copyLineUpsToLocalPage( + strategyId: strategyId, + direction: direction, + lineUps: copy, + ); + } + + /// Whether [links] holds one of [lineUps]' lineups, or a copy of it. + static bool _hasAnyOf(Iterable links, LineUpGraph lineUps) { + final roots = {for (final link in lineUps.links) pageCopyRoot(link.id)}; + return links.any((id) => roots.contains(pageCopyRoot(id))); + } + + Future _copyLineUpsToLocalPage({ + required String strategyId, + required PageTransitionDirection direction, + required LineUpGraph lineUps, + }) async { + try { + await _syncCurrentPageToHive(); + } catch (error) { + log('Could not save the page before copying lineups: $error'); + return PageCopyResult.notSaved; + } + if (state.strategyId != strategyId) return PageCopyResult.unavailable; + final pages = _orderedLocalPages(); + final currentIndex = pages.indexWhere( + (page) => page.id == ref.read(strategyPageSessionProvider).activePageId, + ); + final targetIndex = switch (direction) { + PageTransitionDirection.backward => currentIndex - 1, + PageTransitionDirection.forward => currentIndex + 1, + }; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= pages.length) { + return PageCopyResult.unavailable; + } + final target = pages[targetIndex]; + if (_hasAnyOf(target.lineUpLinks.map((link) => link.id), lineUps)) { + return PageCopyResult.alreadyThere; + } + + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null) return PageCopyResult.unavailable; + final updated = strat.copyWith( + pages: [ + for (final page in strat.pages) + if (page.id == target.id) + page.copyWith( + lineUpGraph: LineUpGraph( + origins: [...page.lineUpOrigins, ...lineUps.origins], + landings: [...page.lineUpLandings, ...lineUps.landings], + links: [...page.lineUpLinks, ...lineUps.links], + ), + ) + else + page, + ], + lastEdited: DateTime.now(), + ); + try { + await box.put(updated.id, updated); + } catch (error) { + log('Writing lineups to page ${target.id} failed: $error'); + // Hive can store a write and then fail tidying its file, so what the + // box holds decides whether the lineups are there. + final stored = box.get(strategyId)?.pages.where((p) => p.id == target.id); + final landed = stored != null && + stored.any((page) => page.lineUpLinks.any( + (link) => lineUps.links.any((copied) => copied.id == link.id))); + if (!landed) return PageCopyResult.notSaved; + } + return PageCopyResult.copied; + } + + /// Queues [lineUps] onto cloud page [pageId] as one new lineup group after + /// the page's own. The page is read from the server first, with the work + /// still queued for it laid over: if it has one of the lineups, or a copy + /// of it, nothing is queued, and if it cannot be read, neither. + Future _copyLineUpsToCloudPage({ + required String strategyId, + required String pageId, + required LineUpGraph lineUps, + }) async { + // The lineups at one spot share it, so they make one group, and the + // copy is one write that lands whole or not at all. Its ids are new, so + // the group's id (its smallest lineup id) is one no row has. + final rows = cloudLineupRows(lineUps).rows; + final accountId = ref.read(strategyOpQueueProvider).accountId; + if (rows.length != 1 || + accountId == null || + state.strategyId != strategyId) { + return PageCopyResult.unavailable; + } + final group = rows.single; + // Hive refuses keys over 255 characters, and an op is stored under one + // naming its account, strategy, page and group. Only a lineup imported + // with an unusually long id could get here. + if (DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.lineup(pageId, group.publicId), + ).length > + 255) { + return PageCopyResult.unavailable; + } + + final RemotePageSnapshot targetPage; + try { + targetPage = + await ref.read(convexStrategyRepositoryProvider).fetchPageSnapshot( + strategyPublicId: strategyId, + pagePublicId: pageId, + ); + } catch (error) { + log('Could not read page $pageId to copy lineups onto it: $error'); + return PageCopyResult.unreachable; + } + if (state.strategyId != strategyId) return PageCopyResult.unavailable; + + final onTarget = _cloudLineupGroupsOn(targetPage); + final linksThere = [ + for (final payload in onTarget.values.map((group) => group.payload)) + if (payload != null) + for (final link in cloudPayloadData(payload)['links'] as List? ?? []) + if (link is Map && link['id'] is String) link['id'] as String, + ]; + if (_hasAnyOf(linksThere, lineUps)) return PageCopyResult.alreadyThere; + + // The canvas never draws the group: its page shows it from the server. + final queued = + await ref.read(strategyOpQueueProvider.notifier).enqueueOffCanvas( + LineupAddOp( + opId: const Uuid().v4(), + lineupPublicId: group.publicId, + pagePublicId: pageId, + payload: group.payload, + sortIndex: 1 + + onTarget.values + .map((group) => group.sortIndex) + .fold(-1, max), + ), + flushImmediately: true, + ); + if (!queued) return PageCopyResult.notSaved; + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + return PageCopyResult.copied; + } + + /// The lineup groups on [page] as the server has them, with the work still + /// queued for it laid over, refused work waiting for the user's choice + /// included: by id, their sortIndexes and what they hold. + Map _cloudLineupGroupsOn( + RemotePageSnapshot page, + ) { + final pageId = page.page.publicId; + final groups = { + for (final lineup in page.lineups) + if (!lineup.deleted) + lineup.publicId: ( + sortIndex: lineup.sortIndex, + payload: lineup.payload, + ), + }; + final queue = ref.read(strategyOpQueueProvider); + // Later entries win: a sent op over a paused one, its successor over it. + for (final pendingByKey in [ + queue.attentionByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.pausedByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.queuedByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.inFlightByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.successorByEntityKey.map((k, v) => MapEntry(k, v.pending)), + ]) { + pendingByKey.forEach((key, pending) { + if (key.kind != EntitySyncKeyKind.lineup || key.pageId != pageId) { + return; + } + final id = key.entityId!; + switch (pending.op) { + case LineupDeleteOp(): + groups.remove(id); + case final op: + groups[id] = ( + sortIndex: op.sortIndex ?? groups[id]?.sortIndex ?? 0, + payload: op.payload as CloudPayload? ?? groups[id]?.payload, + ); + } + }); + } + return groups; + } + static StrategyPage? _copyPlacedWidgetBetweenPages({ required String widgetId, required StrategyPage source, diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 762e441c..c326b01e 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -52,7 +52,10 @@ List? buildAbilityContextMenuItems( ); final adjacentPageItems = landingId == null ? buildAdjacentPageCopyMenuItems(ref, ability.id) - : const []; + : buildLineUpAdjacentPageCopyMenuItems(ref, { + for (final link in ref.read(lineUpProvider).linksToLanding(landingId)) + link.id, + }); final lineUpItems = landingId == null ? const [] : buildLandingLineUpMenuItems(ref, landingId, context: context); diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart index ee18d4a2..f4b10dd4 100644 --- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart +++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart @@ -10,15 +10,39 @@ List buildAdjacentPageCopyMenuItems( String widgetId, ) { if (widgetId.isEmpty) return const []; + final notifier = ref.read(strategyProvider.notifier); + return _copyMenuItems( + notifier.copyDirectionsForPlacedWidget(widgetId), + (direction) => notifier.copyPlacedWidgetToAdjacentPage( + widgetId: widgetId, + direction: direction, + ), + ); +} +/// The same items for the lineups [linkIds]: every lineup at the spot the +/// user right-clicked, copied together with the spots they aim at. +List buildLineUpAdjacentPageCopyMenuItems( + WidgetRef ref, + Set linkIds, +) { + if (linkIds.isEmpty) return const []; final notifier = ref.read(strategyProvider.notifier); - final directions = notifier.copyDirectionsForPlacedWidget(widgetId); + return _copyMenuItems( + notifier.copyDirectionsForLineUps(linkIds), + (direction) => notifier.copyLineUpsToAdjacentPage( + linkIds: linkIds, + direction: direction, + ), + ); +} +List _copyMenuItems( + List directions, + Future Function(PageTransitionDirection direction) copyTo, +) { Future copy(PageTransitionDirection direction) async { - final result = await notifier.copyPlacedWidgetToAdjacentPage( - widgetId: widgetId, - direction: direction, - ); + final result = await copyTo(direction); final page = direction == PageTransitionDirection.forward ? 'next page' : 'previous page'; diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index 7ead4030..d6486bcf 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -255,10 +255,18 @@ class AgentWidget extends ConsumerWidget { ), isAttack: mapState.isAttack, ); - final adjacentPageCopyItems = - canInteract && lineUpId == null && placedAgentNode != null - ? buildAdjacentPageCopyMenuItems(ref, placedAgentNode.id) - : const []; + final adjacentPageCopyItems = !canInteract + ? const [] + : lineUpId != null + ? buildLineUpAdjacentPageCopyMenuItems(ref, { + for (final link in ref + .read(lineUpProvider.notifier) + .linksFromOrigin(lineUpId!)) + link.id, + }) + : placedAgentNode != null + ? buildAdjacentPageCopyMenuItems(ref, placedAgentNode.id) + : const []; final hasContextMenuItemsBelow = canInteract && (lineUpId != null || (plainAgent != null && plainAgent.id.isNotEmpty) || diff --git a/test/strategy_page_semantics_test.dart b/test/strategy_page_semantics_test.dart index ecaed9b2..bda9727c 100644 --- a/test/strategy_page_semantics_test.dart +++ b/test/strategy_page_semantics_test.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; @@ -328,6 +329,235 @@ void main() { expect(savedTarget.agentData.single.position, const Offset(300, 400)); }, ); + + group('copying lineups to the next or previous page', () { + // Sova stands at two spots. Two lineups from them meet at one landing; + // a third goes from the first spot to another landing. + LineUpGraph sovaLineUps({String prefix = ''}) { + final ability = AgentData.agents[AgentType.sova]!.abilities.first; + LineUpOrigin origin(String id, Offset at) => LineUpOrigin( + id: '$prefix$id', + agent: PlacedAgent( + id: 'agent-$prefix$id', + type: AgentType.sova, + position: at, + lineUpID: '$prefix$id', + ), + ); + LineUpLanding landing(String id, Offset at) => LineUpLanding( + id: '$prefix$id', + ability: PlacedAbility( + id: 'ability-$prefix$id', + data: ability, + position: at, + lineUpID: '$prefix$id', + ), + ); + return LineUpGraph( + origins: [ + origin('stand-1', const Offset(10, 10)), + origin('stand-2', const Offset(20, 10)), + ], + landings: [ + landing('land-1', const Offset(300, 300)), + landing('land-2', const Offset(400, 300)), + ], + links: [ + LineUpLink( + id: '${prefix}bolt-a', + originId: '${prefix}stand-1', + landingId: '${prefix}land-1', + name: 'Bolt A', + notes: 'Jump throw', + images: [SimpleImageData(id: 'shot-a', fileExtension: '.png')], + ), + LineUpLink( + id: '${prefix}bolt-b', + originId: '${prefix}stand-2', + landingId: '${prefix}land-1', + name: 'Bolt B', + ), + LineUpLink( + id: '${prefix}recon', + originId: '${prefix}stand-1', + landingId: '${prefix}land-2', + name: 'Recon', + ), + ], + ); + } + + Future open({ + LineUpGraph nextPageLineUps = LineUpGraph.empty, + }) async { + final pages = [ + _page(id: 'page-1', name: 'Page 1', sortIndex: 0), + _page( + id: 'page-2', + name: 'Page 2', + sortIndex: 1, + lineUps: sovaLineUps(), + ), + _page( + id: 'page-3', + name: 'Page 3', + sortIndex: 2, + lineUps: nextPageLineUps, + ), + ]; + final strategy = _strategy(pages); + await strategyBox.put(strategy.id, strategy); + final container = ProviderContainer(); + addTearDown(container.dispose); + _activatePage(container, strategy, pages[1]); + return container; + } + + LineUpGraph savedLineUps(String pageId) => strategyBox + .get('strategy-id')! + .pages + .singleWhere((page) => page.id == pageId) + .lineUpGraph; + + Future copyBolts( + ProviderContainer container, + PageTransitionDirection direction, + ) => + container.read(strategyProvider.notifier).copyLineUpsToAdjacentPage( + linkIds: {'bolt-a', 'bolt-b'}, + direction: direction, + ); + + test( + 'lineups at a spot copy to the next page with ids that carry their ' + 'originals', () async { + final container = await open(); + expect( + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'bolt-a', 'bolt-b'}), + [PageTransitionDirection.backward, PageTransitionDirection.forward], + ); + + expect( + await copyBolts(container, PageTransitionDirection.forward), + PageCopyResult.copied, + ); + + // The next page has both lineups, still meeting at one landing, with + // their names, notes and screenshots, where they were. + final there = savedLineUps('page-3'); + expect(there.links.map((link) => link.name), ['Bolt A', 'Bolt B']); + expect( + there.links.map((link) => pageCopyRoot(link.id)), + ['bolt-a', 'bolt-b'], + ); + expect( + there.origins.map((origin) => pageCopyRoot(origin.id)), + ['stand-1', 'stand-2'], + ); + expect(pageCopyRoot(there.landings.single.id), 'land-1'); + expect( + there.links.map((link) => link.landingId).toSet(), + {there.landings.single.id}, + ); + for (final origin in there.origins) { + expect(origin.id, isNot(pageCopyRoot(origin.id))); + expect(origin.agent.lineUpID, origin.id); + } + expect(there.landings.single.ability.lineUpID, there.landings.single.id); + expect(there.landings.single.ability.position, const Offset(300, 300)); + final boltA = there.links.first; + expect(boltA.notes, 'Jump throw'); + expect(boltA.images.single.id, 'shot-a'); + expect( + there.origins + .singleWhere((origin) => origin.id == boltA.originId) + .agent + .position, + const Offset(10, 10), + ); + // This page is left as it was. + expect(container.read(lineUpProvider).links, hasLength(3)); + }); + + test( + 'a page that already has the lineups, or copies of them, is not ' + 'offered and gets no others', () async { + final container = await open(); + final notifier = container.read(strategyProvider.notifier); + await copyBolts(container, PageTransitionDirection.forward); + + expect(notifier.copyDirectionsForLineUps({'bolt-a', 'bolt-b'}), [ + PageTransitionDirection.backward, + ]); + expect( + await copyBolts(container, PageTransitionDirection.forward), + PageCopyResult.alreadyThere, + ); + expect(savedLineUps('page-3').links, hasLength(2)); + // Another lineup can still go. + expect(notifier.copyDirectionsForLineUps({'recon'}), [ + PageTransitionDirection.backward, + PageTransitionDirection.forward, + ]); + }); + + test( + 'a throw spot whose other lineup is on the next page does not offer ' + 'it, and that lineup still can go from its own landing', () async { + final container = await open(); + final notifier = container.read(strategyProvider.notifier); + await notifier.copyLineUpsToAdjacentPage( + linkIds: {'recon'}, + direction: PageTransitionDirection.forward, + ); + + // Stand 1 throws Bolt A and Recon; Recon is already there. + expect(notifier.copyDirectionsForLineUps({'bolt-a', 'recon'}), [ + PageTransitionDirection.backward, + ]); + expect(notifier.copyDirectionsForLineUps({'bolt-a'}), [ + PageTransitionDirection.backward, + PageTransitionDirection.forward, + ]); + }); + + test('a copy adds to the lineups already on the page', () async { + final container = + await open(nextPageLineUps: sovaLineUps(prefix: 'other-')); + + expect( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( + linkIds: {'recon'}, + direction: PageTransitionDirection.forward, + ), + PageCopyResult.copied, + ); + + final there = savedLineUps('page-3'); + expect(there.links.map((link) => link.name), + ['Bolt A', 'Bolt B', 'Recon', 'Recon']); + final copy = there.links.last; + expect(pageCopyRoot(copy.id), 'recon'); + expect( + there.origins + .singleWhere((origin) => origin.id == copy.originId) + .agent + .position, + const Offset(10, 10), + ); + expect( + there.landings + .singleWhere((landing) => landing.id == copy.landingId) + .ability + .position, + const Offset(400, 300), + ); + }); + }); } StrategyData _strategy(List pages) { @@ -353,6 +583,7 @@ StrategyPage _page({ List text = const [], List images = const [], List utilities = const [], + LineUpGraph lineUps = LineUpGraph.empty, }) { return StrategyPage( id: id, @@ -367,6 +598,9 @@ StrategyPage _page({ textData: text, imageData: images, utilityData: utilities, + lineUpOrigins: lineUps.origins, + lineUpLandings: lineUps.landings, + lineUpLinks: lineUps.links, isAttack: true, settings: StrategySettings(), ); diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 282c8adb..c0ebaba0 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6638,6 +6638,7 @@ void main() { Completer? hold, List otherPages = const [], Map> otherElements = const {}, + Map> otherLineups = const {}, String? themeProfileId, }) async { final others = { @@ -6646,6 +6647,7 @@ void main() { other, settings: settingsFor(1), elements: otherElements[other.publicId] ?? const [], + lineups: otherLineups[other.publicId] ?? const [], ), }; var shown = page.publicId; @@ -6887,6 +6889,95 @@ void main() { expect(server.element(copyId).deleted, isFalse); }); + test( + 'lineups copied to another page show there when the user gets there ' + 'before they land', () async { + final source = _page('page-1', 0); + final target = _page('page-2', 1); + server = _FakeServer(target.publicId); + final (container, batches) = await openOnRealQueue( + target, + otherPages: [source], + otherLineups: { + source.publicId: [_lineup(source.publicId, 'a', linkName: 'Smoke')], + }, + ); + readPage(source.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(container.read(lineUpProvider).links.map((link) => link.id), + ['link-a']); + + // The copy is sent, and its answer held back. + final hold = repository.hold = Completer(); + expect( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( + linkIds: {'link-a'}, + direction: PageTransitionDirection.forward, + ), + PageCopyResult.copied, + ); + await _until(() => batches.isNotEmpty); + final groupId = (batches.single.single as LineupAddOp).lineupPublicId; + + // The user opens page 2 before the copy lands, then it lands. + final session = container.read(strategyPageSessionProvider.notifier); + await session.setActivePage(target.publicId); + readPage(target.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + // A press off the canvas (the sidebar) holds the page's updates back + // while the copy lands and the page's read shows it. The user places + // an agent meanwhile. + final pointers = container.read(editorPointersProvider.notifier)..down(1); + hold.complete(); + final copyKey = EntitySyncKey.lineup(target.publicId, groupId); + await _until(() => !container.read(strategyOpQueueProvider).pending.any( + (pending) => EntitySyncKey.forStrategyOp(pending.op) == copyKey)); + showServer(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(container.read(lineUpProvider).links, isEmpty); + container.read(agentProvider.notifier).addAgent(PlacedAgent( + id: 'agent-1', + type: AgentType.jett, + position: const Offset(40, 40), + )); + for (var i = 0; i < 10; i++) { + await _settle(); + } + pointers.release(1); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until( + () => container.read(strategyOpQueueProvider).pending.isEmpty); + showServer(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(container.read(lineUpProvider).links.map((link) => link.name), + ['Smoke']); + // Page 2 sent the copy and the agent, and no delete of the copy. + expect( + [ + for (final batch in batches) + for (final op in batch) + if (EntitySyncKey.forStrategyOp(op)?.pageId == target.publicId) + (op.kind, op.entityPublicId), + ], + [(StrategyOpKind.add, groupId), (StrategyOpKind.add, 'agent-1')], + ); + }); + test( 'Keep mine restores a lineup a teammate deleted while the user ' 'edited it', () async { @@ -11161,6 +11252,225 @@ void main() { ); }); }); + + group('copying lineups to the next or previous cloud page', () { + final pages = [_page('page-1', 0), _page('page-2', 1), _page('page-3', 2)]; + + // On page 2, lineups a and b meet at one landing; solo is on its own. + final shared = _groupRow( + 'page-2', + 'link-a', + origins: [_originJson('stand-a'), _originJson('stand-b')], + landings: [_landingJson('land-ab')], + links: [ + _linkJson('link-a', + originId: 'stand-a', landingId: 'land-ab', name: 'Bolt A'), + _linkJson('link-b', + originId: 'stand-b', landingId: 'land-ab', name: 'Bolt B'), + ], + ); + + /// Opens the strategy on page 2; the repository reads page 3 with + /// [nextPage] on it. + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier, _PageReader)> open( + {List? nextPage}) async { + final onScreen = _pageSnapshot( + pages[1], + lineups: [shared, _lineup('page-2', 'solo', sortIndex: 1)], + ); + final queue = _FakeStrategyOpQueueNotifier(); + final reader = _PageReader({ + 'page-3': _pageSnapshot( + pages[2], + lineups: nextPage ?? [_lineup('page-3', 'there', sortIndex: 6)], + ), + }); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier( + _editorSnapshot(pages: pages, activePage: onScreen), + ), + queue: queue, + repository: reader, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + preferredPageId: 'page-2', + ); + return (container, queue, reader); + } + + Iterable adds(ProviderContainer container) => container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .values + .map((intent) => intent.pending.op) + .whereType() + .where((op) => op.pagePublicId == 'page-3'); + + Set linksHere(ProviderContainer container) => + {for (final link in container.read(lineUpProvider).links) link.id}; + + Future copyBolts(ProviderContainer container) => + container.read(strategyProvider.notifier).copyLineUpsToAdjacentPage( + linkIds: {'link-a', 'link-b'}, + direction: PageTransitionDirection.forward, + ); + + test('offers both neighbours', () async { + final (container, _, _) = await open(); + expect( + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'link-a', 'link-b'}), + [PageTransitionDirection.forward, PageTransitionDirection.backward], + ); + }); + + test( + 'a copy is sent as one group after the page\'s own, with ids that ' + 'carry the originals\'', () async { + final (container, queue, _) = await open(); + + expect(await copyBolts(container), PageCopyResult.copied); + + final add = adds(container).single; + expect(add.sortIndex, 7); + final data = cloudPayloadData(add.payload); + expect(data['id'], add.lineupPublicId); + final links = _entries(data, 'links'); + expect(links.map((link) => link['name']), ['Bolt A', 'Bolt B']); + expect( + links.map((link) => pageCopyRoot(link['id'] as String)), + ['link-a', 'link-b'], + ); + expect( + _entries(data, 'origins').map((o) => pageCopyRoot(o['id'] as String)), + ['stand-a', 'stand-b'], + ); + final landing = _entries(data, 'landings').single; + expect(pageCopyRoot(landing['id'] as String), 'land-ab'); + expect(links.map((link) => link['landingId']).toSet(), {landing['id']}); + expect(add.lineupPublicId, isNot('link-a')); + expect(queue.flushNowCount, greaterThan(0)); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + }); + + test('a page that already has a copy of the lineups gets no other', + () async { + final copyId = 'link-b~cp1~${const Uuid().v4()}'; + final (container, _, _) = await open(nextPage: [ + _groupRow( + 'page-3', + copyId, + origins: [_originJson('stand-copy')], + landings: [_landingJson('land-copy')], + links: [ + _linkJson(copyId, originId: 'stand-copy', landingId: 'land-copy'), + ], + ), + ]); + + expect(await copyBolts(container), PageCopyResult.alreadyThere); + expect(adds(container), isEmpty); + }); + + test('copying twice at once sends one copy', () async { + final (container, queue, _) = await open(); + + // The first copy's write to the outbox takes a while. + final write = queue.writeGate = Completer(); + final copies = Future.wait([copyBolts(container), copyBolts(container)]); + await _settle(); + queue.writeGate = null; + write.complete(); + + expect( + await copies, [PageCopyResult.copied, PageCopyResult.alreadyThere]); + expect(adds(container), hasLength(1)); + }); + + test('a lineup whose copy could not be stored to send is not offered', + () async { + final (container, _, _) = await open(); + final long = 'x' * 200; + container.read(lineUpProvider.notifier).mergeRemote( + lineUpGraphFromCloudRows([ + CloudLineupRow.remote(_lineup('page-2', long)), + ]).graph, + ); + + expect( + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'link-$long'}), + isEmpty, + ); + await _settle(); + }); + + test('a page that cannot be read gets nothing', () async { + final (container, _, reader) = await open(); + reader.fails = true; + + expect(await copyBolts(container), PageCopyResult.unreachable); + expect(adds(container), isEmpty); + }); + + test('a copy this device cannot store is not reported as copied', () async { + final (container, queue, _) = await open(); + queue.offCanvasStoreFails = true; + + expect(await copyBolts(container), PageCopyResult.notSaved); + expect(container.read(strategySaveStateProvider).hasPendingCloudSync, + isFalse); + }); + + test('lineups that share no spot are not copied, so none goes half-way', + () async { + final (container, _, _) = await open(); + + expect( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( + linkIds: {'link-a', 'link-solo'}, + direction: PageTransitionDirection.forward, + ), + PageCopyResult.unavailable, + ); + expect(adds(container), isEmpty); + }); + + test('the lineups are copied as they were when the user asked', () async { + final (container, _, reader) = await open(); + final read = reader.gate = Completer(); + + final copied = copyBolts(container); + // While page 3 is read, a teammate's edit renames Bolt A. + final now = container.read(lineUpProvider); + container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( + origins: now.origins, + landings: now.landings, + links: [ + for (final link in now.links) + link.id == 'link-a' ? link.copyWith(name: 'Renamed') : link, + ], + )); + read.complete(); + + expect(await copied, PageCopyResult.copied); + expect( + _entries(cloudPayloadData(adds(container).single.payload), 'links') + .map((link) => link['name']), + ['Bolt A', 'Bolt B'], + ); + await _settle(); + }); + }); } /// Reads cloud pages for a copy to another page.