From 3e965965cbcd91f274d23ce7e2a65ff51b73625f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 11 Nov 2026 23:25:53 -0500 Subject: [PATCH] Run random schedules of edits, lost answers and teammate changes One user edits a page and its strategy's map and theme while the network fails under them and a teammate edits too, in an order drawn from a seed. Once everything is sent and read back, the canvas must show what the server holds, the user's last edit to each thing must be there unless they were asked about it, and a teammate's edit to something the user left alone must still be there. 200 seeds run by default. SYNC_SEEDS runs more, or replays named seeds. --- test/strategy_page_session_provider_test.dart | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 4b94f1cc..91fcc378 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'dart:convert'; import 'dart:io'; @@ -698,6 +699,9 @@ class _RecordingMediaQueue extends CloudMediaUploadQueueNotifier { /// Whether this device can reach the server, for tests that go offline. final _online = StateProvider((ref) => true); +/// A ref, for functions that read providers through one. +final _refProvider = Provider((ref) => ref); + Future _settle() async { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); @@ -10183,6 +10187,287 @@ void main() { }); }); + // One user edits a page and its strategy's map and theme while the + // network fails under them and a teammate edits too, in an order drawn + // from a seed. Whatever the order, once everything is sent and read back + // the canvas shows what the server holds, the user's last edit to each + // thing is there unless they were asked about it, and a teammate's edit + // to something the user left alone is still there. + group('random schedules', () { + const defaultTheme = 'immutable-default-map-theme'; + const valorant = MapThemeProfilesProvider.immutableValorantProfileId; + const maps = { + 'ascent': MapValue.ascent, + 'bind': MapValue.bind, + 'haven': MapValue.haven, + 'split': MapValue.split, + }; + const strategyKey = EntitySyncKey.strategy(); + + Future settle([int turns = 6]) async { + for (var i = 0; i < turns; i++) { + await _settle(); + } + } + + Future run(int seed, {int steps = 30}) async { + final random = Random(seed); + final log = []; + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, elements: [ + _textElement(page.publicId, 'a', 'alpha', worldSized: true), + _textElement(page.publicId, 'b', 'bravo', + sortIndex: 1, worldSized: true), + _textElement(page.publicId, 'c', 'charlie', + sortIndex: 2, worldSized: true), + ]) + ..mergesByField = true; + final (container, sentBatches) = + await openOnRealQueue(page, themeProfileId: defaultTheme); + await settle(10); + + StrategyOpQueueState queue() => container.read(strategyOpQueueProvider); + final texts = container.read(textProvider.notifier); + PlacedText? onCanvas(String id) => container + .read(textProvider) + .where((text) => text.id == id) + .firstOrNull; + Map onServer(String id) => + cloudPayloadData(server.element(id).payload); + + // What the user last set, by thing. + final userWords = {}; + final userPlaces = {}; + var teammateWords = 'charlie'; + var pointerDown = false; + var edit = 0; + + for (var step = 0; step < steps; step++) { + switch (random.nextInt(12)) { + case 0: + final name = maps.keys.elementAt(random.nextInt(maps.length)); + if (container.read(mapProvider).currentMap == maps[name]) break; + // Set to what this device last read from the server, there is + // nothing to send: a teammate's change it has not read stands. + final read = container + .read(remoteEditorSnapshotProvider) + .requireValue! + .header; + final known = knownStrategyHeader( + container.read(_refProvider), + read, + ).mapData; + log.add(name == known + ? 'user sets the map back to $name' + : 'user map $name'); + container.read(mapProvider.notifier).updateMap(maps[name]!); + case 1: + final theme = random.nextBool() ? valorant : defaultTheme; + log.add('user theme $theme'); + container.read(strategyThemeProvider.notifier).setProfile(theme); + case 2: + final id = random.nextBool() ? 'a' : 'b'; + if (onCanvas(id) == null) break; + final to = Offset(100.0 + random.nextInt(8) * 25, + 100.0 + random.nextInt(8) * 25); + log.add('user moves $id to $to'); + texts.updatePosition(to, id); + userPlaces[id] = to; + case 3: + final id = random.nextBool() ? 'a' : 'b'; + if (onCanvas(id) == null) break; + final words = '$id-${edit++}'; + log.add('user retypes $id as $words'); + texts.commitText(id, words); + userWords[id] = words; + case 4: + final name = maps.keys.elementAt(random.nextInt(maps.length)); + if (name == server.strategyMap) break; + log.add('teammate map $name'); + server.teammateSetsMap(name); + case 5: + teammateWords = 'theirs-${edit++}'; + log.add('teammate retypes c as $teammateWords'); + final words = teammateWords; + server.teammateEditElement('c', (data) => data['text'] = words); + case 6: + log.add('next answer is lost'); + repository.loseNextAnswer = () {}; + case 7: + final online = !container.read(_online); + log.add(online ? 'online' : 'offline'); + container.read(_online.notifier).state = online; + case 8: + log.add('the live read arrives'); + showServer(); + case 9: + log.add(pointerDown ? 'pointer up' : 'pointer down'); + final pointers = container.read(editorPointersProvider.notifier); + pointerDown ? pointers.release(1) : pointers.down(1); + pointerDown = !pointerDown; + case 10: + log.add('save'); + unawaited(container + .read(strategyProvider.notifier) + .forceSaveNow('cloud-strategy')); + case 11: + log.add('send'); + unawaited( + container.read(strategyOpQueueProvider.notifier).flushNow()); + } + await settle(1 + random.nextInt(4)); + } + + // Everything settles: online, nothing held, sent and read back. + log.add('settle'); + container.read(_online.notifier).state = true; + repository.loseNextAnswer = null; + if (pointerDown) { + container.read(editorPointersProvider.notifier).release(1); + } + // Work the user is asked about waits for their answer, and so does + // an edit made behind it; the canvas stays unsaved meanwhile. + bool asking() => queue().attentionByEntityKey.isNotEmpty; + bool waiting() => + queue().queuedByEntityKey.isNotEmpty || + queue().inFlightByEntityKey.isNotEmpty || + queue() + .successorByEntityKey + .keys + .any((key) => !queue().attentionByEntityKey.containsKey(key)) || + queue().isFlushing || + container.read(strategySaveStateProvider).isSaving || + (container.read(strategySaveStateProvider).isDirty && !asking()); + for (var round = 0; round < 40; round++) { + await container + .read(strategyProvider.notifier) + .forceSaveNow('cloud-strategy'); + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await settle(); + log.add(' round $round before read: a=${onCanvas('a')?.text} ' + 'b=${onCanvas('b')?.text} pending ' + '${queue().pending.map((p) => '${p.op.runtimeType}:${p.op.entityPublicId}:${p.op.expectedRevision}').toList()} ' + 'attention ${queue().attentionByEntityKey.keys.length} ' + 'server a=${onServer('a')['text']} b=${onServer('b')['text']} ' + 'conflicts ${container.read(strategyConflictProvider).length} ' + 'dirty ${container.read(strategySaveStateProvider).isDirty}'); + showServer(); + await settle(); + log.add(' round $round after read: a=${onCanvas('a')?.text} ' + 'b=${onCanvas('b')?.text}'); + if (!waiting() && round > 1) break; + } + // Asked about work the server refused, the user takes the cloud's + // version, and the canvas catches up with everything since. + final asked = queue().attentionByEntityKey.keys.toSet(); + if (asked.isNotEmpty) { + log.add('use cloud for $asked'); + await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + for (var round = 0; round < 10; round++) { + await container + .read(strategyProvider.notifier) + .forceSaveNow('cloud-strategy'); + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await settle(); + showServer(); + await settle(); + } + } + String told(StrategyOp op) { + final payload = op.payload; + final data = payload is Map ? payload['data'] : null; + final words = data is Map ? data['text'] : null; + return '${op.runtimeType}:${op.entityPublicId}:' + '${op.expectedRevision}:$words'; + } + + log.add('sent: ${[ + for (final batch in sentBatches) batch.map(told).toList() + ]}'); + final why = 'seed $seed:\n ${log.join('\n ')}'; + final save = container.read(strategySaveStateProvider); + expect(waiting(), isFalse, + reason: + 'Work still waits: queued ${queue().queuedByEntityKey.keys} ' + 'inFlight ${queue().inFlightByEntityKey.keys} ' + 'successor ${queue().successorByEntityKey.keys} ' + 'paused ${queue().pausedByEntityKey.keys} ' + 'attention ${queue().attentionByEntityKey.keys} ' + 'flushing ${queue().isFlushing} dirty ${save.isDirty} ' + 'saving ${save.isSaving} error ${queue().lastError}. $why'); + + expect(asking(), isFalse, reason: 'The user is still asked. $why'); + + // The canvas shows what the server holds. + expect( + Maps.mapNames[container.read(mapProvider).currentMap], + server.strategyMap, + reason: 'The canvas and the server show different maps. $why', + ); + expect( + container.read(strategyThemeProvider).profileId, + server.strategyThemeProfileId, + reason: 'The canvas and the server show different themes. $why', + ); + // The last change to the map is the one kept, unless the user was + // asked and took the cloud's. + final lastMap = + log.lastWhere((line) => line.contains(' map '), orElse: () => ''); + // A map set back to what this device last read sends nothing, so a + // teammate's change it had not read stands; with no teammate + // changing the map, the user's last choice is the one kept. + final teammateChangedMap = + log.any((line) => line.startsWith('teammate map')); + if (!asked.contains(strategyKey) && + lastMap.isNotEmpty && + (!lastMap.contains('back') || !teammateChangedMap)) { + expect(server.strategyMap, lastMap.split(' ').last, + reason: 'The last map change is not the one kept. $why'); + } + for (final id in ['a', 'b']) { + final canvas = onCanvas(id)!; + final data = onServer(id); + expect(data['text'], canvas.text, + reason: 'The server has other words for $id. $why'); + expect( + Offset((data['position'] as Map)['dx'] as double, + (data['position'] as Map)['dy'] as double), + canvas.position, + reason: 'The server has $id somewhere else. $why', + ); + // Nobody else touches these: the user's last edit is there, unless + // they were asked and took the cloud's. + if (asked.contains(EntitySyncKey.element(page.publicId, id))) { + continue; + } + if (userWords[id] case final words?) { + expect(canvas.text, words, + reason: "The user's last words for $id are gone. $why"); + } + if (userPlaces[id] case final place?) { + expect(canvas.position, place, + reason: "The user's last place for $id is gone. $why"); + } + } + expect(onServer('c')['text'], teammateWords, + reason: "The teammate's words for c were written over. $why"); + expect(onCanvas('c')?.text, teammateWords, + reason: "The canvas never drew the teammate's words. $why"); + } + + // 200 schedules by default. SYNC_SEEDS runs others: a count + // ("5000"), or the seeds themselves ("1907,2673") to replay failures. + final wanted = Platform.environment['SYNC_SEEDS'] ?? '200'; + final seeds = wanted.contains(',') + ? wanted.split(',').map(int.parse) + : Iterable.generate(int.parse(wanted)); + for (final seed in seeds) { + test('seed $seed', () => run(seed)); + } + }); + test( "an edit made offline lands when a teammate's edit to another item " 'arrived first', () async {