From 1f2a8d2203179d450dcceed94d9fc9fa43ffa7fe Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 12:38:43 -0400 Subject: [PATCH] feat(playground): add the AI deck-editing tool layer Stage 1 of 2 for the "edit this deck with AI" session: the tool layer only. The conversation UI (screen, session controller, routes, catalog, profile) follows in a dedicated PR once it is rebuilt on the current AI subsystem; until then this layer is exercised by its tests. - DeckMarkdownCodec: one owner for editor Markdown <-> canonical slides. MemoryDeckLoader decodes through it, so the live preview and the deck tools can never parse the same document differently. - DeckStore + EditorDeckStore: canonical document operations bridging the editor document and the rendered preview, with a reactive barrier that completes only after the preview observes each write. - DeckToolsService: five slide operations plus readSlide and an updateStyle seam, serialized through one error-recovering FIFO queue. - DeckToolsAdapter: six Dartantic tools (getDeck, createSlide, updateSlide, deleteSlide, moveSlide, readSlide) with Ack-validated arguments and stable wire error codes. updateStyle is not registered yet: its schema and applier targeted the pre-#102 style contract and return with the UI PR on the brand/theme contract. - DeckSlideReader: captures one rendered slide as a PNG thumbnail from the live configuration list. Error translation sites use Error.throwWithStackTrace so typed tool errors keep the original stack trace. --- .../data/data_sources/memory_deck_loader.dart | 24 +- .../data/mappers/deck_markdown_codec.dart | 37 +++ .../ai/deck_editor/ai/deck_tool_schemas.dart | 62 +++++ .../ai/deck_editor/ai/deck_tools_adapter.dart | 111 ++++++++ .../deck_editor/data/deck_slide_reader.dart | 94 +++++++ .../deck_editor/data/editor_deck_store.dart | 160 +++++++++++ .../ai/deck_editor/domain/deck_store.dart | 16 ++ .../deck_editor/domain/deck_tool_error.dart | 26 ++ .../domain/deck_tools_service.dart | 194 +++++++++++++ .../mappers/deck_markdown_codec_test.dart | 110 ++++++++ .../ai/deck_tool_schemas_test.dart | 77 ++++++ .../ai/deck_tools_adapter_test.dart | 155 +++++++++++ .../data/deck_slide_reader_test.dart | 144 ++++++++++ .../data/editor_deck_store_test.dart | 181 ++++++++++++ .../domain/deck_tools_service_test.dart | 260 ++++++++++++++++++ 15 files changed, 1637 insertions(+), 14 deletions(-) create mode 100644 packages/playground/lib/core/data/mappers/deck_markdown_codec.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/ai/deck_tool_schemas.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/ai/deck_tools_adapter.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/data/deck_slide_reader.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/data/editor_deck_store.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/domain/deck_store.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/domain/deck_tool_error.dart create mode 100644 packages/playground/lib/features/ai/deck_editor/domain/deck_tools_service.dart create mode 100644 packages/playground/test/core/data/mappers/deck_markdown_codec_test.dart create mode 100644 packages/playground/test/features/ai/deck_editor/ai/deck_tool_schemas_test.dart create mode 100644 packages/playground/test/features/ai/deck_editor/ai/deck_tools_adapter_test.dart create mode 100644 packages/playground/test/features/ai/deck_editor/data/deck_slide_reader_test.dart create mode 100644 packages/playground/test/features/ai/deck_editor/data/editor_deck_store_test.dart create mode 100644 packages/playground/test/features/ai/deck_editor/domain/deck_tools_service_test.dart diff --git a/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart b/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart index 5e980af8..2fa7b515 100644 --- a/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart +++ b/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart @@ -1,39 +1,35 @@ import 'dart:async'; -import 'package:superdeck_builder/superdeck_builder.dart'; import 'package:superdeck_core/superdeck_core.dart'; +import '../mappers/deck_markdown_codec.dart'; + /// A [DeckLoader] that parses markdown in-memory for live preview. class MemoryDeckLoader extends DeckLoader { + /// Shared with the deck-edit tools so the preview and the tools can never + /// decode the same markdown differently. + static const _codec = DeckMarkdownCodec(); + final _controller = StreamController.broadcast(); bool _disposed = false; String? _lastLoadedMarkdown; - @override - Stream load() => _controller.stream; - /// Parses the given markdown and emits a [SlidesLoadedEvent]. void updateMarkdown(String markdown) { if (_disposed || markdown == _lastLoadedMarkdown) return; _lastLoadedMarkdown = markdown; try { - final rawSlides = const MarkdownParser().parse(markdown); - final slides = [ - for (final raw in rawSlides) - Slide( - key: raw.key, - options: .parse(raw.frontmatter), - sections: const SectionParser().parse(raw.content), - comments: const CommentParser().parse(raw.content), - ), - ]; + final slides = _codec.decode(markdown); _controller.add(SlidesLoadedEvent(slides)); } catch (e) { _controller.add(SlidesErrorEvent('$e', error: e)); } } + @override + Stream load() => _controller.stream; + @override Future reload() async {} diff --git a/packages/playground/lib/core/data/mappers/deck_markdown_codec.dart b/packages/playground/lib/core/data/mappers/deck_markdown_codec.dart new file mode 100644 index 00000000..ecf22158 --- /dev/null +++ b/packages/playground/lib/core/data/mappers/deck_markdown_codec.dart @@ -0,0 +1,37 @@ +import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +/// Converts between an editor Markdown document and the canonical slide model. +class DeckMarkdownCodec { + const DeckMarkdownCodec(); + + /// Parses [markdown] with the same pipeline used by the live preview loader. + List decode(String markdown) { + try { + final rawSlides = const MarkdownParser().parse(markdown); + + return [ + for (final raw in rawSlides) + Slide( + key: raw.key, + options: SlideOptions.parse(raw.frontmatter), + sections: const SectionParser().parse(raw.content), + comments: const CommentParser().parse(raw.content), + ), + ]; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + DeckFormatException( + 'Failed to decode deck Markdown: $error', + markdown, + null, + ), + stackTrace, + ); + } + } + + /// Serializes [slides] to canonical SuperDeck Markdown. + String encode(List slides) => + const SlideSerializer().serialize(slides); +} diff --git a/packages/playground/lib/features/ai/deck_editor/ai/deck_tool_schemas.dart b/packages/playground/lib/features/ai/deck_editor/ai/deck_tool_schemas.dart new file mode 100644 index 00000000..dd1796b6 --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/ai/deck_tool_schemas.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:superdeck_core/superdeck_core.dart'; + +/// Strict slide contract exposed to the model. Runtime-only keys are forbidden. +final keylessSlideSchema = Ack.object({ + 'options': slideOptionsSchema.optional(), + 'comments': Ack.list(Ack.string()).optional(), + 'sections': Ack.list(sectionBlockSchema), +}, additionalProperties: false); + +final getDeckArgumentsSchema = Ack.object({}, additionalProperties: false); + +final createSlideArgumentsSchema = Ack.object({ + 'slide': keylessSlideSchema, + 'atIndex': Ack.integer().optional(), +}, additionalProperties: false); + +final updateSlideArgumentsSchema = Ack.object({ + 'index': Ack.integer(), + 'slide': keylessSlideSchema, +}, additionalProperties: false); + +final deleteSlideArgumentsSchema = Ack.object({ + 'index': Ack.integer(), +}, additionalProperties: false); + +final moveSlideArgumentsSchema = Ack.object({ + 'fromIndex': Ack.integer(), + 'toIndex': Ack.integer(), +}, additionalProperties: false); + +final readSlideArgumentsSchema = Ack.object({ + 'index': Ack.integer(), +}, additionalProperties: false); + +/// Validates and constructs a core slide with a private transient key. +Slide parseKeylessSlide(Object? value) { + final validated = keylessSlideSchema.parse(value)!; + final map = Map.of(validated); + + return Slide.fromMap({ + 'key': 'tool_${generateValueHash(jsonEncode(map))}', + ...map, + }); +} + +/// Serializes [slide] without exposing its runtime key. +Map slideToKeylessMap(Slide slide) { + return Map.from(slide.toMap())..remove('key'); +} + +/// Returns the style-free, key-free deck summary used in tool results. +Map deckSnapshot(List slides) { + return { + 'totalSlides': slides.length, + 'slides': [ + for (final (index, slide) in slides.indexed) + {'index': index, 'title': ?slide.options?.title}, + ], + }; +} diff --git a/packages/playground/lib/features/ai/deck_editor/ai/deck_tools_adapter.dart b/packages/playground/lib/features/ai/deck_editor/ai/deck_tools_adapter.dart new file mode 100644 index 00000000..ce310368 --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/ai/deck_tools_adapter.dart @@ -0,0 +1,111 @@ +import 'package:ack/ack.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; + +import '../domain/deck_tool_error.dart'; +import '../domain/deck_tools_service.dart'; +import 'deck_tool_schemas.dart'; + +/// Adapts the typed deck service to the current Dartantic tool API. +final class DeckToolsAdapter { + late final List>> tools; + + final DeckToolsService _service; + + DeckToolsAdapter(DeckToolsService service) : _service = service { + tools = List>>.unmodifiable([ + _tool( + name: 'getDeck', + description: 'Return the current slide count and slide titles.', + schema: getDeckArgumentsSchema, + call: (_) => _service.getDeck(), + ), + _tool( + name: 'createSlide', + description: + 'Insert a keyless slide, appending when atIndex is omitted.', + schema: createSlideArgumentsSchema, + call: (input) => _service.createSlide( + parseKeylessSlide(input['slide']), + atIndex: input['atIndex'] as int?, + ), + ), + _tool( + name: 'updateSlide', + description: 'Replace the slide at a zero-based index.', + schema: updateSlideArgumentsSchema, + call: (input) => _service.updateSlide( + input['index'] as int, + parseKeylessSlide(input['slide']), + ), + ), + _tool( + name: 'deleteSlide', + description: 'Delete the slide at a zero-based index.', + schema: deleteSlideArgumentsSchema, + call: (input) => _service.deleteSlide(input['index'] as int), + ), + _tool( + name: 'moveSlide', + description: 'Move a slide so it ends at the requested final index.', + schema: moveSlideArgumentsSchema, + call: (input) => _service.moveSlide( + input['fromIndex'] as int, + input['toIndex'] as int, + ), + ), + _tool( + name: 'readSlide', + description: 'Return one keyless slide and a rendered PNG thumbnail.', + schema: readSlideArgumentsSchema, + call: (input) => _service.readSlide(input['index'] as int), + ), + // updateStyle is not registered yet: its schema and applier targeted the + // pre-#102 style contract. The service keeps the seam + // (DeckToolsService.updateStyle); the tool returns with the deck-edit UI + // once it is rebuilt on the brand/theme contract. + ]); + } + + dartantic.Tool> _tool({ + required String name, + required String description, + required AckSchema schema, + required Future> Function(Map input) + call, + }) { + return dartantic.Tool( + name: name, + description: description, + onCall: (input) => _invoke(schema, input, call), + inputSchema: schema.toJsonSchemaBuilder(), + ); + } + + Future> _invoke( + AckSchema schema, + Map input, + Future> Function(Map) call, + ) async { + try { + schema.parse(input); + + return await call(input); + } on AckException { + return _error(.validationFailed, 'The tool arguments are invalid.'); + } on DeckToolError catch (error) { + return _error(error.code, error.message); + } catch (_) { + return _error( + .internalError, + 'The deck tool could not complete the request.', + ); + } + } + + Map _error(DeckToolErrorCode code, String message) { + return { + 'error': {'code': code.wireName, 'message': message}, + }; + } +} diff --git a/packages/playground/lib/features/ai/deck_editor/data/deck_slide_reader.dart b/packages/playground/lib/features/ai/deck_editor/data/deck_slide_reader.dart new file mode 100644 index 00000000..87f5416d --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/data/deck_slide_reader.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:superdeck/superdeck.dart'; + +import '../ai/deck_tool_schemas.dart'; +import '../domain/deck_tool_error.dart'; + +typedef SlideCaptureCallback = + Future Function({ + required SlideConfiguration slide, + required BuildContext context, + required SlideCaptureQuality quality, + }); + +/// Reads and captures one slide from a single immutable live configuration list. +final class DeckSlideReader { + final BuildContext _context; + + final DeckController _deckController; + final SlideCaptureCallback _capture; + DeckSlideReader({ + required BuildContext context, + required DeckController deckController, + SlideCaptureCallback? capture, + }) : _context = context, + _deckController = deckController, + _capture = capture ?? SlideCaptureService().capture; + + Future> read(int index) async { + final snapshot = List.unmodifiable( + _deckController.slides.value, + ); + if (index < 0 || index >= snapshot.length) { + throw DeckToolError( + .slideIndexOutOfRange, + 'Slide index $index is outside the live preview.', + ); + } + if (!_context.mounted) { + throw const DeckToolError( + .contextUnavailable, + 'The deck editing route is no longer mounted.', + ); + } + + final configuration = snapshot[index]; + final Uint8List bytes; + try { + bytes = await _capture( + context: _context, + quality: .thumbnail, + slide: configuration, + ); + } catch (error, stackTrace) { + if (!_context.mounted) { + Error.throwWithStackTrace( + DeckToolError( + .contextUnavailable, + 'The deck editing route is no longer mounted.', + cause: error, + ), + stackTrace, + ); + } + Error.throwWithStackTrace( + DeckToolError( + .captureFailed, + 'The slide thumbnail could not be rendered.', + cause: error, + ), + stackTrace, + ); + } + if (bytes.isEmpty) { + throw const DeckToolError( + .captureFailed, + 'The slide thumbnail renderer returned no image data.', + ); + } + + final slides = [for (final item in snapshot) item.slide]; + final slide = configuration.slide; + + return { + 'index': index, + 'title': ?slide.options?.title, + 'slide': slideToKeylessMap(slide), + 'thumbnailBase64': base64Encode(bytes), + 'deck': deckSnapshot(slides), + }; + } +} diff --git a/packages/playground/lib/features/ai/deck_editor/data/editor_deck_store.dart b/packages/playground/lib/features/ai/deck_editor/data/editor_deck_store.dart new file mode 100644 index 00000000..53dd352b --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/data/editor_deck_store.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:signals/signals.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../../../core/data/mappers/deck_markdown_codec.dart'; +import '../../../editor/domain/stores/deck_document_store.dart'; +import '../domain/deck_store.dart'; +import '../domain/deck_tool_error.dart'; + +/// Bridges queued tool operations to the live editor and rendered preview. +final class EditorDeckStore implements DeckStore { + final DeckDocumentStore _documentStore; + + final DeckController _deckController; + final DeckMarkdownCodec _codec; + final Duration _barrierTimeout; + const EditorDeckStore({ + required DeckDocumentStore documentStore, + required DeckController deckController, + DeckMarkdownCodec codec = const DeckMarkdownCodec(), + Duration barrierTimeout = const Duration(seconds: 2), + }) : _documentStore = documentStore, + _deckController = deckController, + _codec = codec, + _barrierTimeout = barrierTimeout; + + List _decodeForWrite(String markdown) { + try { + return _codec.decode(markdown); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + DeckToolError( + .deckWriteFailed, + 'The baseline editor document is not a valid deck.', + cause: error, + ), + stackTrace, + ); + } + } + + Future> _awaitPreview(String expectedMarkdown) { + final completer = Completer>(); + EffectCleanup? cleanup; + late final Timer timer; + + void completeError(Object error, StackTrace stackTrace) { + if (completer.isCompleted) return; + completer.completeError( + DeckToolError( + .deckWriteFailed, + 'The live preview did not observe the editor document.', + cause: error, + ), + stackTrace, + ); + } + + timer = Timer( + _barrierTimeout, + () => completeError( + TimeoutException('Preview synchronization timed out.'), + .current, + ), + ); + + cleanup = effect(() { + try { + final configurations = _deckController.slides.value; + final sessionError = + _deckController.session.error.value ?? + _deckController.session.buildFailure.value; + if (sessionError != null) { + completeError(sessionError, .current); + + return; + } + + final observed = [ + for (final configuration in configurations) configuration.slide, + ]; + if (_codec.encode(observed) == expectedMarkdown && + !completer.isCompleted) { + completer.complete(List.unmodifiable(observed)); + } + } catch (error, stackTrace) { + completeError(error, stackTrace); + } + }); + + return completer.future.whenComplete(() { + timer.cancel(); + cleanup?.call(); + }); + } + + @override + List read() { + try { + return _codec.decode(_documentStore.markdown); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + DeckToolError( + .deckParseFailed, + 'The current editor document could not be parsed.', + cause: error, + ), + stackTrace, + ); + } + } + + @override + Future> write(List slides) { + final expected = _codec.encode(slides); + try { + _documentStore.replaceMarkdown(expected); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + DeckToolError( + .deckWriteFailed, + 'The editor document could not be replaced.', + cause: error, + ), + stackTrace, + ); + } + + return _awaitPreview(expected); + } + + @override + Future> restore(String markdown) { + final decoded = _decodeForWrite(markdown); + final expected = _codec.encode(decoded); + try { + _documentStore.replaceMarkdown(markdown); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + DeckToolError( + .deckWriteFailed, + 'The baseline editor document could not be restored.', + cause: error, + ), + stackTrace, + ); + } + + return _awaitPreview(expected); + } + + @override + Future> synchronize() { + final expected = _codec.encode(read()); + + return _awaitPreview(expected); + } +} diff --git a/packages/playground/lib/features/ai/deck_editor/domain/deck_store.dart b/packages/playground/lib/features/ai/deck_editor/domain/deck_store.dart new file mode 100644 index 00000000..19df0abe --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/domain/deck_store.dart @@ -0,0 +1,16 @@ +import 'package:superdeck_core/superdeck_core.dart'; + +/// Canonical document operations used by the deck editing tools. +abstract interface class DeckStore { + /// Decodes the editor's current document. + List read(); + + /// Replaces the editor with [slides] and completes after the preview observes it. + Future> write(List slides); + + /// Restores exact source [markdown] and completes after preview synchronization. + Future> restore(String markdown); + + /// Completes when the current editor document and preview agree structurally. + Future> synchronize(); +} diff --git a/packages/playground/lib/features/ai/deck_editor/domain/deck_tool_error.dart b/packages/playground/lib/features/ai/deck_editor/domain/deck_tool_error.dart new file mode 100644 index 00000000..a06079c4 --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/domain/deck_tool_error.dart @@ -0,0 +1,26 @@ +/// Stable failure codes returned by the deck editing tool boundary. +enum DeckToolErrorCode { + validationFailed('validation_failed'), + slideIndexOutOfRange('slide_index_out_of_range'), + deckParseFailed('deck_parse_failed'), + deckWriteFailed('deck_write_failed'), + captureFailed('capture_failed'), + contextUnavailable('context_unavailable'), + internalError('internal_error'); + + const DeckToolErrorCode(this.wireName); + + final String wireName; +} + +/// Typed internal error translated to structured JSON by the AI adapter. +final class DeckToolError implements Exception { + final DeckToolErrorCode code; + + final String message; + final Object? cause; + const DeckToolError(this.code, this.message, {this.cause}); + + @override + String toString() => '${code.wireName}: $message'; +} diff --git a/packages/playground/lib/features/ai/deck_editor/domain/deck_tools_service.dart b/packages/playground/lib/features/ai/deck_editor/domain/deck_tools_service.dart new file mode 100644 index 00000000..583931d9 --- /dev/null +++ b/packages/playground/lib/features/ai/deck_editor/domain/deck_tools_service.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:superdeck_core/superdeck_core.dart'; + +import 'deck_store.dart'; +import 'deck_tool_error.dart'; + +typedef ReadSlideOperation = FutureOr> Function(int index); +typedef UpdateStyleOperation = + FutureOr> Function(Object? style); + +/// Serializes all deck tool operations through one error-recovering FIFO queue. +final class DeckToolsService { + final DeckStore _deckStore; + + final ReadSlideOperation? _readSlide; + final UpdateStyleOperation? _updateStyle; + final void Function()? _onDirty; + Future _tail = Future.value(); + + bool _closed = false; + DeckToolsService({ + required DeckStore deckStore, + ReadSlideOperation? readSlide, + UpdateStyleOperation? updateStyle, + void Function()? onDirty, + }) : _deckStore = deckStore, + _readSlide = readSlide, + _updateStyle = updateStyle, + _onDirty = onDirty; + + Future> _write(List slides) { + _ensureOpen(); + final barrier = _deckStore.write(slides); + _onDirty?.call(); + + return barrier; + } + + Future _enqueue(FutureOr Function() operation) { + final result = _tail.then((_) async { + _ensureOpen(); + + return operation(); + }); + // The queue must advance even when this operation failed; the error still + // reaches the caller through [result], so it is ignored here. + final settled = Completer(); + result.whenComplete(settled.complete).ignore(); + _tail = settled.future; + + return result; + } + + void _ensureOpen() { + if (_closed) { + throw const DeckToolError( + .contextUnavailable, + 'The deck editing session is closed.', + ); + } + } + + void _validateInsertionIndex(int index, int length) { + if (index < 0 || index > length) { + throw DeckToolError( + .slideIndexOutOfRange, + 'Slide insertion index $index is outside 0..$length.', + ); + } + } + + void _validateExistingIndex(int index, int length) { + if (index < 0 || index >= length) { + throw DeckToolError( + .slideIndexOutOfRange, + 'Slide index $index is outside 0..<$length.', + ); + } + } + + Map _keyless(Slide slide) { + return Map.from(slide.toMap())..remove('key'); + } + + Map _snapshot(List slides) { + return { + 'totalSlides': slides.length, + 'slides': [ + for (final (index, slide) in slides.indexed) + {'index': index, 'title': ?slide.options?.title}, + ], + }; + } + + void close() => _closed = true; + + Future> getDeck() { + return _enqueue(() => _snapshot(_deckStore.read())); + } + + Future> createSlide(Slide slide, {int? atIndex}) { + return _enqueue(() async { + final current = _deckStore.read(); + final index = atIndex ?? current.length; + _validateInsertionIndex(index, current.length); + final updated = List.of(current)..insert(index, slide); + final written = await _write(updated); + + return { + 'index': index, + 'slide': _keyless(written[index]), + 'deck': _snapshot(written), + }; + }); + } + + Future> updateSlide(int index, Slide slide) { + return _enqueue(() async { + final current = _deckStore.read(); + _validateExistingIndex(index, current.length); + final updated = List.of(current)..[index] = slide; + final written = await _write(updated); + + return { + 'index': index, + 'slide': _keyless(written[index]), + 'deck': _snapshot(written), + }; + }); + } + + Future> deleteSlide(int index) { + return _enqueue(() async { + final current = _deckStore.read(); + _validateExistingIndex(index, current.length); + final updated = List.of(current)..removeAt(index); + + return _snapshot(await _write(updated)); + }); + } + + Future> moveSlide(int fromIndex, int toIndex) { + return _enqueue(() async { + final current = _deckStore.read(); + _validateExistingIndex(fromIndex, current.length); + _validateExistingIndex(toIndex, current.length); + final updated = List.of(current); + final slide = updated.removeAt(fromIndex); + updated.insert(toIndex, slide); + final written = await _write(updated); + + return { + 'fromIndex': fromIndex, + 'toIndex': toIndex, + 'deck': _snapshot(written), + }; + }); + } + + Future> readSlide(int index) { + return _enqueue(() async { + final current = await _deckStore.synchronize(); + _validateExistingIndex(index, current.length); + final operation = _readSlide; + if (operation == null) { + throw const DeckToolError( + .contextUnavailable, + 'Slide capture is unavailable in this session.', + ); + } + _ensureOpen(); + + return operation(index); + }); + } + + Future> updateStyle(Object? style) { + return _enqueue(() async { + final operation = _updateStyle; + if (operation == null) { + throw const DeckToolError( + .contextUnavailable, + 'Style editing is unavailable in this session.', + ); + } + _ensureOpen(); + final result = operation(style); + _onDirty?.call(); + + return result; + }); + } +} diff --git a/packages/playground/test/core/data/mappers/deck_markdown_codec_test.dart b/packages/playground/test/core/data/mappers/deck_markdown_codec_test.dart new file mode 100644 index 00000000..b172d607 --- /dev/null +++ b/packages/playground/test/core/data/mappers/deck_markdown_codec_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/data/mappers/deck_markdown_codec.dart'; +import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + const codec = DeckMarkdownCodec(); + + test('decodes Markdown with options, sections, widgets, and comments', () { + const markdown = ''' +--- +title: Demo +layout: fullscreen +custom: preserved +--- + +@section { flex: 2 } +@block +# Heading + +@image { + src: hero.png + fit: cover +} + + +'''; + + final slide = codec.decode(markdown).single; + + expect(slide.options?.title, 'Demo'); + expect(slide.options?.layout, SlideLayout.fullscreen); + expect(slide.options?.args, containsPair('custom', 'preserved')); + expect(slide.sections.single.flex, 2); + expect( + slide.sections.single.blocks.whereType().single, + isA() + .having((block) => block.name, 'name', 'image') + .having((block) => block.args['src'], 'src', 'hero.png') + .having((block) => block.args['fit'], 'fit', 'cover'), + ); + expect(slide.comments, ['Speaker note']); + }); + + test('encodes with the canonical SlideSerializer', () { + final slides = [ + Slide( + key: 'transient', + options: SlideOptions(title: 'Canonical'), + sections: [SectionBlock.text('# Canonical')], + ), + ]; + + expect(codec.encode(slides), const SlideSerializer().serialize(slides)); + }); + + test('throws a typed deck format error for invalid Markdown', () { + const markdown = ''' +--- +layout: diagonal +--- + +# Invalid layout +'''; + + expect( + () => codec.decode(markdown), + throwsA( + isA().having( + (error) => error.source, + 'source', + markdown, + ), + ), + ); + }); + + test('decode-encode-decode preserves structural slide data', () { + const markdown = ''' +--- +title: Round trip +template: cover +custom: value +--- + +@section { + flex: 2 + align: center +} +@chart { + kind: bar + values: [1, 2, 3] +} + + +'''; + + final decoded = codec.decode(markdown); + final reparsed = codec.decode(codec.encode(decoded)); + + expect(_withoutKeys(reparsed), _withoutKeys(decoded)); + }); +} + +List> _withoutKeys(List slides) { + return [ + for (final slide in slides) + Map.from(slide.toMap())..remove('key'), + ]; +} diff --git a/packages/playground/test/features/ai/deck_editor/ai/deck_tool_schemas_test.dart b/packages/playground/test/features/ai/deck_editor/ai/deck_tool_schemas_test.dart new file mode 100644 index 00000000..07842287 --- /dev/null +++ b/packages/playground/test/features/ai/deck_editor/ai/deck_tool_schemas_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/deck_editor/ai/deck_tool_schemas.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + group('keyless slide boundary', () { + test('rejects an incoming slide key', () { + expect( + () => parseKeylessSlide({ + 'key': 'model-supplied', + 'sections': [], + }), + throwsA(isA()), + ); + }); + + test('preserves core options and arbitrary widget args', () { + final slide = parseKeylessSlide({ + 'options': { + 'title': 'Rich slide', + 'layout': 'fullscreen', + 'template': 'cover', + 'customOption': {'nested': true}, + }, + 'comments': ['Speaker note'], + 'sections': [ + { + 'type': 'section', + 'flex': 2, + 'blocks': [ + { + 'type': 'widget', + 'name': 'chart', + 'series': [1, 2, 3], + 'palette': {'primary': '#ff0000'}, + }, + ], + }, + ], + }); + + final encoded = slideToKeylessMap(slide); + + expect(encoded, isNot(contains('key'))); + expect(slide.options?.layout, SlideLayout.fullscreen); + expect(slide.options?.args['customOption'], {'nested': true}); + final widget = slide.sections.single.blocks.single as WidgetBlock; + expect(widget.args['series'], [1, 2, 3]); + expect(widget.args['palette'], {'primary': '#ff0000'}); + }); + + test('deck snapshots expose only slide indices and optional titles', () { + final snapshot = deckSnapshot([ + _slide('one', title: 'One'), + _slide('two'), + ]); + + expect(snapshot, { + 'totalSlides': 2, + 'slides': [ + {'index': 0, 'title': 'One'}, + {'index': 1}, + ], + }); + expect(snapshot.toString(), isNot(contains('one'))); + expect(snapshot.toString(), isNot(contains('two'))); + }); + }); +} + +Slide _slide(String key, {String? title}) { + return Slide( + key: key, + options: title == null ? null : SlideOptions(title: title), + sections: [SectionBlock.text('# ${title ?? key}')], + ); +} diff --git a/packages/playground/test/features/ai/deck_editor/ai/deck_tools_adapter_test.dart b/packages/playground/test/features/ai/deck_editor/ai/deck_tools_adapter_test.dart new file mode 100644 index 00000000..cb6d8ffd --- /dev/null +++ b/packages/playground/test/features/ai/deck_editor/ai/deck_tools_adapter_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/deck_editor/ai/deck_tools_adapter.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_store.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_tools_service.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + test('registers exactly the six deck-edit tools with provider schemas', () { + final adapter = DeckToolsAdapter( + DeckToolsService(deckStore: _MemoryDeckStore([_slide('a')])), + ); + + expect(adapter.tools.map((tool) => tool.name), [ + 'getDeck', + 'createSlide', + 'updateSlide', + 'deleteSlide', + 'moveSlide', + 'readSlide', + ]); + for (final tool in adapter.tools) { + expect(tool.inputSchema.value, isA>()); + } + }); + + test('calls the service and returns JSON-encodable success data', () async { + final adapter = DeckToolsAdapter( + DeckToolsService(deckStore: _MemoryDeckStore([_slide('a')])), + ); + final tool = adapter.tools.singleWhere( + (tool) => tool.name == 'createSlide', + ); + + final result = await tool.call({ + 'slide': { + 'options': {'title': 'Created'}, + 'sections': [ + { + 'blocks': [ + {'type': 'block', 'content': '# Created'}, + ], + }, + ], + }, + }); + + expect(result, isA>()); + expect((result as Map)['error'], isNull); + expect(result['index'], 1); + }); + + test('maps Ack validation failures to validation_failed', () async { + final adapter = DeckToolsAdapter( + DeckToolsService(deckStore: _MemoryDeckStore([_slide('a')])), + ); + final tool = adapter.tools.singleWhere( + (tool) => tool.name == 'updateSlide', + ); + + final result = await tool.call({ + 'index': 0, + 'slide': {'key': 'forbidden', 'sections': []}, + }); + + expect(result, { + 'error': {'code': 'validation_failed', 'message': isA()}, + }); + }); + + test('maps typed service failures to their stable code', () async { + final adapter = DeckToolsAdapter( + DeckToolsService(deckStore: _MemoryDeckStore([_slide('a')])), + ); + final tool = adapter.tools.singleWhere( + (tool) => tool.name == 'deleteSlide', + ); + + final result = await tool.call({'index': 9}); + + expect((result as Map)['error'], { + 'code': 'slide_index_out_of_range', + 'message': isA(), + }); + }); + + test('sanitizes unexpected failures as internal_error', () async { + final adapter = DeckToolsAdapter( + DeckToolsService(deckStore: _ThrowingDeckStore()), + ); + final tool = adapter.tools.singleWhere((tool) => tool.name == 'getDeck'); + + final result = await tool.call({}); + + expect(result, { + 'error': { + 'code': 'internal_error', + 'message': 'The deck tool could not complete the request.', + }, + }); + expect(result.toString(), isNot(contains('provider-secret-token'))); + }); + + test('readSlide reaches its queued service operation', () async { + final service = DeckToolsService( + deckStore: _MemoryDeckStore([_slide('a')]), + readSlide: (index) => {'index': index, 'thumbnailBase64': 'iVBORw=='}, + ); + final adapter = DeckToolsAdapter(service); + + final readResult = await adapter.tools + .singleWhere((tool) => tool.name == 'readSlide') + .call({'index': 0}); + + expect(readResult['thumbnailBase64'], 'iVBORw=='); + }); +} + +Slide _slide(String key) => + Slide(key: key, sections: [SectionBlock.text('# $key')]); + +class _MemoryDeckStore implements DeckStore { + _MemoryDeckStore(List slides) : _slides = List.of(slides); + + List _slides; + + @override + List read() => List.unmodifiable(_slides); + + @override + Future> restore(String markdown) => + throw UnimplementedError('Not used'); + + @override + Future> synchronize() async => read(); + + @override + Future> write(List slides) async { + _slides = List.of(slides); + return read(); + } +} + +class _ThrowingDeckStore implements DeckStore { + @override + List read() => throw StateError('provider-secret-token'); + + @override + Future> restore(String markdown) => throw UnimplementedError(); + + @override + Future> synchronize() => throw UnimplementedError(); + + @override + Future> write(List slides) => throw UnimplementedError(); +} diff --git a/packages/playground/test/features/ai/deck_editor/data/deck_slide_reader_test.dart b/packages/playground/test/features/ai/deck_editor/data/deck_slide_reader_test.dart new file mode 100644 index 00000000..4976c299 --- /dev/null +++ b/packages/playground/test/features/ai/deck_editor/data/deck_slide_reader_test.dart @@ -0,0 +1,144 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:playground/features/ai/deck_editor/data/deck_slide_reader.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_tool_error.dart'; +import 'package:superdeck/superdeck.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + testWidgets('captures and describes one immutable live slide snapshot', ( + tester, + ) async { + final loader = MemoryDeckLoader(); + final cache = MemoryAssetCacheStore(); + final controller = DeckController( + deckLoader: loader, + options: DeckOptions(), + assetCacheStore: cache, + ); + addTearDown(controller.dispose); + loader.updateMarkdown('---\ntitle: First\n---\n\n# First\n'); + await tester.pump(); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + SlideConfiguration? capturedSlide; + SlideCaptureQuality? capturedQuality; + final reader = DeckSlideReader( + context: context, + deckController: controller, + capture: ({required context, required quality, required slide}) async { + capturedSlide = slide; + capturedQuality = quality; + return Uint8List.fromList([137, 80, 78, 71]); + }, + ); + + final result = await reader.read(0); + + expect(capturedSlide, same(controller.slides.value.single)); + expect(capturedSlide?.assetCacheStore, same(cache)); + expect(capturedQuality, SlideCaptureQuality.thumbnail); + expect(result['index'], 0); + expect(result['title'], 'First'); + expect(result['thumbnailBase64'], 'iVBORw=='); + expect((result['slide'] as Map), isNot(contains('key'))); + expect((result['deck'] as Map)['totalSlides'], 1); + }); + + testWidgets('unmounted context maps to context_unavailable', (tester) async { + final harness = await _ReaderHarness.create(tester); + addTearDown(harness.dispose); + var captured = false; + final reader = DeckSlideReader( + context: harness.context, + deckController: harness.controller, + capture: ({required context, required quality, required slide}) async { + captured = true; + return Uint8List(1); + }, + ); + await tester.pumpWidget(const SizedBox()); + + await expectLater( + reader.read(0), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.contextUnavailable, + ), + ), + ); + expect(captured, isFalse); + }); + + testWidgets('render failures map to capture_failed', (tester) async { + final harness = await _ReaderHarness.create(tester); + addTearDown(harness.dispose); + final reader = DeckSlideReader( + context: harness.context, + deckController: harness.controller, + capture: ({required context, required quality, required slide}) async { + throw StateError('render failed'); + }, + ); + + await expectLater( + reader.read(0), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.captureFailed, + ), + ), + ); + }); +} + +class _ReaderHarness { + _ReaderHarness._({required this.context, required this.controller}); + + final BuildContext context; + final DeckController controller; + + static Future<_ReaderHarness> create(WidgetTester tester) async { + final loader = MemoryDeckLoader(); + final controller = DeckController( + deckLoader: loader, + options: DeckOptions(), + ); + loader.updateMarkdown('# Slide'); + await tester.pump(); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + return _ReaderHarness._(context: context, controller: controller); + } + + void dispose() => controller.dispose(); +} diff --git a/packages/playground/test/features/ai/deck_editor/data/editor_deck_store_test.dart b/packages/playground/test/features/ai/deck_editor/data/editor_deck_store_test.dart new file mode 100644 index 00000000..6aeedb67 --- /dev/null +++ b/packages/playground/test/features/ai/deck_editor/data/editor_deck_store_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:playground/core/data/mappers/deck_markdown_codec.dart'; +import 'package:playground/features/ai/deck_editor/data/editor_deck_store.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_tool_error.dart'; +import 'package:playground/features/editor/domain/stores/deck_document_store.dart'; +import 'package:playground/features/editor/domain/stores/editor_store.dart'; +import 'package:playground/features/editor/utils/text_editor_controller.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + test( + 'a real loader and controller observe each write before completion', + () async { + final harness = _Harness(); + addTearDown(harness.dispose); + await harness.store.synchronize(); + final updated = [ + ...harness.store.read(), + Slide( + key: 'new', + options: SlideOptions(title: 'Second'), + sections: [SectionBlock.text('# Second')], + ), + ]; + + final observed = await harness.store.write(updated); + + expect( + harness.documentStore.markdown, + const DeckMarkdownCodec().encode(updated), + ); + expect(harness.controller.slides.value, hasLength(2)); + expect(harness.controller.slides.value.last.options.title, 'Second'); + expect(observed, hasLength(2)); + expect(observed.last.options?.title, 'Second'); + }, + ); + + test('read always decodes the current editor document', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.documentStore.replaceMarkdown( + '---\ntitle: Changed\n---\n\n# Changed\n', + ); + + final slide = harness.store.read().single; + + expect(slide.options?.title, 'Changed'); + }); + + test('invalid editor Markdown maps to deck_parse_failed', () { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.documentStore.replaceMarkdown( + '---\nlayout: diagonal\n---\n\n# Invalid\n', + ); + + expect( + harness.store.read, + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckParseFailed, + ), + ), + ); + }); + + test('a preview timeout maps to deck_write_failed', () async { + final editorLoader = MemoryDeckLoader(); + final silentLoader = _SilentDeckLoader(); + final controller = DeckController( + deckLoader: silentLoader, + options: DeckOptions(), + ); + final editorStore = EditorStore(); + final documentStore = DeckDocumentStore(markdown: '# Initial'); + final editor = TextEditorController( + editorStore: editorStore, + deckLoader: editorLoader, + documentStore: documentStore, + ); + final store = EditorDeckStore( + documentStore: documentStore, + deckController: controller, + barrierTimeout: const Duration(milliseconds: 20), + ); + addTearDown(() async { + editor.dispose(); + editorStore.dispose(); + documentStore.dispose(); + controller.dispose(); + await editorLoader.dispose(); + }); + + await expectLater( + store.write([ + Slide(key: 'new', sections: [SectionBlock.text('# New')]), + ]), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckWriteFailed, + ), + ), + ); + }); + + test( + 'restore preserves exact raw Markdown after the preview barrier', + () async { + const baseline = '# Baseline \n\nText with trailing spaces.\n'; + final harness = _Harness(initialText: baseline); + addTearDown(harness.dispose); + await harness.store.synchronize(); + await harness.store.write([ + Slide(key: 'changed', sections: [SectionBlock.text('# Changed')]), + ]); + + await harness.store.restore(baseline); + + expect(harness.documentStore.markdown, baseline); + expect(harness.controller.slides.value.single.slide.sections, isNotEmpty); + }, + ); +} + +class _Harness { + _Harness({String initialText = '# Initial'}) + : loader = MemoryDeckLoader(), + editorStore = EditorStore(), + documentStore = DeckDocumentStore(markdown: initialText) { + controller = DeckController(deckLoader: loader, options: DeckOptions()); + editor = TextEditorController( + editorStore: editorStore, + deckLoader: loader, + documentStore: documentStore, + ); + store = EditorDeckStore( + documentStore: documentStore, + deckController: controller, + ); + } + + final MemoryDeckLoader loader; + final EditorStore editorStore; + final DeckDocumentStore documentStore; + late final DeckController controller; + late final TextEditorController editor; + late final EditorDeckStore store; + + Future dispose() async { + editor.dispose(); + editorStore.dispose(); + documentStore.dispose(); + controller.dispose(); + } +} + +class _SilentDeckLoader extends DeckLoader { + final _controller = StreamController.broadcast(); + + @override + Stream load() => _controller.stream; + + @override + Future reload() async {} + + @override + Future dispose() => _controller.close(); +} diff --git a/packages/playground/test/features/ai/deck_editor/domain/deck_tools_service_test.dart b/packages/playground/test/features/ai/deck_editor/domain/deck_tools_service_test.dart new file mode 100644 index 00000000..9a7f7b6f --- /dev/null +++ b/packages/playground/test/features/ai/deck_editor/domain/deck_tools_service_test.dart @@ -0,0 +1,260 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_store.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_tool_error.dart'; +import 'package:playground/features/ai/deck_editor/domain/deck_tools_service.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + group('DeckToolsService slide operations', () { + test('getDeck returns a style-free, keyless summary', () async { + final service = DeckToolsService( + deckStore: _FakeDeckStore([_slide('a', title: 'A'), _slide('b')]), + ); + + expect(await service.getDeck(), { + 'totalSlides': 2, + 'slides': [ + {'index': 0, 'title': 'A'}, + {'index': 1}, + ], + }); + }); + + test('create without atIndex appends at the live slide count', () async { + final store = _FakeDeckStore([_slide('a')]); + final service = DeckToolsService(deckStore: store); + + final result = await service.createSlide(_slide('b', title: 'B')); + + expect(result['index'], 1); + expect((result['slide'] as Map), isNot(contains('key'))); + expect((result['deck'] as Map)['totalSlides'], 2); + expect(store.slides.map((slide) => slide.key), ['a', 'b']); + }); + + test('create inserts at every valid boundary', () async { + final store = _FakeDeckStore([_slide('b')]); + final service = DeckToolsService(deckStore: store); + + await service.createSlide(_slide('a'), atIndex: 0); + await service.createSlide(_slide('c'), atIndex: 2); + + expect(store.slides.map((slide) => slide.key), ['a', 'b', 'c']); + }); + + test('update returns the canonical post-write slide and deck', () async { + final store = _CanonicalizingDeckStore([_slide('a')]); + final service = DeckToolsService(deckStore: store); + + final result = await service.updateSlide( + 0, + _slide('request-key', title: 'Updated'), + ); + + expect((result['slide'] as Map), isNot(contains('key'))); + expect( + ((result['slide'] as Map)['options'] as Map)['title'], + 'Updated', + ); + expect(store.slides.single.key, 'canonical-key'); + }); + + test('delete returns the remaining deck snapshot', () async { + final store = _FakeDeckStore([_slide('a'), _slide('b')]); + final service = DeckToolsService(deckStore: store); + + final result = await service.deleteSlide(0); + + expect(result['totalSlides'], 1); + expect(store.slides.single.key, 'b'); + }); + + test('move toIndex is the final index after the move', () async { + final store = _FakeDeckStore([_slide('a'), _slide('b'), _slide('c')]); + final service = DeckToolsService(deckStore: store); + + final result = await service.moveSlide(0, 2); + + expect(store.slides.map((slide) => slide.key), ['b', 'c', 'a']); + expect(result['fromIndex'], 0); + expect(result['toIndex'], 2); + }); + + test('all invalid integer ranges use slide_index_out_of_range', () async { + final service = DeckToolsService( + deckStore: _FakeDeckStore([_slide('a')]), + ); + + final operations = Function()>[ + () => service.createSlide(_slide('b'), atIndex: -1), + () => service.createSlide(_slide('b'), atIndex: 2), + () => service.updateSlide(1, _slide('b')), + () => service.deleteSlide(-1), + () => service.moveSlide(1, 0), + () => service.moveSlide(0, 1), + ]; + + for (final operation in operations) { + await expectLater( + operation(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideIndexOutOfRange, + ), + ), + ); + } + }); + + test( + 'two immediate creates are serialized and retain both slides', + () async { + final store = _FakeDeckStore([_slide('a')], writeDelay: Duration.zero); + final service = DeckToolsService(deckStore: store); + + final first = service.createSlide(_slide('b')); + final second = service.createSlide(_slide('c')); + await Future.wait([first, second]); + + expect(store.slides.map((slide) => slide.key), ['a', 'b', 'c']); + }, + ); + + test( + 'a failed queued operation does not poison the next operation', + () async { + final store = _FakeDeckStore([_slide('a')]); + final service = DeckToolsService(deckStore: store); + + final invalid = service.deleteSlide(9); + final valid = service.createSlide(_slide('b')); + + await expectLater(invalid, throwsA(isA())); + await valid; + expect(store.slides.map((slide) => slide.key), ['a', 'b']); + }, + ); + + test('closed services reject queued work before side effects', () async { + final writeGate = Completer(); + final store = _FakeDeckStore([_slide('a')], writeGate: writeGate); + final service = DeckToolsService(deckStore: store); + + final admitted = service.createSlide(_slide('b')); + await store.writeStarted.future; + service.close(); + final rejected = service.createSlide(_slide('c')); + writeGate.complete(); + + await admitted; + await expectLater( + rejected, + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.contextUnavailable, + ), + ), + ); + expect(store.slides.map((slide) => slide.key), ['a', 'b']); + }); + + test('readSlide synchronizes preview before capture', () async { + final store = _FakeDeckStore([_slide('a')]); + var captures = 0; + final service = DeckToolsService( + deckStore: store, + readSlide: (index) { + captures++; + return {'index': index}; + }, + ); + + expect(await service.readSlide(0), {'index': 0}); + expect(store.synchronizeCalls, 1); + expect(captures, 1); + }); + + test('updateStyle is queued and marks the session dirty', () async { + final store = _FakeDeckStore([_slide('a')]); + var dirtyCount = 0; + final service = DeckToolsService( + deckStore: store, + updateStyle: (style) => {'style': style}, + onDirty: () => dirtyCount++, + ); + + final result = await service.updateStyle({'name': 'Test'}); + + expect(result['style'], {'name': 'Test'}); + expect(dirtyCount, 1); + }); + }); +} + +Slide _slide(String key, {String? title}) { + return Slide( + key: key, + options: title == null ? null : SlideOptions(title: title), + sections: [SectionBlock.text('# ${title ?? key}')], + ); +} + +class _FakeDeckStore implements DeckStore { + _FakeDeckStore( + List slides, { + this.writeDelay = Duration.zero, + this.writeGate, + }) : slides = List.of(slides); + + List slides; + final Duration writeDelay; + final Completer? writeGate; + final writeStarted = Completer(); + var synchronizeCalls = 0; + + @override + List read() => List.unmodifiable(slides); + + @override + Future> restore(String markdown) => + throw UnimplementedError('Not needed by this test'); + + @override + Future> synchronize() async { + synchronizeCalls++; + return read(); + } + + @override + Future> write(List updated) async { + if (!writeStarted.isCompleted) writeStarted.complete(); + if (writeGate != null) await writeGate!.future; + if (writeDelay > Duration.zero) await Future.delayed(writeDelay); + slides = List.of(updated); + return read(); + } +} + +class _CanonicalizingDeckStore extends _FakeDeckStore { + _CanonicalizingDeckStore(super.slides); + + @override + Future> write(List updated) async { + final canonical = [ + for (final slide in updated) + Slide( + key: 'canonical-key', + options: slide.options, + sections: slide.sections, + comments: slide.comments, + ), + ]; + return super.write(canonical); + } +}