feat(icon): icon.show handler + variadic positional schema (T-313)
icon.show resolves Phosphor glyphs by name (injected resolver) or a 0xNNNN
codepoint, reads a --file JSON array of {icon,label,description,color}
entries, validates colors via parseSvgColor (hex or CSS name), and
publishes on the `icon` bus channel. Honest userError on an unknown glyph,
a bad color, or a malformed payload. A trailing stringList positional is
now variadic so `icon show gear folder gauge` collects every token.
Flutter-free, dart-tested. Card widget + wiring next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/// Tests for `icon.show` — the CLI that drives a Phosphor glyph card into the
|
||||
/// Claude conversation (T-313, D-6 parity). Verifies glyph resolution (name +
|
||||
/// 0xNNNN), the --file metadata payload (label/description/color), the published
|
||||
/// `icon` payload, and honest failure on unknown glyphs / colors / no UI.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/icon_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late List<({String publisher, String channel, Map<String, Object?> data})> published;
|
||||
late DaemonDispatcher d;
|
||||
|
||||
const glyphs = {'gear': 0xe2a4, 'folder': 0xe24a, 'gauge': 0xe1d0};
|
||||
|
||||
void wire({bool liveUi = true, Map<String, String> files = const {}}) {
|
||||
published = [];
|
||||
d = DaemonDispatcher();
|
||||
registerIconCommands(
|
||||
d,
|
||||
() {
|
||||
if (!liveUi) return null;
|
||||
return (p, c, data) => published.add((publisher: p, channel: c, data: data));
|
||||
},
|
||||
resolve: (name) => glyphs[name],
|
||||
readFile: (path) async => files[path],
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> show(List<String> positional, {Map<String, Object?>? flags}) =>
|
||||
d.dispatch(IpcRequest(id: '1', cmd: 'icon.show', args: {'positional': positional, 'flags': ?flags}));
|
||||
|
||||
test('variadic positionals resolve to codepoint entries', () async {
|
||||
wire();
|
||||
final r = await show(['gear', 'folder']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['count'], 2);
|
||||
expect(published.single.channel, 'icon');
|
||||
expect(published.single.data['entries'], [
|
||||
{'codepoint': 0xe2a4, 'name': 'gear'},
|
||||
{'codepoint': 0xe24a, 'name': 'folder'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a 0xNNNN codepoint is accepted directly', () async {
|
||||
wire();
|
||||
final r = await show(['0xe2a4']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect((published.single.data['entries'] as List).single, {'codepoint': 0xe2a4, 'name': '0xe2a4'});
|
||||
});
|
||||
|
||||
test('--file entries carry label, description, and color', () async {
|
||||
wire(files: {'i.json': '[{"icon":"gear","label":"Settings","description":"global","color":"#e2b714"}]'});
|
||||
final r = await show([], flags: {'file': 'i.json'});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect((published.single.data['entries'] as List).single, {
|
||||
'codepoint': 0xe2a4,
|
||||
'name': 'gear',
|
||||
'label': 'Settings',
|
||||
'description': 'global',
|
||||
'color': '#e2b714',
|
||||
});
|
||||
});
|
||||
|
||||
test('a card-level --color rides along', () async {
|
||||
wire();
|
||||
final r = await show(['gear'], flags: {'color': 'red'});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data['color'], 'red');
|
||||
});
|
||||
|
||||
test('an unknown glyph name is an honest userError', () async {
|
||||
wire();
|
||||
final r = await show(['notaglyph']);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('an invalid color is an honest userError', () async {
|
||||
wire(files: {'i.json': '[{"icon":"gear","color":"notacolor"}]'});
|
||||
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'});
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('no icons at all is a userError', () async {
|
||||
wire();
|
||||
final r = await show([]);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('no live UI is a toolError, not a hang', () async {
|
||||
wire(liveUi: false);
|
||||
final r = await show(['gear']);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
}
|
||||
@@ -124,6 +124,29 @@ void main() {
|
||||
expect(out['to'], '300');
|
||||
});
|
||||
|
||||
test('a trailing stringList positional absorbs all remaining tokens (T-313)', () {
|
||||
const variadic = CommandSchema(
|
||||
positional: ['head', 'rest'],
|
||||
args: {
|
||||
'head': ArgSpec(),
|
||||
'rest': ArgSpec(type: ArgType.stringList),
|
||||
},
|
||||
);
|
||||
final out = variadic.normalize(const {
|
||||
'positional': ['a', 'b', 'c', 'd'],
|
||||
});
|
||||
expect(out['head'], 'a');
|
||||
expect(out['rest'], ['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('a variadic positional with no tokens normalizes to an empty list', () {
|
||||
const variadic = CommandSchema(
|
||||
positional: ['rest'],
|
||||
args: {'rest': ArgSpec(type: ArgType.stringList)},
|
||||
);
|
||||
expect(variadic.normalize(const {'positional': []})['rest'], const <Object?>[]);
|
||||
});
|
||||
|
||||
test('passthrough is carried over', () {
|
||||
final out = schema.normalize(const {
|
||||
'positional': ['x'],
|
||||
|
||||
Reference in New Issue
Block a user