test(draw): cover the 2.9.0 drawing-card additions to clear the coverage floor
The drawing-card feature batch (icon/image/compare/graph/d2 cards, --stdin, tool resolution) added widget + wiring code that dipped total coverage under the 95% floor — surfaced by `make release` (push-check skips coverage). Cover the gaps: ProblemsController.refresh, every SVG shape-type bbox + the style vocabulary, _spawnD2 via a real /bin/cat, the icon-show bus path + error branches, and quad/arc/close marker paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/widgets/src/draw/drawing_card.dart' show DrawingCard;
|
||||
import 'package:clide/widgets/src/svg/svg_painter.dart' show SvgView;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
|
||||
@@ -493,6 +495,24 @@ void main() {
|
||||
expect(find.text('48'), findsOneWidget); // largest strip sample
|
||||
});
|
||||
|
||||
testWidgets('an icon entry + card colour are parsed and rendered (T-313)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
_iconMsg([const IconEntry(codepoint: 0xe2a4, name: 'gear', label: 'S', color: '#e2b714')], color: '#888888'),
|
||||
]);
|
||||
expect(find.text('S'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a data-lightbox drawing opens the zoom lightbox (T-318)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
DrawingMessage(uuid: 'L', timestamp: _t, isSidechain: false, svg: '<svg viewBox="0 0 10 10"><rect width="10" height="10" data-lightbox=""/></svg>'),
|
||||
]);
|
||||
await tester.tap(find.byType(DrawingCard));
|
||||
await tester.pumpAndSettle();
|
||||
// The lightbox opened — its SvgView renders in the dialog overlay (in
|
||||
// addition to the card's own).
|
||||
expect(find.byType(SvgView), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('inject() drives a new image card into a live view (T-249)', (tester) async {
|
||||
final c = await pumpWith(tester, [_user('hi')]);
|
||||
expect(find.text('image'), findsNothing);
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeS
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/icon_commands.dart' show iconShowChannel;
|
||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -128,6 +129,20 @@ void main() {
|
||||
// receive the card and the CLI already acked at publish time.
|
||||
});
|
||||
|
||||
test('an icon-show message parses entries, dropped silently with no session (T-313)', () async {
|
||||
f.services.messages.publish('test', iconShowChannel, {
|
||||
'entries': [
|
||||
{'codepoint': 0xe2a4, 'name': 'gear', 'label': 'Settings', 'description': 'd', 'color': '#fff'},
|
||||
{'name': 'noCodepoint'}, // skipped — no int codepoint
|
||||
],
|
||||
'color': '#888',
|
||||
});
|
||||
f.services.messages.publish('test', iconShowChannel, {'entries': const []}); // empty → early return
|
||||
f.services.messages.publish('test', iconShowChannel, {'entries': 'notalist'}); // not a list → early return
|
||||
await pumpEventQueue();
|
||||
// No live session — the entry parse ran; nothing to inject into.
|
||||
});
|
||||
|
||||
test('a project switch closes sessions that belong to the old root (T-269)', () async {
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
|
||||
await pumpEventQueue();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:clide/builtin/problems/src/problems_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/env/supporter_binaries.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
group('supporterToolProblems', () {
|
||||
test('flags a stale supporter-binary pin', () {
|
||||
@@ -21,4 +25,89 @@ void main() {
|
||||
expect(supporterToolProblems(null), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProblemsController.refresh', () {
|
||||
late FakeDaemonClient ipc;
|
||||
SupporterBinaries? saved;
|
||||
|
||||
setUp(() {
|
||||
saved = activeSupporterBinaries;
|
||||
activeSupporterBinaries = null; // isolate from supporter-tool problems
|
||||
ipc = FakeDaemonClient(log: Logger(), events: DaemonBus());
|
||||
});
|
||||
tearDown(() => activeSupporterBinaries = saved);
|
||||
|
||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
|
||||
test('a clean doctor + sync yields no problems', () async {
|
||||
ipc.stub(
|
||||
'pql.doctor',
|
||||
(_) async => ok({
|
||||
'db': {'exists': true},
|
||||
'skill': {
|
||||
'project': {'state': 'ok'},
|
||||
},
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.decisions.sync', (_) async => ok({'broken': 0}));
|
||||
final c = ProblemsController(ipc: ipc);
|
||||
var notified = 0;
|
||||
c.addListener(() => notified++);
|
||||
await c.refresh();
|
||||
expect(c.problems, isEmpty);
|
||||
expect(c.loading, isFalse);
|
||||
expect(c.error, isNull);
|
||||
expect(notified, greaterThan(0)); // loading toggled + final
|
||||
});
|
||||
|
||||
test('flags a missing db, a stale skill, and broken refs', () async {
|
||||
ipc.stub(
|
||||
'pql.doctor',
|
||||
(_) async => ok({
|
||||
'db': {'exists': false},
|
||||
'skill': {
|
||||
'project': {'state': 'stale'},
|
||||
},
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.decisions.sync', (_) async => ok({'broken': 2}));
|
||||
final c = ProblemsController(ipc: ipc);
|
||||
await c.refresh();
|
||||
final msgs = c.problems.map((p) => p.message).join('\n');
|
||||
expect(c.problems.map((p) => p.source), containsAll(['pql', 'decisions']));
|
||||
expect(msgs, contains('not found'));
|
||||
expect(msgs, contains('stale'));
|
||||
expect(msgs, contains('broken'));
|
||||
});
|
||||
|
||||
test('a missing skill is flagged with the install hint', () async {
|
||||
ipc.stub(
|
||||
'pql.doctor',
|
||||
(_) async => ok({
|
||||
'db': {'exists': true},
|
||||
'skill': {
|
||||
'project': {'state': 'missing'},
|
||||
},
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.decisions.sync', (_) async => ok({'broken': 0}));
|
||||
final c = ProblemsController(ipc: ipc);
|
||||
await c.refresh();
|
||||
expect(c.problems.any((p) => p.message.contains('not installed')), isTrue);
|
||||
});
|
||||
|
||||
test('a failed doctor surfaces as a problem', () async {
|
||||
ipc.stub(
|
||||
'pql.doctor',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '1',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
|
||||
),
|
||||
);
|
||||
ipc.stub('pql.decisions.sync', (_) async => ok({'broken': 0}));
|
||||
final c = ProblemsController(ipc: ipc);
|
||||
await c.refresh();
|
||||
expect(c.problems.any((p) => p.message.contains('doctor failed')), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,4 +44,17 @@ void main() {
|
||||
expect(f.services.settings.get<bool>('app.tools.detected'), isTrue);
|
||||
expect(activeSupporterBinaries, isNotNull);
|
||||
});
|
||||
|
||||
test('exposes its id, title, and version', () {
|
||||
final ext = ToolsSettingsExtension();
|
||||
expect(ext.id, 'builtin.tools-settings');
|
||||
expect(ext.title, isNotEmpty);
|
||||
expect(ext.version, isNotEmpty);
|
||||
});
|
||||
|
||||
test('deactivate removes the live-sync listener', () async {
|
||||
await f.services.extensions.deactivate('builtin.tools-settings');
|
||||
// Listener gone — a settings write no longer rebuilds the resolver, no throw.
|
||||
await f.services.settings.set<String>(supporterToolKey('d2'), '/x/d2');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +74,21 @@ void main() {
|
||||
expect(published.single.data['svg'], '<svg id="raw"/>');
|
||||
});
|
||||
|
||||
test('an empty --file value is a userError', () async {
|
||||
wire();
|
||||
final r = await draw('');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('valid JSON that is not an object is a userError', () async {
|
||||
wire();
|
||||
files['arr.json'] = '[1,2,3]';
|
||||
final r = await draw('arr.json');
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('a missing file → notFound, nothing published', () async {
|
||||
wire();
|
||||
final r = await draw('nope.json');
|
||||
|
||||
@@ -84,6 +84,19 @@ void main() {
|
||||
expect(published.single.data['color'], 'red');
|
||||
});
|
||||
|
||||
test('an invalid card-level --color is an honest userError', () async {
|
||||
wire();
|
||||
final r = await show(['gear'], flags: {'color': 'notacolor'});
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('a missing --file is notFound', () async {
|
||||
wire();
|
||||
final r = await show([], flags: {'file': 'gone.json'});
|
||||
expect(r.error?.kind, IpcErrorKind.notFound);
|
||||
});
|
||||
|
||||
test('an unknown glyph name is an honest userError', () async {
|
||||
wire();
|
||||
final r = await show(['notaglyph']);
|
||||
@@ -97,6 +110,12 @@ void main() {
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('an unknown icon inside a --file entry is a userError', () async {
|
||||
wire(files: {'i.json': '[{"icon":"notaglyph"}]'});
|
||||
final r = await show([], flags: {'file': 'i.json'});
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('a malformed --file is a userError', () async {
|
||||
wire(files: {'i.json': 'not json'});
|
||||
final r = await show([], flags: {'file': 'i.json'});
|
||||
|
||||
@@ -55,5 +55,18 @@ void main() {
|
||||
final r = await compile(run: (exe, src) async => throw 'ENOENT');
|
||||
expect((r as DrawErr).message, contains('could not run d2'));
|
||||
});
|
||||
|
||||
test('spawns the resolved binary over stdin — real process (covers _spawnD2)', () async {
|
||||
// /bin/cat stands in for d2: `cat - -` echoes stdin (the source) to stdout.
|
||||
final r = await d2CompileViaBinary('<svg>hi</svg>', resolveD2: () => '/bin/cat');
|
||||
expect((r as DrawOk).svg, contains('hi'));
|
||||
}, testOn: 'linux || mac-os');
|
||||
|
||||
test('the default resolver runs when resolveD2 is not injected', () async {
|
||||
// run is injected so there is no real spawn; the default resolver either
|
||||
// finds d2 or not — either way exercises _defaultResolveD2.
|
||||
final r = await d2CompileViaBinary('a -> b', run: (exe, src) async => (code: 0, out: '<svg/>', err: ''));
|
||||
expect(r, anyOf(isA<DrawOk>(), isA<DrawErr>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ void main() {
|
||||
Future<DrawResult> resolve(DrawingCardDoc doc, DrawingRegistry reg, {Map<String, String> files = const {}}) =>
|
||||
resolveDrawingSvg(doc, reg, readFile: reader(files));
|
||||
|
||||
test('a fresh registry is empty; registering a handler fills it', () {
|
||||
expect(DrawingRegistry().isEmpty, isTrue);
|
||||
final reg = DrawingRegistry()..register('x', (_) async => const DrawOk('<svg/>'));
|
||||
expect(reg.isEmpty, isFalse);
|
||||
expect(reg.handlerFor('x'), isNotNull);
|
||||
});
|
||||
|
||||
group('resolveDrawingSvg', () {
|
||||
test('primitive: inline svg passes through', () async {
|
||||
final r = await resolve(parseDrawingCardDoc({'svg': '<svg id="x"/>'})!, DrawingRegistry());
|
||||
|
||||
@@ -54,6 +54,20 @@ void main() {
|
||||
expect((r as DrawErr).message, contains('duplicate'));
|
||||
});
|
||||
|
||||
test('an edge from an unknown node is an error', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'a'},
|
||||
],
|
||||
'edges': [
|
||||
{'from': 'ghost', 'to': 'a'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((r as DrawErr).message, contains('ghost'));
|
||||
});
|
||||
|
||||
test('an edge to an unknown node is an error', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
|
||||
@@ -155,5 +155,40 @@ void main() {
|
||||
test('a group with data-label is skipped — annotations anchor leaf shapes', () {
|
||||
expect(buildSvgDocument('<svg><g data-label="grp"><rect width="10" height="10"/></g></svg>').annotations, isEmpty);
|
||||
});
|
||||
|
||||
test('computes a bounding box for every leaf shape type (T-318)', () {
|
||||
final d = buildSvgDocument(
|
||||
'<svg viewBox="0 0 100 100">'
|
||||
'<ellipse cx="50" cy="40" rx="20" ry="10" data-label="e"/>'
|
||||
'<line x1="0" y1="0" x2="30" y2="40" data-label="l"/>'
|
||||
'<polyline points="0,0 10,20 30,5" data-label="p"/>'
|
||||
'<path d="M0 0 L10 10 C20 20 30 0 40 10 Q50 20 60 0 A5 5 0 0 1 70 5 Z" data-label="pa"/>'
|
||||
'<text x="5" y="15" data-label="t">hi</text>'
|
||||
'</svg>',
|
||||
);
|
||||
expect(d.annotations.map((a) => a.label), containsAll(['e', 'l', 'p', 'pa', 't']));
|
||||
// ellipse bbox = [cx-rx, cy-ry, 2rx, 2ry].
|
||||
final e = d.annotations.firstWhere((a) => a.label == 'e');
|
||||
expect([e.x, e.y, e.width, e.height], [30, 30, 40, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildSvgDocument — style attribute vocabulary', () {
|
||||
test('parses every stroke/text style branch', () {
|
||||
// Exercises _cap/_join/_anchor/_baseline/_weight/_dash.
|
||||
final kids = buildSvgDocument(
|
||||
'<svg viewBox="0 0 10 10">'
|
||||
'<line x1="0" y1="0" x2="9" y2="9" stroke="#000" stroke-linecap="square" stroke-linejoin="bevel" stroke-dasharray="2 1"/>'
|
||||
'<line x1="0" y1="0" x2="9" y2="9" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
'<text x="1" y="5" text-anchor="middle" dominant-baseline="hanging" font-weight="bold">a</text>'
|
||||
'<text x="1" y="8" text-anchor="end" dominant-baseline="central" font-weight="600">b</text>'
|
||||
'</svg>',
|
||||
).root.children;
|
||||
expect(kids, hasLength(4)); // all parsed; every style branch hit
|
||||
});
|
||||
|
||||
test('stroke-dasharray="none" yields no dashes', () {
|
||||
expect(buildSvgDocument('<svg><line x1="0" y1="0" x2="9" y2="9" stroke="#000" stroke-dasharray="none"/></svg>').root.children, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,4 +135,34 @@ void main() {
|
||||
}
|
||||
expect(anyInk, isTrue);
|
||||
});
|
||||
|
||||
const markerDefs =
|
||||
'<defs><marker id="a" orient="auto" markerWidth="6" markerHeight="6" refX="3" refY="3"><path d="M0 0 L6 3 L0 6 Z" fill="#000"/></marker></defs>';
|
||||
|
||||
test('renders quad/arc/close path segments with markers (T-320)', () async {
|
||||
// Each path leads with a different first-draw op to exercise every
|
||||
// start-angle branch in the marker placement + the segPath builder.
|
||||
for (final d in ['M5 25 Q20 5 35 25 Z', 'M5 25 A8 8 0 0 1 50 25', 'M5 25 Z']) {
|
||||
final svg = '<svg viewBox="0 0 100 50">$markerDefs<path d="$d" stroke="#f00" fill="none" marker-start="url(#a)" marker-end="url(#a)"/></svg>';
|
||||
final img = await render(svg, 100, 50);
|
||||
expect(img.width, 100);
|
||||
}
|
||||
});
|
||||
|
||||
test('a line carries start + end markers (T-320)', () async {
|
||||
final svg = '<svg viewBox="0 0 50 50">$markerDefs<line x1="0" y1="0" x2="40" y2="40" stroke="#00f" marker-start="url(#a)" marker-end="url(#a)"/></svg>';
|
||||
final img = await render(svg, 50, 50);
|
||||
expect(img.width, 50);
|
||||
});
|
||||
|
||||
test('renders text anchors/baselines/weights + a fill-opacity (T-320)', () async {
|
||||
const svg =
|
||||
'<svg viewBox="0 0 40 20">'
|
||||
'<rect width="40" height="20" fill="#FF0000" fill-opacity="0.5"/>'
|
||||
'<text x="20" y="10" text-anchor="middle" dominant-baseline="middle" font-weight="bold" fill="#000">M</text>'
|
||||
'<text x="20" y="18" text-anchor="end" dominant-baseline="hanging" font-weight="300">e</text>'
|
||||
'</svg>';
|
||||
final img = await render(svg, 40, 20);
|
||||
expect(img.width, 40); // exercised the anchor/baseline/weight + fill-opacity color paths
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user