feat(draw): template dispatch — lower a drawing-card doc to SVG (T-318)

resolveDrawingSvg lowers a DrawingCardDoc to an SVG string: primitive docs
use inline svg or read svgPath (via an injected reader); template docs use
a registered DrawingRegistry handler, so the d2/icon/compare/image children
plug in. Honest DrawErr on no source / unknown template / unreadable path /
empty output. Flutter-free, covered by dart test (7 cases). Not yet wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent 1a0a69df5f
commit 1fdba4aba5
2 changed files with 121 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
/// Drawing-card template dispatch (T-318 / D-103).
///
/// Lowers a [DrawingCardDoc] to an SVG string — the substrate the renderer
/// paints (D-103). In primitive mode the SVG is the doc's inline `svg` or the
/// contents of its `svgPath`; in template mode a registered handler (`d2`,
/// `icon`, `compare`, `image` — the child tickets) produces the SVG from the
/// doc's fields. The handlers and the file reader are injected, so this stays
/// headless- and `dart test`-friendly (no ambient filesystem, mirroring how
/// image.show injects its path resolver).
///
/// Honest result: every failure (no source, unknown template, unreadable path,
/// empty handler output) returns a [DrawErr] with a message rather than
/// throwing — the command layer turns it into an IpcError userError.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'draw_doc.dart';
/// Produces an SVG string for a template-mode doc, or `null` on failure.
typedef DrawingTemplateHandler = Future<String?> Function(DrawingCardDoc doc);
/// Reads a file's contents, or `null` if unreadable. Injected for testability.
typedef DrawingFileReader = Future<String?> Function(String path);
/// Registry of template handlers, keyed by `template` name.
class DrawingRegistry {
final Map<String, DrawingTemplateHandler> _handlers = {};
/// Register (or replace) the handler for [template].
void register(String template, DrawingTemplateHandler handler) => _handlers[template] = handler;
DrawingTemplateHandler? handlerFor(String template) => _handlers[template];
bool get isEmpty => _handlers.isEmpty;
}
/// Outcome of lowering a doc to SVG.
sealed class DrawResult {
const DrawResult();
}
class DrawOk extends DrawResult {
const DrawOk(this.svg);
final String svg;
}
class DrawErr extends DrawResult {
const DrawErr(this.message);
final String message;
}
/// Lower [doc] to an SVG string. Primitive docs use their inline `svg` or read
/// `svgPath` via [readFile]; template docs use the matching handler in
/// [registry].
Future<DrawResult> resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registry, {required DrawingFileReader readFile}) async {
if (doc.isPrimitive) {
if (doc.svg != null) return DrawOk(doc.svg!);
if (doc.svgPath != null) {
final contents = await readFile(doc.svgPath!);
return contents == null ? DrawErr('cannot read ${doc.svgPath}') : DrawOk(contents);
}
return const DrawErr('drawing card has no svg, svgPath, or template');
}
final handler = registry.handlerFor(doc.template!);
if (handler == null) return DrawErr('unknown drawing template: ${doc.template}');
final svg = await handler(doc);
return svg == null ? DrawErr('template ${doc.template} produced no SVG') : DrawOk(svg);
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:clide/src/draw/draw_dispatch.dart';
import 'package:clide/src/draw/draw_doc.dart';
import 'package:test/test.dart';
void main() {
DrawingFileReader reader(Map<String, String> files) =>
(p) async => files[p];
Future<DrawResult> resolve(DrawingCardDoc doc, DrawingRegistry reg, {Map<String, String> files = const {}}) =>
resolveDrawingSvg(doc, reg, readFile: reader(files));
group('resolveDrawingSvg', () {
test('primitive: inline svg passes through', () async {
final r = await resolve(parseDrawingCardDoc({'svg': '<svg id="x"/>'})!, DrawingRegistry());
expect((r as DrawOk).svg, '<svg id="x"/>');
});
test('primitive: svgPath is read via the injected reader', () async {
final r = await resolve(parseDrawingCardDoc({'svgPath': 'd.svg'})!, DrawingRegistry(), files: {'d.svg': '<svg id="file"/>'});
expect((r as DrawOk).svg, '<svg id="file"/>');
});
test('primitive: an unreadable svgPath is an honest error', () async {
final r = await resolve(parseDrawingCardDoc({'svgPath': 'missing.svg'})!, DrawingRegistry());
expect(r, isA<DrawErr>());
expect((r as DrawErr).message, contains('missing.svg'));
});
test('primitive: no source at all is an error', () async {
final r = await resolve(parseDrawingCardDoc(const {})!, DrawingRegistry());
expect(r, isA<DrawErr>());
});
test('template: an unknown template is an error', () async {
final r = await resolve(parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!, DrawingRegistry());
expect(r, isA<DrawErr>());
expect((r as DrawErr).message, contains('d2'));
});
test('template: a registered handler lowers the doc to SVG', () async {
final reg = DrawingRegistry()..register('d2', (doc) async => '<svg data-src="${doc.fields['source']}"/>');
final r = await resolve(parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!, reg);
expect((r as DrawOk).svg, '<svg data-src="a -> b"/>');
});
test('template: a handler that returns null is an error', () async {
final reg = DrawingRegistry()..register('d2', (_) async => null);
final r = await resolve(parseDrawingCardDoc({'template': 'd2'})!, reg);
expect(r, isA<DrawErr>());
});
});
}