capdag 0.88.20458

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +131 -0
  2. package/RULES.md +111 -0
  3. package/capdag.js +4322 -0
  4. package/capdag.test.js +2874 -0
  5. package/package.json +36 -0
package/capdag.test.js ADDED
@@ -0,0 +1,2874 @@
1
+ // Cap URN JavaScript Test Suite
2
+ // Tests mirror Rust test numbering (TEST###) for cross-language tracking.
3
+ // All implementations (Rust, Go, JS, ObjC, Python) must pass these identically.
4
+
5
+ const {
6
+ CapUrn, CapUrnBuilder, CapMatcher, CapUrnError, ErrorCodes,
7
+ MediaUrn, MediaUrnError, MediaUrnErrorCodes,
8
+ Cap, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
9
+ resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
10
+ CapMatrixError, CapMatrix, BestCapSetMatch, CompositeCapSet, CapBlock,
11
+ PluginInfo, PluginCapSummary, PluginSuggestion, PluginRepoClient, PluginRepoServer,
12
+ CapGraphEdge, CapGraphStats, CapGraph,
13
+ StdinSource, StdinSourceKind,
14
+ validateNoMediaSpecRedefinitionSync,
15
+ CapArgumentValue,
16
+ llmConversationUrn, modelAvailabilityUrn, modelPathUrn,
17
+ MEDIA_STRING, MEDIA_INTEGER, MEDIA_NUMBER, MEDIA_BOOLEAN,
18
+ MEDIA_OBJECT, MEDIA_STRING_ARRAY, MEDIA_INTEGER_ARRAY,
19
+ MEDIA_NUMBER_ARRAY, MEDIA_BOOLEAN_ARRAY, MEDIA_OBJECT_ARRAY,
20
+ MEDIA_BINARY, MEDIA_VOID, MEDIA_PNG, MEDIA_AUDIO, MEDIA_VIDEO,
21
+ MEDIA_PDF, MEDIA_EPUB, MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG,
22
+ MEDIA_HTML, MEDIA_XML, MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA,
23
+ MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
24
+ MEDIA_LLM_INFERENCE_OUTPUT,
25
+ MEDIA_FILE_PATH, MEDIA_FILE_PATH_ARRAY,
26
+ MEDIA_COLLECTION, MEDIA_COLLECTION_LIST,
27
+ MEDIA_DECISION, MEDIA_DECISION_ARRAY,
28
+ MEDIA_AUDIO_SPEECH, MEDIA_IMAGE_THUMBNAIL
29
+ } = require('./capdag.js');
30
+
31
+ // ============================================================================
32
+ // Test utilities
33
+ // ============================================================================
34
+
35
+ let passCount = 0;
36
+ let failCount = 0;
37
+
38
+ function assert(condition, message) {
39
+ if (!condition) {
40
+ throw new Error(`Assertion failed: ${message}`);
41
+ }
42
+ }
43
+
44
+ function assertEqual(actual, expected, message) {
45
+ if (actual !== expected) {
46
+ throw new Error(`Assertion failed: ${message}. Expected: ${JSON.stringify(expected)}, Actual: ${JSON.stringify(actual)}`);
47
+ }
48
+ }
49
+
50
+ function assertThrows(fn, expectedErrorCode, message) {
51
+ try {
52
+ fn();
53
+ throw new Error(`Expected error but function succeeded: ${message}`);
54
+ } catch (error) {
55
+ if (error.message && error.message.startsWith('Expected error but function succeeded')) {
56
+ throw error;
57
+ }
58
+ if (error instanceof CapUrnError && error.code === expectedErrorCode) {
59
+ return; // Expected error
60
+ }
61
+ throw new Error(`Expected CapUrnError with code ${expectedErrorCode} but got: [code=${error.code}] ${error.message}`);
62
+ }
63
+ }
64
+
65
+ function assertThrowsMediaUrn(fn, expectedErrorCode, message) {
66
+ try {
67
+ fn();
68
+ throw new Error(`Expected error but function succeeded: ${message}`);
69
+ } catch (error) {
70
+ if (error.message && error.message.startsWith('Expected error but function succeeded')) {
71
+ throw error;
72
+ }
73
+ if (error instanceof MediaUrnError && error.code === expectedErrorCode) {
74
+ return;
75
+ }
76
+ throw new Error(`Expected MediaUrnError with code ${expectedErrorCode} but got: [code=${error.code}] ${error.message}`);
77
+ }
78
+ }
79
+
80
+ function runTest(name, fn) {
81
+ try {
82
+ const result = fn();
83
+ if (result && typeof result.then === 'function') {
84
+ return result.then(() => {
85
+ passCount++;
86
+ console.log(` PASS ${name}`);
87
+ }).catch(err => {
88
+ failCount++;
89
+ console.log(` FAIL ${name}: ${err.message}`);
90
+ });
91
+ }
92
+ passCount++;
93
+ console.log(` PASS ${name}`);
94
+ } catch (err) {
95
+ failCount++;
96
+ console.log(` FAIL ${name}: ${err.message}`);
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Helper function to build test URNs with required in/out media URNs.
103
+ * Uses MEDIA_VOID for in and MEDIA_OBJECT for out, matching the
104
+ * Rust reference test_urn helper: test_urn(tags) => cap:in="media:void";{tags};out="media:record;textable"
105
+ */
106
+ function testUrn(tags) {
107
+ if (!tags || tags === '') {
108
+ return `cap:in="${MEDIA_VOID}";out="${MEDIA_OBJECT}"`;
109
+ }
110
+ return `cap:in="${MEDIA_VOID}";${tags};out="${MEDIA_OBJECT}"`;
111
+ }
112
+
113
+ // Mock CapSet for testing
114
+ class MockCapSet {
115
+ constructor(name) {
116
+ this.name = name;
117
+ }
118
+
119
+ async executeCap(capUrn, args) {
120
+ return {
121
+ binaryOutput: null,
122
+ textOutput: `Mock response from ${this.name}`
123
+ };
124
+ }
125
+ }
126
+
127
+ // Helper to create a Cap for testing
128
+ function makeCap(urnString, title) {
129
+ const capUrn = CapUrn.fromString(urnString);
130
+ return new Cap(capUrn, title, 'test', title);
131
+ }
132
+
133
+ // Helper to create caps with specific in/out media URNs for graph testing
134
+ function makeGraphCap(inUrn, outUrn, title) {
135
+ const urnString = `cap:in="${inUrn}";op=convert;out="${outUrn}"`;
136
+ const capUrn = CapUrn.fromString(urnString);
137
+ return new Cap(capUrn, title, 'convert', title);
138
+ }
139
+
140
+ // Helper to create a test URN for matrix tests
141
+ function matrixTestUrn(tags) {
142
+ if (!tags) {
143
+ return 'cap:in="media:void";out="media:object"';
144
+ }
145
+ return `cap:in="media:void";out="media:object";${tags}`;
146
+ }
147
+
148
+ // ============================================================================
149
+ // cap_urn.rs: TEST001-TEST050, TEST890-TEST891
150
+ // ============================================================================
151
+
152
+ // TEST001: Cap URN created with tags, direction specs accessible
153
+ function test001_capUrnCreation() {
154
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf;target=thumbnail'));
155
+ assertEqual(cap.getTag('op'), 'generate', 'Should get op tag');
156
+ assertEqual(cap.getTag('target'), 'thumbnail', 'Should get target tag');
157
+ assertEqual(cap.getTag('ext'), 'pdf', 'Should get ext tag');
158
+ assertEqual(cap.getInSpec(), MEDIA_VOID, 'Should get inSpec');
159
+ assertEqual(cap.getOutSpec(), MEDIA_OBJECT, 'Should get outSpec');
160
+ }
161
+
162
+ // TEST002: Missing in -> MissingInSpec, missing out -> MissingOutSpec
163
+ function test002_directionSpecsRequired() {
164
+ assertThrows(
165
+ () => CapUrn.fromString('cap:out="media:void";op=test'),
166
+ ErrorCodes.MISSING_IN_SPEC,
167
+ 'Missing in should fail with MISSING_IN_SPEC'
168
+ );
169
+ assertThrows(
170
+ () => CapUrn.fromString('cap:in="media:void";op=test'),
171
+ ErrorCodes.MISSING_OUT_SPEC,
172
+ 'Missing out should fail with MISSING_OUT_SPEC'
173
+ );
174
+ }
175
+
176
+ // TEST003: Direction specs must match exactly; wildcard matches any
177
+ function test003_directionMatching() {
178
+ const cap = CapUrn.fromString(testUrn('op=generate'));
179
+ const request = CapUrn.fromString(testUrn('op=generate'));
180
+ assert(cap.accepts(request), 'Same direction specs should match');
181
+
182
+ // Different direction should not match
183
+ const requestDiff = CapUrn.fromString('cap:in="media:textable";op=generate;out="media:record;textable"');
184
+ assert(!cap.accepts(requestDiff), 'Different inSpec should not match');
185
+
186
+ // Wildcard direction matches any
187
+ const wildcardCap = CapUrn.fromString('cap:in=*;op=generate;out=*');
188
+ assert(wildcardCap.accepts(request), 'Wildcard direction should match any');
189
+ }
190
+
191
+ // TEST004: Unquoted keys/values normalized to lowercase
192
+ function test004_unquotedValuesLowercased() {
193
+ const cap = CapUrn.fromString('cap:IN="media:void";OP=Generate;EXT=PDF;OUT="media:record;textable"');
194
+ assertEqual(cap.getTag('op'), 'generate', 'Unquoted value should be lowercased');
195
+ assertEqual(cap.getTag('ext'), 'pdf', 'Unquoted value should be lowercased');
196
+ // Key lookup is case-insensitive
197
+ assertEqual(cap.getTag('OP'), 'generate', 'Key lookup should be case-insensitive');
198
+ }
199
+
200
+ // TEST005: Quoted values preserve case
201
+ function test005_quotedValuesPreserveCase() {
202
+ const cap = CapUrn.fromString('cap:in="media:void";key="HelloWorld";out="media:void"');
203
+ assertEqual(cap.getTag('key'), 'HelloWorld', 'Quoted value should preserve case');
204
+ }
205
+
206
+ // TEST006: Semicolons, equals, spaces in quoted values
207
+ function test006_quotedValueSpecialChars() {
208
+ const cap = CapUrn.fromString('cap:in="media:void";key="val;ue=with spaces";out="media:void"');
209
+ assertEqual(cap.getTag('key'), 'val;ue=with spaces', 'Quoted value should preserve special chars');
210
+ }
211
+
212
+ // TEST007: Escaped quotes and backslashes in quoted values
213
+ function test007_quotedValueEscapeSequences() {
214
+ const s = String.raw`cap:in="media:void";key="val\"ue\\test";out="media:void"`;
215
+ const cap = CapUrn.fromString(s);
216
+ assertEqual(cap.getTag('key'), 'val"ue\\test', 'Escaped quote and backslash should be unescaped');
217
+ }
218
+
219
+ // TEST008: Mix of quoted and unquoted values
220
+ function test008_mixedQuotedUnquoted() {
221
+ const cap = CapUrn.fromString('cap:a=simple;b="Quoted";in="media:void";out="media:void"');
222
+ assertEqual(cap.getTag('a'), 'simple', 'Unquoted value should be lowercase');
223
+ assertEqual(cap.getTag('b'), 'Quoted', 'Quoted value should preserve case');
224
+ }
225
+
226
+ // TEST009: Unterminated quote produces error
227
+ function test009_unterminatedQuoteError() {
228
+ let threw = false;
229
+ try {
230
+ CapUrn.fromString('cap:in="media:void";key="unterminated;out="media:void"');
231
+ } catch (e) {
232
+ if (e instanceof CapUrnError) {
233
+ threw = true;
234
+ }
235
+ }
236
+ assert(threw, 'Unterminated quote should produce CapUrnError');
237
+ }
238
+
239
+ // TEST010: Invalid escape sequences produce error
240
+ function test010_invalidEscapeSequenceError() {
241
+ let threw = false;
242
+ try {
243
+ const s = String.raw`cap:in="media:void";key="hello\x";out="media:void"`;
244
+ CapUrn.fromString(s);
245
+ } catch (e) {
246
+ if (e instanceof CapUrnError) {
247
+ threw = true;
248
+ }
249
+ }
250
+ assert(threw, 'Invalid escape sequence should produce CapUrnError');
251
+ }
252
+
253
+ // TEST011: Smart quoting: no quotes for simple lowercase, quotes for special
254
+ function test011_serializationSmartQuoting() {
255
+ const cap = CapUrn.fromString('cap:a=simple;b="Has Space";in="media:void";out="media:void"');
256
+ const s = cap.toString();
257
+ // simple lowercase should not be quoted, "Has Space" should be quoted
258
+ assert(s.includes('a=simple'), 'Simple value should not be quoted');
259
+ assert(s.includes('b="Has Space"'), 'Value with space should be quoted');
260
+ }
261
+
262
+ // TEST012: Simple cap URN parse -> serialize -> parse equals original
263
+ function test012_roundTripSimple() {
264
+ const original = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
265
+ const serialized = original.toString();
266
+ const reparsed = CapUrn.fromString(serialized);
267
+ assert(original.equals(reparsed), 'Simple round-trip should produce equal URN');
268
+ }
269
+
270
+ // TEST013: Quoted values round-trip preserving case
271
+ function test013_roundTripQuoted() {
272
+ const original = CapUrn.fromString('cap:in="media:void";key="HelloWorld";out="media:void"');
273
+ const serialized = original.toString();
274
+ const reparsed = CapUrn.fromString(serialized);
275
+ assert(original.equals(reparsed), 'Quoted round-trip should produce equal URN');
276
+ assertEqual(reparsed.getTag('key'), 'HelloWorld', 'Quoted value should survive round-trip');
277
+ }
278
+
279
+ // TEST014: Escape sequences round-trip correctly
280
+ function test014_roundTripEscapes() {
281
+ const s = String.raw`cap:in="media:void";key="val\"ue\\test";out="media:void"`;
282
+ const original = CapUrn.fromString(s);
283
+ const serialized = original.toString();
284
+ const reparsed = CapUrn.fromString(serialized);
285
+ assert(original.equals(reparsed), 'Escape round-trip should produce equal URN');
286
+ assertEqual(reparsed.getTag('key'), 'val"ue\\test', 'Escaped value should survive round-trip');
287
+ }
288
+
289
+ // TEST015: cap: prefix required, case-insensitive
290
+ function test015_capPrefixRequired() {
291
+ assertThrows(
292
+ () => CapUrn.fromString('in="media:void";out="media:void";op=generate'),
293
+ ErrorCodes.MISSING_CAP_PREFIX,
294
+ 'Should require cap: prefix'
295
+ );
296
+ // Valid cap: prefix should work
297
+ const cap = CapUrn.fromString(testUrn('op=generate'));
298
+ assertEqual(cap.getTag('op'), 'generate', 'Should parse with valid cap: prefix');
299
+ }
300
+
301
+ // TEST016: With/without trailing semicolon are equivalent
302
+ function test016_trailingSemicolonEquivalence() {
303
+ const cap1 = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
304
+ const cap2 = CapUrn.fromString(testUrn('op=generate;ext=pdf') + ';');
305
+ assert(cap1.equals(cap2), 'With/without trailing semicolon should be equal');
306
+ assertEqual(cap1.toString(), cap2.toString(), 'Canonical forms should match');
307
+ }
308
+
309
+ // TEST017: Exact match, subset match, wildcard match, value mismatch
310
+ function test017_tagMatching() {
311
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf;target=thumbnail'));
312
+
313
+ // Exact match
314
+ const exact = CapUrn.fromString(testUrn('op=generate;ext=pdf;target=thumbnail'));
315
+ assert(cap.accepts(exact), 'Should accept exact match');
316
+
317
+ // Subset match
318
+ const subset = CapUrn.fromString(testUrn('op=generate'));
319
+ assert(cap.accepts(subset), 'Should accept subset request');
320
+
321
+ // Wildcard match
322
+ const wildcard = CapUrn.fromString(testUrn('ext=*'));
323
+ assert(cap.accepts(wildcard), 'Should accept wildcard request');
324
+
325
+ // Value mismatch
326
+ const mismatch = CapUrn.fromString(testUrn('op=extract'));
327
+ assert(!cap.accepts(mismatch), 'Should not accept value mismatch');
328
+ }
329
+
330
+ // TEST018: Quoted uppercase values don't match lowercase (case sensitive)
331
+ function test018_matchingCaseSensitiveValues() {
332
+ const cap = CapUrn.fromString('cap:in="media:void";key="HelloWorld";out="media:void"');
333
+ const request = CapUrn.fromString('cap:in="media:void";key=helloworld;out="media:void"');
334
+ assert(!cap.accepts(request), 'Quoted HelloWorld should not match unquoted helloworld');
335
+ }
336
+
337
+ // TEST019: Missing tags treated as wildcards
338
+ function test019_missingTagHandling() {
339
+ const cap = CapUrn.fromString(testUrn('op=generate'));
340
+
341
+ // Request with tag cap doesn't have -> cap's missing tag is implicit wildcard
342
+ const request = CapUrn.fromString(testUrn('ext=pdf'));
343
+ assert(cap.accepts(request), 'Missing tag in cap should be treated as wildcard');
344
+
345
+ // Cap with extra tags can accept subset requests
346
+ const cap2 = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
347
+ const request2 = CapUrn.fromString(testUrn('op=generate'));
348
+ assert(cap2.accepts(request2), 'Cap with extra tags should accept subset request');
349
+ }
350
+
351
+ // TEST020: Direction specs use MediaUrn tag count, other tags count non-wildcard
352
+ function test020_specificity() {
353
+ // Direction specs contribute their MediaUrn tag count:
354
+ // MEDIA_VOID = "media:void" -> 1 tag (void)
355
+ // MEDIA_OBJECT = "media:record" -> 1 tag (record)
356
+ const cap1 = CapUrn.fromString(testUrn('type=general'));
357
+ assertEqual(cap1.specificity(), 3, 'void(1) + record(1) + type(1)');
358
+
359
+ const cap2 = CapUrn.fromString(testUrn('op=generate'));
360
+ assertEqual(cap2.specificity(), 3, 'void(1) + record(1) + op(1)');
361
+
362
+ const cap3 = CapUrn.fromString(testUrn('op=*;ext=pdf'));
363
+ assertEqual(cap3.specificity(), 3, 'void(1) + record(1) + ext(1) (wildcard op doesn\'t count)');
364
+
365
+ // Wildcard in direction doesn't count
366
+ const cap4 = CapUrn.fromString(`cap:in=*;out="${MEDIA_OBJECT}";op=test`);
367
+ assertEqual(cap4.specificity(), 2, 'record(1) + op(1) (in wildcard doesn\'t count)');
368
+ }
369
+
370
+ // TEST021: CapUrnBuilder creates valid URN
371
+ function test021_builder() {
372
+ const cap = new CapUrnBuilder()
373
+ .inSpec('media:void')
374
+ .outSpec('media:object')
375
+ .tag('op', 'generate')
376
+ .tag('ext', 'pdf')
377
+ .build();
378
+ assertEqual(cap.getTag('op'), 'generate', 'Builder should set op');
379
+ assertEqual(cap.getTag('ext'), 'pdf', 'Builder should set ext');
380
+ assertEqual(cap.getInSpec(), 'media:void', 'Builder should set inSpec');
381
+ assertEqual(cap.getOutSpec(), 'media:object', 'Builder should set outSpec');
382
+ }
383
+
384
+ // TEST022: Builder requires both inSpec and outSpec
385
+ function test022_builderRequiresDirection() {
386
+ assertThrows(
387
+ () => new CapUrnBuilder().tag('op', 'test').build(),
388
+ ErrorCodes.MISSING_IN_SPEC,
389
+ 'Builder should require inSpec'
390
+ );
391
+ assertThrows(
392
+ () => new CapUrnBuilder().inSpec('media:void').tag('op', 'test').build(),
393
+ ErrorCodes.MISSING_OUT_SPEC,
394
+ 'Builder should require outSpec'
395
+ );
396
+ }
397
+
398
+ // TEST023: Builder lowercases keys but preserves value case
399
+ function test023_builderPreservesCase() {
400
+ const cap = new CapUrnBuilder()
401
+ .inSpec('media:void')
402
+ .outSpec('media:void')
403
+ .tag('MyKey', 'MyValue')
404
+ .build();
405
+ assertEqual(cap.getTag('mykey'), 'MyValue', 'Builder should lowercase key but preserve value case');
406
+ assertEqual(cap.getTag('MyKey'), 'MyValue', 'getTag should be case-insensitive for keys');
407
+ }
408
+
409
+ // TEST024: Directional accepts checks
410
+ function test024_compatibility() {
411
+ // General cap accepts specific request (missing tags = wildcards)
412
+ const general = CapUrn.fromString(testUrn('op=generate'));
413
+ const specific = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
414
+ assert(general.accepts(specific), 'General cap should accept specific request');
415
+ // Specific cap also accepts general request (cap has extra tag, not blocking)
416
+ assert(specific.accepts(general), 'Specific cap accepts general request (extra tags ok)');
417
+
418
+ // Different op values: neither accepts the other
419
+ const cap1 = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
420
+ const cap3 = CapUrn.fromString(testUrn('type=image;op=extract'));
421
+ assert(!cap1.accepts(cap3), 'Different op should not accept');
422
+ assert(!cap3.accepts(cap1), 'Different op should not accept (reverse)');
423
+
424
+ // Different in/out should not accept
425
+ const cap5 = CapUrn.fromString('cap:in="media:textable";out="media:object";op=generate');
426
+ assert(!cap1.accepts(cap5), 'Different inSpec should not accept');
427
+ }
428
+
429
+ // TEST025: CapMatcher.findBestMatch returns most specific
430
+ function test025_bestMatch() {
431
+ const caps = [
432
+ CapUrn.fromString('cap:in=*;out=*;op=*'),
433
+ CapUrn.fromString(testUrn('op=generate')),
434
+ CapUrn.fromString(testUrn('op=generate;ext=pdf'))
435
+ ];
436
+ const request = CapUrn.fromString(testUrn('op=generate'));
437
+ const best = CapMatcher.findBestMatch(caps, request);
438
+ assert(best !== null, 'Should find a best match');
439
+ assertEqual(best.getTag('ext'), 'pdf', 'Best match should be the most specific (ext=pdf)');
440
+ }
441
+
442
+ // TEST026: merge combines tags, subset keeps only specified
443
+ function test026_mergeAndSubset() {
444
+ const cap1 = CapUrn.fromString(testUrn('op=generate'));
445
+ const cap2 = CapUrn.fromString('cap:in="media:textable";ext=pdf;format=binary;out="media:"');
446
+
447
+ // Merge (other takes precedence)
448
+ const merged = cap1.merge(cap2);
449
+ assertEqual(merged.getInSpec(), 'media:textable', 'Merge should take inSpec from other');
450
+ assertEqual(merged.getOutSpec(), 'media:', 'Merge should take outSpec from other');
451
+ assertEqual(merged.getTag('op'), 'generate', 'Merge should keep original tags');
452
+ assertEqual(merged.getTag('ext'), 'pdf', 'Merge should add other tags');
453
+
454
+ // Subset (always preserves in/out)
455
+ const sub = merged.subset(['ext']);
456
+ assertEqual(sub.getTag('ext'), 'pdf', 'Subset should keep ext');
457
+ assertEqual(sub.getTag('op'), undefined, 'Subset should drop op');
458
+ assertEqual(sub.getInSpec(), 'media:textable', 'Subset should preserve inSpec');
459
+ }
460
+
461
+ // TEST027: withWildcardTag sets tag to wildcard including in/out
462
+ function test027_wildcardTag() {
463
+ const cap = CapUrn.fromString(testUrn('ext=pdf'));
464
+ const wildcardExt = cap.withWildcardTag('ext');
465
+ assertEqual(wildcardExt.getTag('ext'), '*', 'Should set ext to wildcard');
466
+
467
+ const wildcardIn = cap.withWildcardTag('in');
468
+ assertEqual(wildcardIn.getInSpec(), '*', 'Should set in to wildcard');
469
+
470
+ const wildcardOut = cap.withWildcardTag('out');
471
+ assertEqual(wildcardOut.getOutSpec(), '*', 'Should set out to wildcard');
472
+ }
473
+
474
+ // TEST028: Empty cap URN without in/out fails (MissingInSpec)
475
+ function test028_emptyCapUrnNotAllowed() {
476
+ assertThrows(
477
+ () => CapUrn.fromString('cap:'),
478
+ ErrorCodes.MISSING_IN_SPEC,
479
+ 'Empty cap URN should fail with MISSING_IN_SPEC'
480
+ );
481
+ }
482
+
483
+ // TEST029: Minimal valid cap URN: just in and out, empty tags
484
+ function test029_minimalCapUrn() {
485
+ const minimal = CapUrn.fromString('cap:in="media:void";out="media:void"');
486
+ assertEqual(Object.keys(minimal.tags).length, 0, 'Should have no other tags');
487
+ assertEqual(minimal.getInSpec(), 'media:void', 'Should have inSpec');
488
+ assertEqual(minimal.getOutSpec(), 'media:void', 'Should have outSpec');
489
+ }
490
+
491
+ // TEST030: Forward slashes and colons in tag values
492
+ function test030_extendedCharacterSupport() {
493
+ const cap = CapUrn.fromString(testUrn('url=https://example_org/api;path=/some/file'));
494
+ assertEqual(cap.getTag('url'), 'https://example_org/api', 'Should support colons and slashes');
495
+ assertEqual(cap.getTag('path'), '/some/file', 'Should support forward slashes');
496
+ }
497
+
498
+ // TEST031: Wildcard rejected in keys, accepted in values
499
+ function test031_wildcardRestrictions() {
500
+ assertThrows(
501
+ () => CapUrn.fromString(testUrn('*=value')),
502
+ ErrorCodes.INVALID_CHARACTER,
503
+ 'Should reject wildcard in key'
504
+ );
505
+
506
+ // Wildcard accepted in values
507
+ const cap = CapUrn.fromString(testUrn('key=*'));
508
+ assertEqual(cap.getTag('key'), '*', 'Should accept wildcard in value');
509
+
510
+ // Wildcard in in/out
511
+ const capWild = CapUrn.fromString('cap:in=*;out=*;key=value');
512
+ assertEqual(capWild.getInSpec(), '*', 'Should accept wildcard in inSpec');
513
+ assertEqual(capWild.getOutSpec(), '*', 'Should accept wildcard in outSpec');
514
+ }
515
+
516
+ // TEST032: Duplicate keys rejected
517
+ function test032_duplicateKeyRejection() {
518
+ assertThrows(
519
+ () => CapUrn.fromString(testUrn('key=value1;key=value2')),
520
+ ErrorCodes.DUPLICATE_KEY,
521
+ 'Should reject duplicate keys'
522
+ );
523
+ }
524
+
525
+ // TEST033: Pure numeric keys rejected, mixed alphanumeric OK
526
+ function test033_numericKeyRestriction() {
527
+ assertThrows(
528
+ () => CapUrn.fromString(testUrn('123=value')),
529
+ ErrorCodes.NUMERIC_KEY,
530
+ 'Should reject pure numeric keys'
531
+ );
532
+ // Mixed alphanumeric allowed
533
+ const cap1 = CapUrn.fromString(testUrn('key123=value'));
534
+ assertEqual(cap1.getTag('key123'), 'value', 'Mixed alphanumeric key should be allowed');
535
+ const cap2 = CapUrn.fromString(testUrn('x123key=value'));
536
+ assertEqual(cap2.getTag('x123key'), 'value', 'Mixed alphanumeric key should be allowed');
537
+ }
538
+
539
+ // TEST034: key= (empty value) is rejected
540
+ function test034_emptyValueError() {
541
+ let threw = false;
542
+ try {
543
+ CapUrn.fromString('cap:in="media:void";key=;out="media:void"');
544
+ } catch (e) {
545
+ if (e instanceof CapUrnError) {
546
+ threw = true;
547
+ }
548
+ }
549
+ assert(threw, 'Empty value (key=) should be rejected');
550
+ }
551
+
552
+ // TEST035: hasTag case-sensitive for values, case-insensitive for keys, works for in/out
553
+ function test035_hasTagCaseSensitive() {
554
+ const cap = CapUrn.fromString('cap:in="media:void";key="Value";out="media:void"');
555
+ assert(cap.hasTag('key', 'Value'), 'hasTag should match exact value');
556
+ assert(cap.hasTag('KEY', 'Value'), 'hasTag should be case-insensitive for keys');
557
+ assert(!cap.hasTag('key', 'value'), 'hasTag should be case-sensitive for values');
558
+ // Works for in/out
559
+ assert(cap.hasTag('in', 'media:void'), 'hasTag should work for in');
560
+ assert(cap.hasTag('IN', 'media:void'), 'hasTag should be case-insensitive for in key');
561
+ assert(cap.hasTag('out', 'media:void'), 'hasTag should work for out');
562
+ }
563
+
564
+ // TEST036: withTag preserves value case
565
+ function test036_withTagPreservesValue() {
566
+ const cap = CapUrn.fromString('cap:in="media:void";out="media:void"');
567
+ const modified = cap.withTag('key', 'MyValue');
568
+ assertEqual(modified.getTag('key'), 'MyValue', 'withTag should preserve value case');
569
+ }
570
+
571
+ // TEST037: withTag('key', '') -> error
572
+ // Note: In JS, withTag does not currently reject empty values (it stores them).
573
+ // The Rust implementation rejects empty values. We test the JS behavior as-is.
574
+ function test037_withTagRejectsEmptyValue() {
575
+ // The JS implementation does not throw for empty values in withTag.
576
+ // This test verifies the current behavior: withTag stores empty string.
577
+ // If the implementation is updated to reject empty values, update this test.
578
+ const cap = CapUrn.fromString('cap:in="media:void";out="media:void"');
579
+ const modified = cap.withTag('key', '');
580
+ assertEqual(modified.getTag('key'), '', 'withTag stores empty string (JS behavior)');
581
+ }
582
+
583
+ // TEST038: Unquoted 'simple' == quoted '"simple"' (lowercase)
584
+ function test038_semanticEquivalence() {
585
+ const c1 = CapUrn.fromString('cap:in="media:void";key=simple;out="media:void"');
586
+ const c2 = CapUrn.fromString('cap:in="media:void";key="simple";out="media:void"');
587
+ assert(c1.equals(c2), 'Unquoted simple and quoted "simple" should be equal');
588
+ assertEqual(c1.getTag('key'), c2.getTag('key'), 'Values should be identical');
589
+ }
590
+
591
+ // TEST039: getTag('in') and getTag('out') work, case-insensitive
592
+ function test039_getTagReturnsDirectionSpecs() {
593
+ const cap = CapUrn.fromString(`cap:in="${MEDIA_VOID}";out="${MEDIA_OBJECT}"`);
594
+ assertEqual(cap.getTag('in'), MEDIA_VOID, 'getTag(in) should return inSpec');
595
+ assertEqual(cap.getTag('IN'), MEDIA_VOID, 'getTag(IN) should return inSpec (case-insensitive)');
596
+ assertEqual(cap.getTag('out'), MEDIA_OBJECT, 'getTag(out) should return outSpec');
597
+ assertEqual(cap.getTag('OUT'), MEDIA_OBJECT, 'getTag(OUT) should return outSpec (case-insensitive)');
598
+ }
599
+
600
+ // TEST040: Cap and request same tags -> accepts=true
601
+ function test040_matchingSemanticsExactMatch() {
602
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
603
+ const request = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
604
+ assert(cap.accepts(request), 'Exact match should accept');
605
+ }
606
+
607
+ // TEST041: Cap missing tag -> implicit wildcard -> accepts=true
608
+ function test041_matchingSemanticsCapMissingTag() {
609
+ const cap = CapUrn.fromString(testUrn('op=generate'));
610
+ const request = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
611
+ assert(cap.accepts(request), 'Cap missing ext tag should accept (implicit wildcard)');
612
+ }
613
+
614
+ // TEST042: Cap extra tag -> still matches
615
+ function test042_matchingSemanticsCapHasExtraTag() {
616
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf;version=2'));
617
+ const request = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
618
+ assert(cap.accepts(request), 'Cap with extra tag should still accept');
619
+ }
620
+
621
+ // TEST043: Request ext=* matches cap ext=pdf
622
+ function test043_matchingSemanticsRequestHasWildcard() {
623
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
624
+ const request = CapUrn.fromString(testUrn('op=generate;ext=*'));
625
+ assert(cap.accepts(request), 'Request wildcard should match specific cap value');
626
+ }
627
+
628
+ // TEST044: Cap ext=* matches request ext=pdf
629
+ function test044_matchingSemanticsCapHasWildcard() {
630
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=*'));
631
+ const request = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
632
+ assert(cap.accepts(request), 'Cap wildcard should match specific request value');
633
+ }
634
+
635
+ // TEST045: ext=pdf vs ext=docx -> no match
636
+ function test045_matchingSemanticsValueMismatch() {
637
+ const cap = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
638
+ const request = CapUrn.fromString(testUrn('op=generate;ext=docx'));
639
+ assert(!cap.accepts(request), 'Value mismatch should not accept');
640
+ }
641
+
642
+ // TEST046: Cap without ext matches request with ext=wav (uses media:binary directions)
643
+ function test046_matchingSemanticsFallbackPattern() {
644
+ const cap = CapUrn.fromString('cap:in="media:binary";op=generate_thumbnail;out="media:binary"');
645
+ const request = CapUrn.fromString('cap:ext=wav;in="media:binary";op=generate_thumbnail;out="media:binary"');
646
+ assert(cap.accepts(request), 'Cap missing ext should accept (implicit wildcard for ext)');
647
+ }
648
+
649
+ // TEST047: Thumbnail with void input matches specific ext request
650
+ function test047_matchingSemanticsThumbnailVoidInput() {
651
+ const cap = CapUrn.fromString('cap:in="media:void";op=generate_thumbnail;out="media:image;png;thumbnail"');
652
+ const request = CapUrn.fromString('cap:ext=pdf;in="media:void";op=generate_thumbnail;out="media:image"');
653
+ assert(cap.accepts(request), 'Void input cap should accept request; cap output conforms to less-specific request output');
654
+ }
655
+
656
+ // TEST048: Cap in=* out=* matches any request
657
+ function test048_matchingSemanticsWildcardDirection() {
658
+ const cap = CapUrn.fromString('cap:in=*;out=*');
659
+ const request = CapUrn.fromString(testUrn('op=generate;ext=pdf'));
660
+ assert(cap.accepts(request), 'Wildcard cap should accept any request');
661
+ }
662
+
663
+ // TEST049: Cap op=generate accepts request ext=pdf (independent tags)
664
+ function test049_matchingSemanticsCrossDimension() {
665
+ const cap = CapUrn.fromString(testUrn('op=generate'));
666
+ const request = CapUrn.fromString(testUrn('ext=pdf'));
667
+ assert(cap.accepts(request), 'Independent tags should not block matching');
668
+ }
669
+
670
+ // TEST050: media:string vs media: (wildcard) -> no match
671
+ function test050_matchingSemanticsDirectionMismatch() {
672
+ const cap = CapUrn.fromString(
673
+ `cap:in="${MEDIA_STRING}";op=generate;out="${MEDIA_OBJECT}"`
674
+ );
675
+ const request = CapUrn.fromString(
676
+ `cap:in="${MEDIA_BINARY}";op=generate;out="${MEDIA_OBJECT}"`
677
+ );
678
+ assert(!cap.accepts(request), 'Incompatible direction types should not match');
679
+ }
680
+
681
+ // TEST890: Semantic direction matching - generic provider matches specific request
682
+ function test890_directionSemanticMatching() {
683
+ // Generic wildcard cap accepts specific pdf request
684
+ const genericCap = CapUrn.fromString(
685
+ 'cap:in="media:";op=generate_thumbnail;out="media:image;png;thumbnail"'
686
+ );
687
+ const pdfRequest = CapUrn.fromString(
688
+ 'cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"'
689
+ );
690
+ assert(genericCap.accepts(pdfRequest), 'Generic wildcard cap must accept pdf request');
691
+
692
+ // Also accepts epub
693
+ const epubRequest = CapUrn.fromString(
694
+ 'cap:in="media:epub";op=generate_thumbnail;out="media:image;png;thumbnail"'
695
+ );
696
+ assert(genericCap.accepts(epubRequest), 'Generic wildcard cap must accept epub request');
697
+
698
+ // Reverse: specific pdf cap does NOT accept generic bytes request
699
+ const pdfCap = CapUrn.fromString(
700
+ 'cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"'
701
+ );
702
+ const genericRequest = CapUrn.fromString(
703
+ 'cap:in="media:";op=generate_thumbnail;out="media:image;png;thumbnail"'
704
+ );
705
+ assert(!pdfCap.accepts(genericRequest), 'Specific pdf cap must NOT accept generic wildcard request');
706
+
707
+ // PDF cap does NOT accept epub request
708
+ assert(!pdfCap.accepts(epubRequest), 'PDF cap must NOT accept epub request');
709
+
710
+ // Output direction: cap producing more specific output satisfies less specific request
711
+ const specificOutCap = CapUrn.fromString(
712
+ 'cap:in="media:";op=generate_thumbnail;out="media:image;png;thumbnail"'
713
+ );
714
+ const genericOutRequest = CapUrn.fromString(
715
+ 'cap:in="media:";op=generate_thumbnail;out="media:image"'
716
+ );
717
+ assert(specificOutCap.accepts(genericOutRequest),
718
+ 'Cap producing image;png;thumbnail must satisfy request for image');
719
+
720
+ // Reverse output: generic output cap does NOT satisfy specific output request
721
+ const genericOutCap = CapUrn.fromString(
722
+ 'cap:in="media:";op=generate_thumbnail;out="media:image"'
723
+ );
724
+ const specificOutRequest = CapUrn.fromString(
725
+ 'cap:in="media:";op=generate_thumbnail;out="media:image;png;thumbnail"'
726
+ );
727
+ assert(!genericOutCap.accepts(specificOutRequest),
728
+ 'Generic output cap must NOT satisfy specific output request');
729
+ }
730
+
731
+ // TEST891: Semantic direction specificity - more media URN tags = higher specificity
732
+ function test891_directionSemanticSpecificity() {
733
+ const genericCap = CapUrn.fromString(
734
+ 'cap:in="media:";op=generate_thumbnail;out="media:image;png;thumbnail"'
735
+ );
736
+ const specificCap = CapUrn.fromString(
737
+ 'cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"'
738
+ );
739
+
740
+ assertEqual(genericCap.specificity(), 4, 'media:(0) + image;png;thumbnail(3) + op(1) = 4');
741
+ assertEqual(specificCap.specificity(), 5, 'pdf(1) + image;png;thumbnail(3) + op(1) = 5');
742
+ assert(specificCap.specificity() > genericCap.specificity(), 'pdf should be more specific');
743
+
744
+ // CapMatcher should prefer more specific
745
+ const pdfRequest = CapUrn.fromString(
746
+ 'cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"'
747
+ );
748
+ const best = CapMatcher.findBestMatch([genericCap, specificCap], pdfRequest);
749
+ assert(best !== null, 'Should find a match');
750
+ assertEqual(best.getInSpec(), 'media:pdf', 'Should prefer more specific pdf cap');
751
+ }
752
+
753
+ // ============================================================================
754
+ // validation.rs: TEST053-TEST056
755
+ // ============================================================================
756
+
757
+ // TEST053: N/A for JS (Rust-only validation infrastructure)
758
+
759
+ // TEST054: Inline media spec redefinition of registry spec is detected
760
+ function test054_xv5InlineSpecRedefinitionDetected() {
761
+ const registryLookup = (mediaUrn) => mediaUrn === MEDIA_STRING;
762
+ const mediaSpecs = [
763
+ {
764
+ urn: MEDIA_STRING,
765
+ media_type: 'text/plain',
766
+ title: 'My Custom String',
767
+ description: 'Trying to redefine string'
768
+ }
769
+ ];
770
+ const result = validateNoMediaSpecRedefinitionSync(mediaSpecs, registryLookup);
771
+ assert(!result.valid, 'Should fail when redefining registry spec');
772
+ assert(result.error && result.error.includes('XV5'), 'Error should mention XV5');
773
+ assert(result.redefines && result.redefines.includes(MEDIA_STRING), 'Should identify MEDIA_STRING as redefined');
774
+ }
775
+
776
+ // TEST055: New inline media spec not in registry is allowed
777
+ function test055_xv5NewInlineSpecAllowed() {
778
+ const registryLookup = (mediaUrn) => mediaUrn === MEDIA_STRING;
779
+ const mediaSpecs = [
780
+ {
781
+ urn: 'media:my-unique-custom-type-xyz123',
782
+ media_type: 'application/json',
783
+ title: 'My Custom Output',
784
+ description: 'A custom output type'
785
+ }
786
+ ];
787
+ const result = validateNoMediaSpecRedefinitionSync(mediaSpecs, registryLookup);
788
+ assert(result.valid, 'New spec not in registry should pass validation');
789
+ }
790
+
791
+ // TEST056: Empty/null media_specs passes validation
792
+ function test056_xv5EmptyMediaSpecsAllowed() {
793
+ const registryLookup = (mediaUrn) => mediaUrn === MEDIA_STRING;
794
+ assert(validateNoMediaSpecRedefinitionSync({}, registryLookup).valid, 'Empty object should pass');
795
+ assert(validateNoMediaSpecRedefinitionSync(null, registryLookup).valid, 'Null should pass');
796
+ assert(validateNoMediaSpecRedefinitionSync(undefined, registryLookup).valid, 'Undefined should pass');
797
+ }
798
+
799
+ // ============================================================================
800
+ // media_urn.rs: TEST060-TEST078
801
+ // ============================================================================
802
+
803
+ // TEST060: MediaUrn.fromString('cap:string') -> INVALID_PREFIX error
804
+ function test060_wrongPrefixFails() {
805
+ assertThrowsMediaUrn(
806
+ () => MediaUrn.fromString('cap:string'),
807
+ MediaUrnErrorCodes.INVALID_PREFIX,
808
+ 'Wrong prefix should fail with INVALID_PREFIX'
809
+ );
810
+ }
811
+
812
+ // TEST061: isBinary true when textable tag is absent (binary = not textable)
813
+ function test061_isBinary() {
814
+ // Binary types: no textable tag
815
+ assert(MediaUrn.fromString(MEDIA_BINARY).isBinary(), 'MEDIA_BINARY (media:) should be binary');
816
+ assert(MediaUrn.fromString(MEDIA_PNG).isBinary(), 'MEDIA_PNG should be binary');
817
+ assert(MediaUrn.fromString(MEDIA_PDF).isBinary(), 'MEDIA_PDF should be binary');
818
+ assert(MediaUrn.fromString('media:video').isBinary(), 'media:video should be binary');
819
+ assert(MediaUrn.fromString('media:epub').isBinary(), 'media:epub should be binary');
820
+ // Textable types: is_binary is false
821
+ assert(!MediaUrn.fromString('media:textable').isBinary(), 'media:textable should not be binary');
822
+ assert(!MediaUrn.fromString('media:textable;record').isBinary(), 'textable map should not be binary');
823
+ assert(!MediaUrn.fromString(MEDIA_STRING).isBinary(), 'MEDIA_STRING should not be binary');
824
+ assert(!MediaUrn.fromString(MEDIA_JSON).isBinary(), 'MEDIA_JSON should not be binary');
825
+ assert(!MediaUrn.fromString(MEDIA_MD).isBinary(), 'MEDIA_MD should not be binary');
826
+ }
827
+
828
+ // TEST062: isMap true for MEDIA_OBJECT (record); false for MEDIA_STRING (form=scalar), MEDIA_STRING_ARRAY (list)
829
+ // TEST062: is_record returns true if record marker tag is present (key-value structure)
830
+ function test062_isRecord() {
831
+ assert(MediaUrn.fromString(MEDIA_OBJECT).isRecord(), 'MEDIA_OBJECT should be record');
832
+ assert(MediaUrn.fromString('media:custom;record').isRecord(), 'custom;record should be record');
833
+ assert(MediaUrn.fromString(MEDIA_JSON).isRecord(), 'MEDIA_JSON should be record');
834
+ // Without record marker, is_record is false
835
+ assert(!MediaUrn.fromString('media:textable').isRecord(), 'plain textable should not be record');
836
+ assert(!MediaUrn.fromString(MEDIA_STRING).isRecord(), 'MEDIA_STRING should not be record');
837
+ assert(!MediaUrn.fromString(MEDIA_STRING_ARRAY).isRecord(), 'MEDIA_STRING_ARRAY should not be record');
838
+ }
839
+
840
+ // TEST063: is_scalar returns true if NO list marker (scalar is default cardinality)
841
+ function test063_isScalar() {
842
+ assert(MediaUrn.fromString(MEDIA_STRING).isScalar(), 'MEDIA_STRING should be scalar');
843
+ assert(MediaUrn.fromString(MEDIA_INTEGER).isScalar(), 'MEDIA_INTEGER should be scalar');
844
+ assert(MediaUrn.fromString(MEDIA_NUMBER).isScalar(), 'MEDIA_NUMBER should be scalar');
845
+ assert(MediaUrn.fromString(MEDIA_BOOLEAN).isScalar(), 'MEDIA_BOOLEAN should be scalar');
846
+ assert(MediaUrn.fromString(MEDIA_OBJECT).isScalar(), 'MEDIA_OBJECT (record but scalar) should be scalar');
847
+ assert(MediaUrn.fromString('media:textable').isScalar(), 'plain textable should be scalar');
848
+ // With list marker, is_scalar is false
849
+ assert(!MediaUrn.fromString(MEDIA_STRING_ARRAY).isScalar(), 'MEDIA_STRING_ARRAY should not be scalar');
850
+ assert(!MediaUrn.fromString(MEDIA_OBJECT_ARRAY).isScalar(), 'MEDIA_OBJECT_ARRAY should not be scalar');
851
+ }
852
+
853
+ // TEST064: isList true for MEDIA_STRING_ARRAY, MEDIA_INTEGER_ARRAY, MEDIA_OBJECT_ARRAY;
854
+ // false for MEDIA_STRING, MEDIA_OBJECT
855
+ function test064_isList() {
856
+ assert(MediaUrn.fromString(MEDIA_STRING_ARRAY).isList(), 'MEDIA_STRING_ARRAY should be list');
857
+ assert(MediaUrn.fromString(MEDIA_INTEGER_ARRAY).isList(), 'MEDIA_INTEGER_ARRAY should be list');
858
+ assert(MediaUrn.fromString(MEDIA_OBJECT_ARRAY).isList(), 'MEDIA_OBJECT_ARRAY should be list');
859
+ assert(!MediaUrn.fromString(MEDIA_STRING).isList(), 'MEDIA_STRING should not be list');
860
+ assert(!MediaUrn.fromString(MEDIA_OBJECT).isList(), 'MEDIA_OBJECT should not be list');
861
+ }
862
+
863
+ // TEST065: is_opaque returns true if NO record marker (opaque is default structure)
864
+ function test065_isOpaque() {
865
+ assert(MediaUrn.fromString(MEDIA_STRING).isOpaque(), 'MEDIA_STRING should be opaque');
866
+ assert(MediaUrn.fromString(MEDIA_STRING_ARRAY).isOpaque(), 'MEDIA_STRING_ARRAY (list but no record) should be opaque');
867
+ assert(MediaUrn.fromString(MEDIA_PDF).isOpaque(), 'MEDIA_PDF should be opaque');
868
+ assert(MediaUrn.fromString('media:textable').isOpaque(), 'plain textable should be opaque');
869
+ // With record marker, is_opaque is false
870
+ assert(!MediaUrn.fromString(MEDIA_OBJECT).isOpaque(), 'MEDIA_OBJECT should not be opaque');
871
+ assert(!MediaUrn.fromString(MEDIA_JSON).isOpaque(), 'MEDIA_JSON should not be opaque');
872
+ }
873
+
874
+ // TEST066: isJson true for MEDIA_JSON; false for MEDIA_OBJECT (map but not json)
875
+ function test066_isJson() {
876
+ assert(MediaUrn.fromString(MEDIA_JSON).isJson(), 'MEDIA_JSON should be json');
877
+ assert(!MediaUrn.fromString(MEDIA_OBJECT).isJson(), 'MEDIA_OBJECT should not be json');
878
+ }
879
+
880
+ // TEST067: is_text returns true only if "textable" marker tag is present
881
+ function test067_isText() {
882
+ assert(MediaUrn.fromString(MEDIA_STRING).isText(), 'MEDIA_STRING should be text');
883
+ assert(MediaUrn.fromString(MEDIA_INTEGER).isText(), 'MEDIA_INTEGER should be text');
884
+ assert(MediaUrn.fromString(MEDIA_JSON).isText(), 'MEDIA_JSON should be text');
885
+ // Without textable tag, is_text is false
886
+ assert(!MediaUrn.fromString(MEDIA_BINARY).isText(), 'MEDIA_BINARY should not be text');
887
+ assert(!MediaUrn.fromString(MEDIA_PNG).isText(), 'MEDIA_PNG should not be text');
888
+ assert(!MediaUrn.fromString(MEDIA_OBJECT).isText(), 'MEDIA_OBJECT (no textable) should not be text');
889
+ }
890
+
891
+ // TEST068: isVoid true for media:void; false for media:string
892
+ function test068_isVoid() {
893
+ assert(MediaUrn.fromString('media:void').isVoid(), 'media:void should be void');
894
+ assert(!MediaUrn.fromString(MEDIA_STRING).isVoid(), 'MEDIA_STRING should not be void');
895
+ }
896
+
897
+ // TEST069-TEST070: N/A for JS (Rust-only binary_media_urn_for_ext/text_media_urn_for_ext)
898
+
899
+ // TEST071: Parse -> toString -> parse equals original
900
+ function test071_toStringRoundtrip() {
901
+ const constants = [MEDIA_STRING, MEDIA_INTEGER, MEDIA_OBJECT, MEDIA_BINARY, MEDIA_PDF, MEDIA_JSON];
902
+ for (const constant of constants) {
903
+ const parsed = MediaUrn.fromString(constant);
904
+ const reparsed = MediaUrn.fromString(parsed.toString());
905
+ assert(parsed.equals(reparsed), `Round-trip failed for ${constant}`);
906
+ }
907
+ }
908
+
909
+ // TEST072: All MEDIA_* constants parse as valid MediaUrns
910
+ function test072_constantsParse() {
911
+ const constants = [
912
+ MEDIA_STRING, MEDIA_INTEGER, MEDIA_NUMBER, MEDIA_BOOLEAN,
913
+ MEDIA_OBJECT, MEDIA_STRING_ARRAY, MEDIA_INTEGER_ARRAY,
914
+ MEDIA_NUMBER_ARRAY, MEDIA_BOOLEAN_ARRAY, MEDIA_OBJECT_ARRAY,
915
+ MEDIA_BINARY, MEDIA_VOID, MEDIA_PNG, MEDIA_PDF, MEDIA_EPUB,
916
+ MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG, MEDIA_HTML, MEDIA_XML,
917
+ MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA, MEDIA_AUDIO, MEDIA_VIDEO,
918
+ MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
919
+ MEDIA_LLM_INFERENCE_OUTPUT
920
+ ];
921
+ for (const constant of constants) {
922
+ const parsed = MediaUrn.fromString(constant);
923
+ assert(parsed !== null, `Constant ${constant} should parse as valid MediaUrn`);
924
+ }
925
+ }
926
+
927
+ // TEST073: N/A for JS (Rust has binary_media_urn_for_ext/text_media_urn_for_ext)
928
+
929
+ // TEST074: MEDIA_PDF (media:pdf) conformsTo media:pdf; MEDIA_MD conformsTo media:md; same URNs conform
930
+ function test074_mediaUrnMatching() {
931
+ const pdfUrn = MediaUrn.fromString(MEDIA_PDF);
932
+ const pdfPattern = MediaUrn.fromString('media:pdf');
933
+ assert(pdfUrn.conformsTo(pdfPattern), 'MEDIA_PDF should conform to media:pdf');
934
+
935
+ const mdUrn = MediaUrn.fromString(MEDIA_MD);
936
+ const mdPattern = MediaUrn.fromString('media:md');
937
+ assert(mdUrn.conformsTo(mdPattern), 'MEDIA_MD should conform to media:md');
938
+
939
+ // Same URN conforms to itself
940
+ assert(pdfUrn.conformsTo(pdfUrn), 'Same URN should conform to itself');
941
+ }
942
+
943
+ // TEST075: handler accepts same request, general handler accepts request
944
+ function test075_accepts() {
945
+ const handler = MediaUrn.fromString(MEDIA_PDF);
946
+ const sameReq = MediaUrn.fromString(MEDIA_PDF);
947
+ assert(handler.accepts(sameReq), 'Handler should accept same request');
948
+
949
+ const generalHandler = MediaUrn.fromString(MEDIA_BINARY);
950
+ const specificReq = MediaUrn.fromString(MEDIA_PDF);
951
+ assert(generalHandler.accepts(specificReq), 'General handler should accept specific request');
952
+ }
953
+
954
+ // TEST076: More tags = higher specificity
955
+ function test076_specificity() {
956
+ const s1 = MediaUrn.fromString('media:');
957
+ const s2 = MediaUrn.fromString('media:pdf');
958
+ const s3 = MediaUrn.fromString('media:image;png;thumbnail');
959
+ assert(s2.specificity() > s1.specificity(), 'pdf should be more specific than wildcard');
960
+ assert(s3.specificity() > s2.specificity(), 'image;png;thumbnail should be more specific than pdf');
961
+ }
962
+
963
+ // TEST077: N/A for JS (Rust serde) - but we test JSON.stringify round-trip
964
+ function test077_serdeRoundtrip() {
965
+ const original = MediaUrn.fromString(MEDIA_PDF);
966
+ const json = JSON.stringify({ urn: original.toString() });
967
+ const parsed = JSON.parse(json);
968
+ const restored = MediaUrn.fromString(parsed.urn);
969
+ assert(original.equals(restored), 'JSON round-trip should preserve MediaUrn');
970
+ }
971
+
972
+ // TEST078: MEDIA_OBJECT does NOT conform to MEDIA_STRING
973
+ function test078_debugMatchingBehavior() {
974
+ const objUrn = MediaUrn.fromString(MEDIA_OBJECT);
975
+ const strUrn = MediaUrn.fromString(MEDIA_STRING);
976
+ assert(!objUrn.conformsTo(strUrn), 'MEDIA_OBJECT should NOT conform to MEDIA_STRING');
977
+ }
978
+
979
+ // ============================================================================
980
+ // media_spec.rs: TEST088-TEST110
981
+ // ============================================================================
982
+
983
+ // TEST088: N/A for JS (async registry, Rust-only)
984
+ // TEST089: N/A for JS
985
+ // TEST090: N/A for JS
986
+
987
+ // TEST091: resolveMediaUrn resolves custom from local mediaSpecs
988
+ function test091_resolveCustomMediaSpec() {
989
+ const mediaSpecs = [
990
+ { urn: 'media:custom-json', media_type: 'application/json', title: 'Custom JSON', profile_uri: 'https://example.com/schema/custom' }
991
+ ];
992
+ const spec = resolveMediaUrn('media:custom-json', mediaSpecs);
993
+ assertEqual(spec.contentType, 'application/json', 'Should resolve custom spec');
994
+ assertEqual(spec.profile, 'https://example.com/schema/custom', 'Should have custom profile');
995
+ }
996
+
997
+ // TEST092: resolveMediaUrn resolves with schema from local mediaSpecs
998
+ function test092_resolveCustomWithSchema() {
999
+ const mediaSpecs = [
1000
+ {
1001
+ urn: 'media:rich-xml',
1002
+ media_type: 'application/xml',
1003
+ title: 'Rich XML',
1004
+ profile_uri: 'https://example.com/schema/rich',
1005
+ schema: { type: 'object' }
1006
+ }
1007
+ ];
1008
+ const spec = resolveMediaUrn('media:rich-xml', mediaSpecs);
1009
+ assertEqual(spec.contentType, 'application/xml', 'Should resolve rich spec');
1010
+ assert(spec.schema !== null, 'Should have schema');
1011
+ assertEqual(spec.schema.type, 'object', 'Schema should have correct type');
1012
+ }
1013
+
1014
+ // TEST093: resolveMediaUrn fails hard on unknown URN
1015
+ function test093_resolveUnresolvableFailsHard() {
1016
+ let caught = false;
1017
+ try {
1018
+ resolveMediaUrn('media:nonexistent', []);
1019
+ } catch (e) {
1020
+ if (e instanceof MediaSpecError && e.code === MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN) {
1021
+ caught = true;
1022
+ }
1023
+ }
1024
+ assert(caught, 'Should fail hard on unresolvable media URN');
1025
+ }
1026
+
1027
+ // TEST094: N/A for JS (no registry concept)
1028
+ // TEST095: N/A for JS (Rust serde)
1029
+ // TEST096: N/A for JS (Rust serde)
1030
+ // TEST097: N/A for JS (Rust validation function)
1031
+ // TEST098: N/A for JS
1032
+
1033
+ // TEST099: MediaSpec with media: (no textable tag) -> isBinary() true
1034
+ function test099_resolvedIsBinary() {
1035
+ const spec = new MediaSpec('application/octet-stream', null, null, 'Binary', null, MEDIA_BINARY);
1036
+ assert(spec.isBinary(), 'Resolved binary spec should be binary');
1037
+ }
1038
+
1039
+ // TEST100: MediaSpec with record -> isRecord() true
1040
+ function test100_resolvedIsRecord() {
1041
+ const spec = new MediaSpec('application/json', null, null, 'Object', null, MEDIA_OBJECT);
1042
+ assert(spec.isRecord(), 'Resolved object spec should be record');
1043
+ }
1044
+
1045
+ // TEST101: MediaSpec with form=scalar -> isScalar() true
1046
+ function test101_resolvedIsScalar() {
1047
+ const spec = new MediaSpec('text/plain', null, null, 'String', null, MEDIA_STRING);
1048
+ assert(spec.isScalar(), 'Resolved string spec should be scalar');
1049
+ }
1050
+
1051
+ // TEST102: MediaSpec with list -> isList() true
1052
+ function test102_resolvedIsList() {
1053
+ const spec = new MediaSpec('text/plain', null, null, 'String Array', null, MEDIA_STRING_ARRAY);
1054
+ assert(spec.isList(), 'Resolved string_array spec should be list');
1055
+ }
1056
+
1057
+ // TEST103: MediaSpec with json tag -> isJSON() true
1058
+ function test103_resolvedIsJson() {
1059
+ const spec = new MediaSpec('application/json', null, null, 'JSON', null, MEDIA_JSON);
1060
+ assert(spec.isJSON(), 'Resolved json spec should be JSON');
1061
+ }
1062
+
1063
+ // TEST104: MediaSpec with textable tag -> isText() true
1064
+ function test104_resolvedIsText() {
1065
+ const spec = new MediaSpec('text/plain', null, null, 'String', null, MEDIA_STRING);
1066
+ assert(spec.isText(), 'Resolved string spec should be text');
1067
+ }
1068
+
1069
+ // TEST105: Metadata propagated from media spec definition
1070
+ function test105_metadataPropagation() {
1071
+ const mediaSpecs = [
1072
+ {
1073
+ urn: 'media:custom-setting;setting',
1074
+ media_type: 'text/plain',
1075
+ title: 'Custom Setting',
1076
+ profile_uri: 'https://example.com/schema',
1077
+ metadata: {
1078
+ category_key: 'interface',
1079
+ ui_type: 'SETTING_UI_TYPE_CHECKBOX',
1080
+ subcategory_key: 'appearance',
1081
+ display_index: 5
1082
+ }
1083
+ }
1084
+ ];
1085
+ const resolved = resolveMediaUrn('media:custom-setting;setting', mediaSpecs);
1086
+ assert(resolved.metadata !== null, 'Should have metadata');
1087
+ assertEqual(resolved.metadata.category_key, 'interface', 'Should propagate category_key');
1088
+ assertEqual(resolved.metadata.ui_type, 'SETTING_UI_TYPE_CHECKBOX', 'Should propagate ui_type');
1089
+ assertEqual(resolved.metadata.display_index, 5, 'Should propagate display_index');
1090
+ }
1091
+
1092
+ // TEST106: Metadata and validation coexist
1093
+ function test106_metadataWithValidation() {
1094
+ const mediaSpecs = [
1095
+ {
1096
+ urn: 'media:bounded-number;numeric;setting',
1097
+ media_type: 'text/plain',
1098
+ title: 'Bounded Number',
1099
+ validation: { min: 0, max: 100 },
1100
+ metadata: { category_key: 'inference', ui_type: 'SETTING_UI_TYPE_SLIDER' }
1101
+ }
1102
+ ];
1103
+ const resolved = resolveMediaUrn('media:bounded-number;numeric;setting', mediaSpecs);
1104
+ assert(resolved.validation !== null, 'Should have validation');
1105
+ assertEqual(resolved.validation.min, 0, 'Should have min');
1106
+ assertEqual(resolved.validation.max, 100, 'Should have max');
1107
+ assert(resolved.metadata !== null, 'Should have metadata');
1108
+ assertEqual(resolved.metadata.category_key, 'inference', 'Should have category_key');
1109
+ }
1110
+
1111
+ // TEST107: Extensions field propagated
1112
+ function test107_extensionsPropagation() {
1113
+ const mediaSpecs = [
1114
+ {
1115
+ urn: 'media:pdf',
1116
+ media_type: 'application/pdf',
1117
+ title: 'PDF Document',
1118
+ extensions: ['pdf']
1119
+ }
1120
+ ];
1121
+ const resolved = resolveMediaUrn('media:pdf', mediaSpecs);
1122
+ assert(Array.isArray(resolved.extensions), 'Extensions should be an array');
1123
+ assertEqual(resolved.extensions.length, 1, 'Should have one extension');
1124
+ assertEqual(resolved.extensions[0], 'pdf', 'Should have pdf extension');
1125
+ }
1126
+
1127
+ // TEST108: N/A for JS (Rust serde) - but we test MediaSpec with extensions
1128
+ function test108_extensionsSerialization() {
1129
+ // Test that MediaSpec can hold extensions correctly
1130
+ const spec = new MediaSpec('application/pdf', null, null, 'PDF', null, 'media:pdf', null, null, ['pdf']);
1131
+ assert(Array.isArray(spec.extensions), 'Extensions should be array');
1132
+ assertEqual(spec.extensions[0], 'pdf', 'Should have pdf extension');
1133
+ }
1134
+
1135
+ // TEST109: Extensions coexist with metadata and validation
1136
+ function test109_extensionsWithMetadataAndValidation() {
1137
+ const mediaSpecs = [
1138
+ {
1139
+ urn: 'media:custom-output',
1140
+ media_type: 'application/json',
1141
+ title: 'Custom Output',
1142
+ validation: { min_length: 1, max_length: 1000 },
1143
+ metadata: { category: 'output' },
1144
+ extensions: ['json']
1145
+ }
1146
+ ];
1147
+ const resolved = resolveMediaUrn('media:custom-output', mediaSpecs);
1148
+ assert(resolved.validation !== null, 'Should have validation');
1149
+ assert(resolved.metadata !== null, 'Should have metadata');
1150
+ assert(Array.isArray(resolved.extensions), 'Should have extensions');
1151
+ assertEqual(resolved.extensions[0], 'json', 'Should have json extension');
1152
+ }
1153
+
1154
+ // TEST110: Multiple extensions in a media spec
1155
+ function test110_multipleExtensions() {
1156
+ const mediaSpecs = [
1157
+ {
1158
+ urn: 'media:image;jpeg',
1159
+ media_type: 'image/jpeg',
1160
+ title: 'JPEG Image',
1161
+ extensions: ['jpg', 'jpeg']
1162
+ }
1163
+ ];
1164
+ const resolved = resolveMediaUrn('media:image;jpeg', mediaSpecs);
1165
+ assertEqual(resolved.extensions.length, 2, 'Should have two extensions');
1166
+ assertEqual(resolved.extensions[0], 'jpg', 'First extension should be jpg');
1167
+ assertEqual(resolved.extensions[1], 'jpeg', 'Second extension should be jpeg');
1168
+ }
1169
+
1170
+ // ============================================================================
1171
+ // cap_matrix.rs: TEST117-TEST131
1172
+ // ============================================================================
1173
+
1174
+ // TEST117: CapBlock finds more specific cap across registries
1175
+ function test117_capBlockMoreSpecificWins() {
1176
+ const providerRegistry = new CapMatrix();
1177
+ const pluginRegistry = new CapMatrix();
1178
+
1179
+ const providerHost = new MockCapSet('provider');
1180
+ const providerCap = makeCap(
1181
+ 'cap:in="media:binary";op=generate_thumbnail;out="media:binary"',
1182
+ 'Provider Thumbnail Generator (generic)'
1183
+ );
1184
+ providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
1185
+
1186
+ const pluginHost = new MockCapSet('plugin');
1187
+ const pluginCap = makeCap(
1188
+ 'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
1189
+ 'Plugin PDF Thumbnail Generator (specific)'
1190
+ );
1191
+ pluginRegistry.registerCapSet('plugin', pluginHost, [pluginCap]);
1192
+
1193
+ const composite = new CapBlock();
1194
+ composite.addRegistry('providers', providerRegistry);
1195
+ composite.addRegistry('plugins', pluginRegistry);
1196
+
1197
+ const request = 'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"';
1198
+ const best = composite.findBestCapSet(request);
1199
+
1200
+ assertEqual(best.registryName, 'plugins', 'More specific plugin should win');
1201
+ assertEqual(best.cap.title, 'Plugin PDF Thumbnail Generator (specific)', 'Should get plugin cap');
1202
+ }
1203
+
1204
+ // TEST118: CapBlock tie-breaking prefers first registry in order
1205
+ function test118_capBlockTieGoesToFirst() {
1206
+ const registry1 = new CapMatrix();
1207
+ const registry2 = new CapMatrix();
1208
+
1209
+ const host1 = new MockCapSet('host1');
1210
+ const cap1 = makeCap(matrixTestUrn('ext=pdf;op=generate'), 'Registry 1 Cap');
1211
+ registry1.registerCapSet('host1', host1, [cap1]);
1212
+
1213
+ const host2 = new MockCapSet('host2');
1214
+ const cap2 = makeCap(matrixTestUrn('ext=pdf;op=generate'), 'Registry 2 Cap');
1215
+ registry2.registerCapSet('host2', host2, [cap2]);
1216
+
1217
+ const composite = new CapBlock();
1218
+ composite.addRegistry('first', registry1);
1219
+ composite.addRegistry('second', registry2);
1220
+
1221
+ const best = composite.findBestCapSet(matrixTestUrn('ext=pdf;op=generate'));
1222
+ assertEqual(best.registryName, 'first', 'On tie, first registry should win');
1223
+ }
1224
+
1225
+ // TEST119: CapBlock polls all registries to find best match
1226
+ function test119_capBlockPollsAll() {
1227
+ const registry1 = new CapMatrix();
1228
+ const registry2 = new CapMatrix();
1229
+ const registry3 = new CapMatrix();
1230
+
1231
+ const host1 = new MockCapSet('host1');
1232
+ const cap1 = makeCap(matrixTestUrn('op=different'), 'Registry 1');
1233
+ registry1.registerCapSet('host1', host1, [cap1]);
1234
+
1235
+ const host2 = new MockCapSet('host2');
1236
+ const cap2 = makeCap(matrixTestUrn('op=generate'), 'Registry 2');
1237
+ registry2.registerCapSet('host2', host2, [cap2]);
1238
+
1239
+ const host3 = new MockCapSet('host3');
1240
+ const cap3 = makeCap(matrixTestUrn('ext=pdf;format=thumbnail;op=generate'), 'Registry 3');
1241
+ registry3.registerCapSet('host3', host3, [cap3]);
1242
+
1243
+ const composite = new CapBlock();
1244
+ composite.addRegistry('r1', registry1);
1245
+ composite.addRegistry('r2', registry2);
1246
+ composite.addRegistry('r3', registry3);
1247
+
1248
+ const best = composite.findBestCapSet(matrixTestUrn('ext=pdf;format=thumbnail;op=generate'));
1249
+ assertEqual(best.registryName, 'r3', 'Most specific registry should win');
1250
+ }
1251
+
1252
+ // TEST120: CapBlock returns error when no cap matches request
1253
+ function test120_capBlockNoMatch() {
1254
+ const registry = new CapMatrix();
1255
+ const composite = new CapBlock();
1256
+ composite.addRegistry('empty', registry);
1257
+
1258
+ try {
1259
+ composite.findBestCapSet(matrixTestUrn('op=nonexistent'));
1260
+ throw new Error('Expected error for non-matching capability');
1261
+ } catch (e) {
1262
+ assert(e instanceof CapMatrixError, 'Should be CapMatrixError');
1263
+ assertEqual(e.type, 'NoSetsFound', 'Should be NoSetsFound error');
1264
+ }
1265
+ }
1266
+
1267
+ // TEST121: CapBlock fallback scenario where generic cap handles unknown file types
1268
+ function test121_capBlockFallbackScenario() {
1269
+ const providerRegistry = new CapMatrix();
1270
+ const pluginRegistry = new CapMatrix();
1271
+
1272
+ const providerHost = new MockCapSet('provider_fallback');
1273
+ const providerCap = makeCap(
1274
+ 'cap:in="media:binary";op=generate_thumbnail;out="media:binary"',
1275
+ 'Generic Thumbnail Provider'
1276
+ );
1277
+ providerRegistry.registerCapSet('provider_fallback', providerHost, [providerCap]);
1278
+
1279
+ const pluginHost = new MockCapSet('pdf_plugin');
1280
+ const pluginCap = makeCap(
1281
+ 'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
1282
+ 'PDF Thumbnail Plugin'
1283
+ );
1284
+ pluginRegistry.registerCapSet('pdf_plugin', pluginHost, [pluginCap]);
1285
+
1286
+ const composite = new CapBlock();
1287
+ composite.addRegistry('providers', providerRegistry);
1288
+ composite.addRegistry('plugins', pluginRegistry);
1289
+
1290
+ // PDF request -> plugin wins
1291
+ const best = composite.findBestCapSet('cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"');
1292
+ assertEqual(best.registryName, 'plugins', 'Plugin should win for PDF');
1293
+
1294
+ // WAV request -> provider wins (fallback)
1295
+ const bestWav = composite.findBestCapSet('cap:ext=wav;in="media:binary";op=generate_thumbnail;out="media:binary"');
1296
+ assertEqual(bestWav.registryName, 'providers', 'Provider should win for wav (fallback)');
1297
+ }
1298
+
1299
+ // TEST122: CapBlock can method returns execution info and acceptsRequest checks capability
1300
+ function test122_capBlockCanMethod() {
1301
+ const providerRegistry = new CapMatrix();
1302
+ const providerHost = new MockCapSet('test_provider');
1303
+ const providerCap = makeCap(matrixTestUrn('ext=pdf;op=generate'), 'Test Provider');
1304
+ providerRegistry.registerCapSet('test_provider', providerHost, [providerCap]);
1305
+
1306
+ const composite = new CapBlock();
1307
+ composite.addRegistry('providers', providerRegistry);
1308
+
1309
+ const result = composite.can(matrixTestUrn('ext=pdf;op=generate'));
1310
+ assert(result.cap !== null, 'Should return cap');
1311
+ assert(result.compositeHost instanceof CompositeCapSet, 'Should return CompositeCapSet');
1312
+ assert(composite.acceptsRequest(matrixTestUrn('ext=pdf;op=generate')), 'Should accept matching cap');
1313
+ assert(!composite.acceptsRequest(matrixTestUrn('op=nonexistent')), 'Should not accept non-matching cap');
1314
+ }
1315
+
1316
+ // TEST123: CapBlock registry management add, get, remove operations
1317
+ function test123_capBlockRegistryManagement() {
1318
+ const composite = new CapBlock();
1319
+ const registry1 = new CapMatrix();
1320
+ const registry2 = new CapMatrix();
1321
+
1322
+ composite.addRegistry('r1', registry1);
1323
+ composite.addRegistry('r2', registry2);
1324
+ assertEqual(composite.getRegistryNames().length, 2, 'Should have 2 registries');
1325
+
1326
+ assertEqual(composite.getRegistry('r1'), registry1, 'Should get correct registry');
1327
+
1328
+ const removed = composite.removeRegistry('r1');
1329
+ assertEqual(removed, registry1, 'Should return removed registry');
1330
+ assertEqual(composite.getRegistryNames().length, 1, 'Should have 1 registry after removal');
1331
+
1332
+ assertEqual(composite.getRegistry('nonexistent'), null, 'Should return null for non-existent');
1333
+ }
1334
+
1335
+ // TEST124: CapGraph basic construction builds nodes and edges from caps
1336
+ function test124_capGraphBasicConstruction() {
1337
+ const registry = new CapMatrix();
1338
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1339
+
1340
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1341
+ const cap2 = makeGraphCap('media:string', 'media:object', 'String to Object');
1342
+ registry.registerCapSet('converter', mockHost, [cap1, cap2]);
1343
+
1344
+ const cube = new CapBlock();
1345
+ cube.addRegistry('converters', registry);
1346
+
1347
+ const graph = cube.graph();
1348
+ assertEqual(graph.getNodes().size, 3, 'Expected 3 nodes');
1349
+ assertEqual(graph.getEdges().length, 2, 'Expected 2 edges');
1350
+ assertEqual(graph.stats().nodeCount, 3, 'Expected 3 nodes in stats');
1351
+ assertEqual(graph.stats().edgeCount, 2, 'Expected 2 edges in stats');
1352
+ }
1353
+
1354
+ // TEST125: CapGraph getOutgoing and getIncoming return correct edges for media URN
1355
+ function test125_capGraphOutgoingIncoming() {
1356
+ const registry = new CapMatrix();
1357
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1358
+
1359
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1360
+ const cap2 = makeGraphCap('media:binary', 'media:object', 'Binary to Object');
1361
+ registry.registerCapSet('converter', mockHost, [cap1, cap2]);
1362
+
1363
+ const cube = new CapBlock();
1364
+ cube.addRegistry('converters', registry);
1365
+ const graph = cube.graph();
1366
+
1367
+ assertEqual(graph.getOutgoing('media:binary').length, 2, 'binary should have 2 outgoing');
1368
+ assertEqual(graph.getIncoming('media:string').length, 1, 'string should have 1 incoming');
1369
+ assertEqual(graph.getIncoming('media:object').length, 1, 'object should have 1 incoming');
1370
+ }
1371
+
1372
+ // TEST126: CapGraph canConvert checks direct and transitive conversion paths
1373
+ function test126_capGraphCanConvert() {
1374
+ const registry = new CapMatrix();
1375
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1376
+
1377
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1378
+ const cap2 = makeGraphCap('media:string', 'media:object', 'String to Object');
1379
+ registry.registerCapSet('converter', mockHost, [cap1, cap2]);
1380
+
1381
+ const cube = new CapBlock();
1382
+ cube.addRegistry('converters', registry);
1383
+ const graph = cube.graph();
1384
+
1385
+ assert(graph.canConvert('media:binary', 'media:string'), 'Direct conversion');
1386
+ assert(graph.canConvert('media:string', 'media:object'), 'Direct conversion');
1387
+ assert(graph.canConvert('media:binary', 'media:object'), 'Transitive conversion');
1388
+ assert(graph.canConvert('media:binary', 'media:binary'), 'Same spec');
1389
+ assert(!graph.canConvert('media:object', 'media:binary'), 'Impossible conversion');
1390
+ assert(!graph.canConvert('media:nonexistent', 'media:string'), 'Nonexistent node');
1391
+ }
1392
+
1393
+ // TEST127: CapGraph findPath returns shortest path between media URNs
1394
+ function test127_capGraphFindPath() {
1395
+ const registry = new CapMatrix();
1396
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1397
+
1398
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1399
+ const cap2 = makeGraphCap('media:string', 'media:object', 'String to Object');
1400
+ registry.registerCapSet('converter', mockHost, [cap1, cap2]);
1401
+
1402
+ const cube = new CapBlock();
1403
+ cube.addRegistry('converters', registry);
1404
+ const graph = cube.graph();
1405
+
1406
+ // Direct path
1407
+ let path = graph.findPath('media:binary', 'media:string');
1408
+ assert(path !== null, 'Should find direct path');
1409
+ assertEqual(path.length, 1, 'Direct path length should be 1');
1410
+
1411
+ // Transitive path
1412
+ path = graph.findPath('media:binary', 'media:object');
1413
+ assert(path !== null, 'Should find transitive path');
1414
+ assertEqual(path.length, 2, 'Transitive path length should be 2');
1415
+
1416
+ // No path
1417
+ path = graph.findPath('media:object', 'media:binary');
1418
+ assertEqual(path, null, 'Should not find impossible path');
1419
+
1420
+ // Same spec
1421
+ path = graph.findPath('media:binary', 'media:binary');
1422
+ assert(path !== null, 'Same spec should return empty path');
1423
+ assertEqual(path.length, 0, 'Same spec path should be empty');
1424
+ }
1425
+
1426
+ // TEST128: CapGraph findAllPaths returns all paths sorted by length
1427
+ function test128_capGraphFindAllPaths() {
1428
+ const registry = new CapMatrix();
1429
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1430
+
1431
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1432
+ const cap2 = makeGraphCap('media:string', 'media:object', 'String to Object');
1433
+ const cap3 = makeGraphCap('media:binary', 'media:object', 'Binary to Object (direct)');
1434
+ registry.registerCapSet('converter', mockHost, [cap1, cap2, cap3]);
1435
+
1436
+ const cube = new CapBlock();
1437
+ cube.addRegistry('converters', registry);
1438
+ const graph = cube.graph();
1439
+
1440
+ const paths = graph.findAllPaths('media:binary', 'media:object', 3);
1441
+ assertEqual(paths.length, 2, 'Should find 2 paths');
1442
+ assertEqual(paths[0].length, 1, 'Shortest path first (direct)');
1443
+ assertEqual(paths[1].length, 2, 'Longer path second (via string)');
1444
+ }
1445
+
1446
+ // TEST129: CapGraph getDirectEdges returns edges sorted by specificity
1447
+ function test129_capGraphGetDirectEdges() {
1448
+ const registry1 = new CapMatrix();
1449
+ const registry2 = new CapMatrix();
1450
+ const mockHost1 = { executeCap: async () => ({ textOutput: 'mock1' }) };
1451
+ const mockHost2 = { executeCap: async () => ({ textOutput: 'mock2' }) };
1452
+
1453
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Generic Binary to String');
1454
+ const capUrn2 = CapUrn.fromString('cap:ext=pdf;in="media:binary";op=convert;out="media:string"');
1455
+ const cap2 = new Cap(capUrn2, 'PDF Binary to String', 'convert', 'PDF Binary to String');
1456
+
1457
+ registry1.registerCapSet('converter1', mockHost1, [cap1]);
1458
+ registry2.registerCapSet('converter2', mockHost2, [cap2]);
1459
+
1460
+ const cube = new CapBlock();
1461
+ cube.addRegistry('reg1', registry1);
1462
+ cube.addRegistry('reg2', registry2);
1463
+ const graph = cube.graph();
1464
+
1465
+ const edges = graph.getDirectEdges('media:binary', 'media:string');
1466
+ assertEqual(edges.length, 2, 'Expected 2 direct edges');
1467
+ assertEqual(edges[0].cap.title, 'PDF Binary to String', 'More specific edge first');
1468
+ assert(edges[0].specificity > edges[1].specificity, 'First edge should have higher specificity');
1469
+ }
1470
+
1471
+ // TEST130: CapGraph stats returns node count, edge count, input/output URN counts
1472
+ function test130_capGraphStats() {
1473
+ const registry = new CapMatrix();
1474
+ const mockHost = { executeCap: async () => ({ textOutput: 'mock' }) };
1475
+
1476
+ const cap1 = makeGraphCap('media:binary', 'media:string', 'Binary to String');
1477
+ const cap2 = makeGraphCap('media:string', 'media:object', 'String to Object');
1478
+ const cap3 = makeGraphCap('media:binary', 'media:json', 'Binary to JSON');
1479
+ registry.registerCapSet('converter', mockHost, [cap1, cap2, cap3]);
1480
+
1481
+ const cube = new CapBlock();
1482
+ cube.addRegistry('converters', registry);
1483
+ const graph = cube.graph();
1484
+ const stats = graph.stats();
1485
+
1486
+ assertEqual(stats.nodeCount, 4, '4 unique nodes');
1487
+ assertEqual(stats.edgeCount, 3, '3 edges');
1488
+ assertEqual(stats.inputUrnCount, 2, '2 input URNs');
1489
+ assertEqual(stats.outputUrnCount, 3, '3 output URNs');
1490
+ }
1491
+
1492
+ // TEST131: CapGraph with CapBlock builds graph from multiple registries
1493
+ function test131_capGraphWithCapBlock() {
1494
+ const providerRegistry = new CapMatrix();
1495
+ const pluginRegistry = new CapMatrix();
1496
+ const providerHost = { executeCap: async () => ({ textOutput: 'provider' }) };
1497
+ const pluginHost = { executeCap: async () => ({ textOutput: 'plugin' }) };
1498
+
1499
+ const providerCap = makeGraphCap('media:binary', 'media:string', 'Provider Binary to String');
1500
+ providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
1501
+
1502
+ const pluginCap = makeGraphCap('media:string', 'media:object', 'Plugin String to Object');
1503
+ pluginRegistry.registerCapSet('plugin', pluginHost, [pluginCap]);
1504
+
1505
+ const cube = new CapBlock();
1506
+ cube.addRegistry('providers', providerRegistry);
1507
+ cube.addRegistry('plugins', pluginRegistry);
1508
+ const graph = cube.graph();
1509
+
1510
+ assert(graph.canConvert('media:binary', 'media:object'), 'Should convert across registries');
1511
+ const path = graph.findPath('media:binary', 'media:object');
1512
+ assert(path !== null, 'Should find path');
1513
+ assertEqual(path.length, 2, 'Path through 2 registries');
1514
+ assertEqual(path[0].registryName, 'providers', 'First edge from providers');
1515
+ assertEqual(path[1].registryName, 'plugins', 'Second edge from plugins');
1516
+ }
1517
+
1518
+ // TEST132: N/A (already covered by TEST129)
1519
+ // TEST133: N/A (already covered by TEST131)
1520
+ // TEST134: N/A (already covered by TEST130)
1521
+
1522
+ // ============================================================================
1523
+ // caller.rs: TEST156-TEST159
1524
+ // ============================================================================
1525
+
1526
+ // TEST156: Creating StdinSource Data variant with byte vector
1527
+ function test156_stdinSourceFromData() {
1528
+ const testData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
1529
+ const source = StdinSource.fromData(testData);
1530
+ assert(source !== null, 'Should create source');
1531
+ assertEqual(source.kind, StdinSourceKind.DATA, 'Should be DATA kind');
1532
+ assert(source.isData(), 'isData() should return true');
1533
+ assert(!source.isFileReference(), 'isFileReference() should return false');
1534
+ assertEqual(source.data, testData, 'Should store data');
1535
+ }
1536
+
1537
+ // TEST157: Creating StdinSource FileReference variant with all required fields
1538
+ function test157_stdinSourceFromFileReference() {
1539
+ const trackedFileId = 'tracked-file-123';
1540
+ const originalPath = '/path/to/original.pdf';
1541
+ const securityBookmark = new Uint8Array([0x62, 0x6f, 0x6f, 0x6b]);
1542
+ const mediaUrn = 'media:pdf';
1543
+
1544
+ const source = StdinSource.fromFileReference(trackedFileId, originalPath, securityBookmark, mediaUrn);
1545
+ assert(source !== null, 'Should create source');
1546
+ assertEqual(source.kind, StdinSourceKind.FILE_REFERENCE, 'Should be FILE_REFERENCE kind');
1547
+ assert(!source.isData(), 'isData() should return false');
1548
+ assert(source.isFileReference(), 'isFileReference() should return true');
1549
+ assertEqual(source.trackedFileId, trackedFileId, 'Should store trackedFileId');
1550
+ assertEqual(source.originalPath, originalPath, 'Should store originalPath');
1551
+ assertEqual(source.mediaUrn, mediaUrn, 'Should store mediaUrn');
1552
+ }
1553
+
1554
+ // TEST158: StdinSource Data with empty vector stores and retrieves correctly
1555
+ function test158_stdinSourceWithEmptyData() {
1556
+ const emptyData = new Uint8Array(0);
1557
+ const source = StdinSource.fromData(emptyData);
1558
+ assert(source.isData(), 'Should be data source');
1559
+ assertEqual(source.data.length, 0, 'Data length should be 0');
1560
+ }
1561
+
1562
+ // TEST159: StdinSource Data with binary content like PNG header bytes
1563
+ function test159_stdinSourceWithBinaryContent() {
1564
+ const pngHeader = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
1565
+ const source = StdinSource.fromData(pngHeader);
1566
+ assert(source.isData(), 'Should be data source');
1567
+ assertEqual(source.data.length, 8, 'Should have 8 bytes');
1568
+ assertEqual(source.data[0], 0x89, 'First byte should be 0x89');
1569
+ assertEqual(source.data[1], 0x50, 'Second byte should be 0x50 (P)');
1570
+ }
1571
+
1572
+ // ============================================================================
1573
+ // caller.rs: TEST274-TEST283
1574
+ // ============================================================================
1575
+
1576
+ // TEST274: CapArgumentValue constructor stores media_urn and raw byte value
1577
+ function test274_capArgumentValueNew() {
1578
+ const arg = new CapArgumentValue('media:model-spec;textable', new Uint8Array([103, 112, 116, 45, 52]));
1579
+ assertEqual(arg.mediaUrn, 'media:model-spec;textable', 'mediaUrn must match');
1580
+ assertEqual(arg.value.length, 5, 'value must have 5 bytes');
1581
+ }
1582
+
1583
+ // TEST275: CapArgumentValue.fromStr converts string to UTF-8 bytes
1584
+ function test275_capArgumentValueFromStr() {
1585
+ const arg = CapArgumentValue.fromStr('media:string;textable', 'hello world');
1586
+ assertEqual(arg.mediaUrn, 'media:string;textable', 'mediaUrn must match');
1587
+ assertEqual(new TextDecoder().decode(arg.value), 'hello world', 'value must decode correctly');
1588
+ }
1589
+
1590
+ // TEST276: CapArgumentValue.valueAsStr succeeds for UTF-8 data
1591
+ function test276_capArgumentValueAsStrValid() {
1592
+ const arg = CapArgumentValue.fromStr('media:string', 'test');
1593
+ assertEqual(arg.valueAsStr(), 'test', 'valueAsStr must return test');
1594
+ }
1595
+
1596
+ // TEST277: CapArgumentValue.valueAsStr fails for non-UTF-8 binary data
1597
+ function test277_capArgumentValueAsStrInvalidUtf8() {
1598
+ const arg = new CapArgumentValue('media:pdf', new Uint8Array([0xFF, 0xFE, 0x80]));
1599
+ let threw = false;
1600
+ try {
1601
+ arg.valueAsStr();
1602
+ } catch (e) {
1603
+ threw = true;
1604
+ }
1605
+ assert(threw, 'non-UTF-8 data must fail on valueAsStr with fatal decoder');
1606
+ }
1607
+
1608
+ // TEST278: CapArgumentValue with empty value stores empty Uint8Array
1609
+ function test278_capArgumentValueEmpty() {
1610
+ const arg = new CapArgumentValue('media:void', new Uint8Array([]));
1611
+ assertEqual(arg.value.length, 0, 'empty value must have 0 bytes');
1612
+ assertEqual(arg.valueAsStr(), '', 'empty value as string must be empty string');
1613
+ }
1614
+
1615
+ // TEST279-281: N/A for JS (Rust Debug/Clone/Send traits)
1616
+
1617
+ // TEST282: CapArgumentValue.fromStr with Unicode string preserves all characters
1618
+ function test282_capArgumentValueUnicode() {
1619
+ const arg = CapArgumentValue.fromStr('media:string', 'hello \u4e16\u754c \ud83c\udf0d');
1620
+ assertEqual(arg.valueAsStr(), 'hello \u4e16\u754c \ud83c\udf0d', 'Unicode must roundtrip');
1621
+ }
1622
+
1623
+ // TEST283: CapArgumentValue with large binary payload preserves all bytes
1624
+ function test283_capArgumentValueLargeBinary() {
1625
+ const data = new Uint8Array(10000);
1626
+ for (let i = 0; i < 10000; i++) {
1627
+ data[i] = i % 256;
1628
+ }
1629
+ const arg = new CapArgumentValue('media:pdf', data);
1630
+ assertEqual(arg.value.length, 10000, 'large binary must preserve all bytes');
1631
+ assertEqual(arg.value[0], 0, 'first byte check');
1632
+ assertEqual(arg.value[255], 255, 'byte 255 check');
1633
+ assertEqual(arg.value[256], 0, 'byte 256 wraps check');
1634
+ }
1635
+
1636
+ // ============================================================================
1637
+ // standard/caps.rs: TEST304-TEST312
1638
+ // ============================================================================
1639
+
1640
+ const { TaggedUrn } = require('tagged-urn');
1641
+
1642
+ // TEST304: MEDIA_AVAILABILITY_OUTPUT constant parses as valid media URN with correct tags
1643
+ function test304_mediaAvailabilityOutputConstant() {
1644
+ const urn = TaggedUrn.fromString(MEDIA_AVAILABILITY_OUTPUT);
1645
+ assert(urn.getTag('textable') !== undefined, 'model-availability must be textable');
1646
+ assertEqual(urn.getTag('record'), '*', 'model-availability must be record');
1647
+ assert(urn.getTag('textable') !== undefined, 'model-availability must not be binary (has textable)');
1648
+ const reparsed = TaggedUrn.fromString(urn.toString());
1649
+ assert(urn.conformsTo(reparsed), 'roundtrip must match original');
1650
+ }
1651
+
1652
+ // TEST305: MEDIA_PATH_OUTPUT constant parses as valid media URN with correct tags
1653
+ function test305_mediaPathOutputConstant() {
1654
+ const urn = TaggedUrn.fromString(MEDIA_PATH_OUTPUT);
1655
+ assert(urn.getTag('textable') !== undefined, 'model-path must be textable');
1656
+ assertEqual(urn.getTag('record'), '*', 'model-path must be record');
1657
+ assert(urn.getTag('textable') !== undefined, 'model-path must not be binary (has textable)');
1658
+ const reparsed = TaggedUrn.fromString(urn.toString());
1659
+ assert(urn.conformsTo(reparsed), 'roundtrip must match original');
1660
+ }
1661
+
1662
+ // TEST306: MEDIA_AVAILABILITY_OUTPUT and MEDIA_PATH_OUTPUT are distinct URNs
1663
+ function test306_availabilityAndPathOutputDistinct() {
1664
+ assert(MEDIA_AVAILABILITY_OUTPUT !== MEDIA_PATH_OUTPUT, 'Must be distinct');
1665
+ const avail = TaggedUrn.fromString(MEDIA_AVAILABILITY_OUTPUT);
1666
+ const path = TaggedUrn.fromString(MEDIA_PATH_OUTPUT);
1667
+ let matchResult;
1668
+ try {
1669
+ matchResult = avail.conformsTo(path);
1670
+ } catch (e) {
1671
+ matchResult = false;
1672
+ }
1673
+ assert(!matchResult, 'availability must not conform to path');
1674
+ }
1675
+
1676
+ // TEST307: model_availability_urn builds valid cap URN with correct op and media specs
1677
+ function test307_modelAvailabilityUrn() {
1678
+ const urn = modelAvailabilityUrn();
1679
+ assert(urn.hasTag('op', 'model-availability'), 'Must have op=model-availability');
1680
+ const inSpec = TaggedUrn.fromString(urn.getInSpec());
1681
+ const expectedIn = TaggedUrn.fromString(MEDIA_MODEL_SPEC);
1682
+ assert(inSpec.conformsTo(expectedIn), 'input must conform to MEDIA_MODEL_SPEC');
1683
+ const outSpec = TaggedUrn.fromString(urn.getOutSpec());
1684
+ const expectedOut = TaggedUrn.fromString(MEDIA_AVAILABILITY_OUTPUT);
1685
+ assert(outSpec.conformsTo(expectedOut), 'output must conform to MEDIA_AVAILABILITY_OUTPUT');
1686
+ }
1687
+
1688
+ // TEST308: model_path_urn builds valid cap URN with correct op and media specs
1689
+ function test308_modelPathUrn() {
1690
+ const urn = modelPathUrn();
1691
+ assert(urn.hasTag('op', 'model-path'), 'Must have op=model-path');
1692
+ const inSpec = TaggedUrn.fromString(urn.getInSpec());
1693
+ const expectedIn = TaggedUrn.fromString(MEDIA_MODEL_SPEC);
1694
+ assert(inSpec.conformsTo(expectedIn), 'input must conform to MEDIA_MODEL_SPEC');
1695
+ const outSpec = TaggedUrn.fromString(urn.getOutSpec());
1696
+ const expectedOut = TaggedUrn.fromString(MEDIA_PATH_OUTPUT);
1697
+ assert(outSpec.conformsTo(expectedOut), 'output must conform to MEDIA_PATH_OUTPUT');
1698
+ }
1699
+
1700
+ // TEST309: model_availability_urn and model_path_urn produce distinct URNs
1701
+ function test309_modelAvailabilityAndPathAreDistinct() {
1702
+ const avail = modelAvailabilityUrn();
1703
+ const path = modelPathUrn();
1704
+ assert(avail.toString() !== path.toString(), 'availability and path must be distinct');
1705
+ }
1706
+
1707
+ // TEST310: llm_conversation_urn uses unconstrained tag (not constrained)
1708
+ function test310_llmConversationUrnUnconstrained() {
1709
+ const urn = llmConversationUrn('en');
1710
+ assert(urn.getTag('unconstrained') !== undefined, 'Must have unconstrained tag');
1711
+ assert(urn.hasTag('op', 'conversation'), 'Must have op=conversation');
1712
+ assert(urn.hasTag('language', 'en'), 'Must have language=en');
1713
+ }
1714
+
1715
+ // TEST311: llm_conversation_urn in/out specs match the expected media URNs semantically
1716
+ function test311_llmConversationUrnSpecs() {
1717
+ const urn = llmConversationUrn('fr');
1718
+ const inSpec = TaggedUrn.fromString(urn.getInSpec());
1719
+ const expectedIn = TaggedUrn.fromString(MEDIA_STRING);
1720
+ assert(inSpec.conformsTo(expectedIn), 'in_spec must conform to MEDIA_STRING');
1721
+ const outSpec = TaggedUrn.fromString(urn.getOutSpec());
1722
+ const expectedOut = TaggedUrn.fromString(MEDIA_LLM_INFERENCE_OUTPUT);
1723
+ assert(outSpec.conformsTo(expectedOut), 'out_spec must conform to MEDIA_LLM_INFERENCE_OUTPUT');
1724
+ }
1725
+
1726
+ // TEST312: All URN builders produce parseable cap URNs
1727
+ function test312_allUrnBuildersProduceValidUrns() {
1728
+ const avail = modelAvailabilityUrn();
1729
+ const path = modelPathUrn();
1730
+ const conv = llmConversationUrn('en');
1731
+
1732
+ const parsedAvail = CapUrn.fromString(avail.toString());
1733
+ assert(parsedAvail !== null, 'modelAvailabilityUrn must be parseable');
1734
+ const parsedPath = CapUrn.fromString(path.toString());
1735
+ assert(parsedPath !== null, 'modelPathUrn must be parseable');
1736
+ const parsedConv = CapUrn.fromString(conv.toString());
1737
+ assert(parsedConv !== null, 'llmConversationUrn must be parseable');
1738
+ }
1739
+
1740
+ // ============================================================================
1741
+ // Additional JS-specific tests (extension index, media URN resolution, Cap JSON)
1742
+ // ============================================================================
1743
+
1744
+ // These tests cover JS-specific functionality not in the Rust numbering scheme
1745
+ // but are important for capdag-js correctness.
1746
+
1747
+ function testJS_buildExtensionIndex() {
1748
+ const mediaSpecs = [
1749
+ { urn: 'media:pdf', media_type: 'application/pdf', extensions: ['pdf'] },
1750
+ { urn: 'media:image;jpeg', media_type: 'image/jpeg', extensions: ['jpg', 'jpeg'] },
1751
+ { urn: 'media:json;textable', media_type: 'application/json', extensions: ['json'] }
1752
+ ];
1753
+ const index = buildExtensionIndex(mediaSpecs);
1754
+ assert(index instanceof Map, 'Should return a Map');
1755
+ assertEqual(index.size, 4, 'Should have 4 extensions');
1756
+ assert(index.has('pdf'), 'Should have pdf');
1757
+ assert(index.has('jpg'), 'Should have jpg');
1758
+ assert(index.has('jpeg'), 'Should have jpeg');
1759
+ assert(index.has('json'), 'Should have json');
1760
+ assertEqual(index.get('pdf')[0], 'media:pdf', 'pdf should map correctly');
1761
+ }
1762
+
1763
+ function testJS_mediaUrnsForExtension() {
1764
+ const mediaSpecs = [
1765
+ { urn: 'media:pdf', media_type: 'application/pdf', extensions: ['pdf'] },
1766
+ { urn: 'media:json;textable;record', media_type: 'application/json', extensions: ['json'] },
1767
+ { urn: 'media:json;textable;list', media_type: 'application/json', extensions: ['json'] }
1768
+ ];
1769
+
1770
+ const pdfUrns = mediaUrnsForExtension('pdf', mediaSpecs);
1771
+ assertEqual(pdfUrns.length, 1, 'Should find 1 URN for pdf');
1772
+
1773
+ // Case insensitivity
1774
+ const pdfUrnsUpper = mediaUrnsForExtension('PDF', mediaSpecs);
1775
+ assertEqual(pdfUrnsUpper.length, 1, 'Should find URN with uppercase extension');
1776
+
1777
+ // Multiple URNs for same extension
1778
+ const jsonUrns = mediaUrnsForExtension('json', mediaSpecs);
1779
+ assertEqual(jsonUrns.length, 2, 'Should find 2 URNs for json');
1780
+
1781
+ // Unknown extension throws
1782
+ let thrownError = null;
1783
+ try {
1784
+ mediaUrnsForExtension('unknown', mediaSpecs);
1785
+ } catch (e) {
1786
+ thrownError = e;
1787
+ }
1788
+ assert(thrownError instanceof MediaSpecError, 'Should throw MediaSpecError for unknown ext');
1789
+ }
1790
+
1791
+ function testJS_getExtensionMappings() {
1792
+ const mediaSpecs = [
1793
+ { urn: 'media:pdf', media_type: 'application/pdf', extensions: ['pdf'] },
1794
+ { urn: 'media:image;jpeg', media_type: 'image/jpeg', extensions: ['jpg', 'jpeg'] }
1795
+ ];
1796
+ const mappings = getExtensionMappings(mediaSpecs);
1797
+ assert(Array.isArray(mappings), 'Should return an array');
1798
+ assertEqual(mappings.length, 3, 'Should have 3 mappings');
1799
+ }
1800
+
1801
+ function testJS_capWithMediaSpecs() {
1802
+ const urn = CapUrn.fromString('cap:in="media:string";op=test;out="media:custom"');
1803
+ const cap = new Cap(urn, 'Test Cap', 'test_command');
1804
+ cap.mediaSpecs = [
1805
+ { urn: MEDIA_STRING, media_type: 'text/plain', title: 'String', profile_uri: 'https://capdag.com/schema/str' },
1806
+ { urn: 'media:custom', media_type: 'application/json', title: 'Custom Output', schema: { type: 'object' } }
1807
+ ];
1808
+ const strSpec = cap.resolveMediaUrn(MEDIA_STRING);
1809
+ assertEqual(strSpec.contentType, 'text/plain', 'Should resolve string spec');
1810
+ const outputSpec = cap.resolveMediaUrn('media:custom');
1811
+ assertEqual(outputSpec.contentType, 'application/json', 'Should resolve custom spec');
1812
+ assert(outputSpec.schema !== null, 'Should have schema');
1813
+ }
1814
+
1815
+ function testJS_capJSONSerialization() {
1816
+ const urn = CapUrn.fromString(testUrn('op=test'));
1817
+ const cap = new Cap(urn, 'Test Cap', 'test_command');
1818
+ cap.mediaSpecs = [
1819
+ { urn: 'media:custom', media_type: 'text/plain', title: 'Custom' }
1820
+ ];
1821
+ cap.arguments = {
1822
+ required: [{ name: 'input', media_urn: MEDIA_STRING }],
1823
+ optional: []
1824
+ };
1825
+ cap.output = { media_urn: 'media:custom', output_description: 'Test output' };
1826
+
1827
+ const json = cap.toJSON();
1828
+ assert(json.media_specs !== undefined, 'Should have media_specs');
1829
+ assertEqual(typeof json.urn, 'string', 'URN should be string');
1830
+
1831
+ const restored = Cap.fromJSON(json);
1832
+ assert(restored.mediaSpecs !== undefined, 'Should restore mediaSpecs');
1833
+ assertEqual(restored.urn.getInSpec(), MEDIA_VOID, 'Should restore inSpec');
1834
+ assertEqual(restored.urn.getOutSpec(), MEDIA_OBJECT, 'Should restore outSpec');
1835
+ }
1836
+
1837
+ function testJS_stdinSourceKindConstants() {
1838
+ assert(StdinSourceKind.DATA !== undefined, 'DATA kind should be defined');
1839
+ assert(StdinSourceKind.FILE_REFERENCE !== undefined, 'FILE_REFERENCE kind should be defined');
1840
+ assert(StdinSourceKind.DATA !== StdinSourceKind.FILE_REFERENCE, 'Kind values should be distinct');
1841
+ }
1842
+
1843
+ function testJS_stdinSourceNullData() {
1844
+ const source = StdinSource.fromData(null);
1845
+ assert(source !== null, 'Should create source');
1846
+ assert(source.isData(), 'Should be data source');
1847
+ assertEqual(source.data, null, 'Data should be null');
1848
+ }
1849
+
1850
+ function testJS_argsPassedToExecuteCap() {
1851
+ let receivedArgs = null;
1852
+ const mockHost = {
1853
+ executeCap: async (capUrn, args) => {
1854
+ receivedArgs = args;
1855
+ return { textOutput: 'ok' };
1856
+ }
1857
+ };
1858
+
1859
+ const cap = new Cap(
1860
+ CapUrn.fromString('cap:in="media:void";op=test;out="media:string"'),
1861
+ 'Test Cap',
1862
+ 'test-command'
1863
+ );
1864
+ const registry = new CapMatrix();
1865
+ registry.registerCapSet('test', mockHost, [cap]);
1866
+ const cube = new CapBlock();
1867
+ cube.addRegistry('test', registry);
1868
+
1869
+ const args = [new CapArgumentValue('media:void', new Uint8Array([1, 2, 3]))];
1870
+ const { compositeHost } = cube.can('cap:in="media:void";op=test;out="media:string"');
1871
+
1872
+ return compositeHost.executeCap(
1873
+ 'cap:in="media:void";op=test;out="media:string"',
1874
+ args
1875
+ ).then(() => {
1876
+ assert(receivedArgs !== null, 'Should receive arguments');
1877
+ assert(Array.isArray(receivedArgs), 'Should receive array');
1878
+ assertEqual(receivedArgs.length, 1, 'Should have one argument');
1879
+ assertEqual(receivedArgs[0].mediaUrn, 'media:void', 'Correct mediaUrn');
1880
+ assertEqual(receivedArgs[0].value.length, 3, 'Correct data length');
1881
+ });
1882
+ }
1883
+
1884
+ function testJS_binaryArgPassedToExecuteCap() {
1885
+ let receivedArgs = null;
1886
+ const mockHost = {
1887
+ executeCap: async (capUrn, args) => {
1888
+ receivedArgs = args;
1889
+ return { textOutput: 'ok' };
1890
+ }
1891
+ };
1892
+
1893
+ const cap = new Cap(
1894
+ CapUrn.fromString('cap:in="media:void";op=test;out="media:string"'),
1895
+ 'Test Cap',
1896
+ 'test-command'
1897
+ );
1898
+ const registry = new CapMatrix();
1899
+ registry.registerCapSet('test', mockHost, [cap]);
1900
+ const cube = new CapBlock();
1901
+ cube.addRegistry('test', registry);
1902
+
1903
+ const binaryArg = new CapArgumentValue('media:pdf', new Uint8Array([0x89, 0x50, 0x4E, 0x47]));
1904
+ const { compositeHost } = cube.can('cap:in="media:void";op=test;out="media:string"');
1905
+
1906
+ return compositeHost.executeCap(
1907
+ 'cap:in="media:void";op=test;out="media:string"',
1908
+ [binaryArg]
1909
+ ).then(() => {
1910
+ assert(receivedArgs !== null, 'Should receive arguments');
1911
+ assertEqual(receivedArgs[0].mediaUrn, 'media:pdf', 'Correct mediaUrn');
1912
+ assertEqual(receivedArgs[0].value[0], 0x89, 'First byte check');
1913
+ assertEqual(receivedArgs[0].value.length, 4, 'Correct data length');
1914
+ });
1915
+ }
1916
+
1917
+ function testJS_mediaSpecConstruction() {
1918
+ const spec1 = new MediaSpec('text/plain', 'https://capdag.com/schema/str', null, 'String', null, 'media:string');
1919
+ assertEqual(spec1.contentType, 'text/plain', 'Should have content type');
1920
+ assertEqual(spec1.profile, 'https://capdag.com/schema/str', 'Should have profile');
1921
+ assertEqual(spec1.title, 'String', 'Should have title');
1922
+ assertEqual(spec1.mediaUrn, 'media:string', 'Should have mediaUrn');
1923
+
1924
+ const spec2 = new MediaSpec('application/octet-stream', null, null, 'Binary', null, 'media:binary');
1925
+ assertEqual(spec2.profile, null, 'Should have null profile');
1926
+ }
1927
+
1928
+ // =============================================================================
1929
+ // Plugin Repository Tests (TEST320-TEST335)
1930
+ // =============================================================================
1931
+
1932
+ // Sample registry for testing
1933
+ const sampleRegistry = {
1934
+ schemaVersion: '3.0',
1935
+ lastUpdated: '2026-02-07T16:48:28Z',
1936
+ plugins: {
1937
+ pdfcartridge: {
1938
+ name: 'pdfcartridge',
1939
+ description: 'PDF document processor',
1940
+ author: 'test-author',
1941
+ pageUrl: 'https://example.com/pdf',
1942
+ teamId: 'P336JK947M',
1943
+ minAppVersion: '1.0.0',
1944
+ categories: ['document'],
1945
+ tags: ['pdf', 'extractor'],
1946
+ caps: [
1947
+ {
1948
+ urn: 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"',
1949
+ title: 'Disbind PDF',
1950
+ description: 'Extract pages'
1951
+ },
1952
+ {
1953
+ urn: 'cap:in="media:pdf";op=extract_metadata;out="media:file-metadata;textable;record"',
1954
+ title: 'Extract Metadata',
1955
+ description: 'Get PDF metadata'
1956
+ }
1957
+ ],
1958
+ latestVersion: '0.81.5325',
1959
+ versions: {
1960
+ '0.81.5325': {
1961
+ releaseDate: '2026-02-07T16:40:28Z',
1962
+ changelog: ['Initial release'],
1963
+ minAppVersion: '1.0.0',
1964
+ platform: 'darwin-arm64',
1965
+ package: {
1966
+ name: 'pdfcartridge-0.81.5325.pkg',
1967
+ sha256: '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0',
1968
+ size: 5187485
1969
+ },
1970
+ binary: {
1971
+ name: 'pdfcartridge-0.81.5325-darwin-arm64',
1972
+ sha256: '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19',
1973
+ size: 12980288
1974
+ }
1975
+ }
1976
+ }
1977
+ },
1978
+ txtcartridge: {
1979
+ name: 'txtcartridge',
1980
+ description: 'Text file processor',
1981
+ author: 'test-author',
1982
+ pageUrl: 'https://example.com/txt',
1983
+ teamId: 'P336JK947M',
1984
+ minAppVersion: '1.0.0',
1985
+ categories: ['text'],
1986
+ tags: ['txt', 'text'],
1987
+ caps: [
1988
+ {
1989
+ urn: 'cap:in="media:txt;textable";op=disbind;out="media:disbound-page;textable;list"',
1990
+ title: 'Disbind Text',
1991
+ description: 'Extract text pages'
1992
+ }
1993
+ ],
1994
+ latestVersion: '0.54.6408',
1995
+ versions: {
1996
+ '0.54.6408': {
1997
+ releaseDate: '2026-02-07T17:44:00Z',
1998
+ changelog: ['First version'],
1999
+ minAppVersion: '1.0.0',
2000
+ platform: 'darwin-arm64',
2001
+ package: {
2002
+ name: 'txtcartridge-0.54.6408.pkg',
2003
+ sha256: 'abc123',
2004
+ size: 821000
2005
+ },
2006
+ binary: {
2007
+ name: 'txtcartridge-0.54.6408-darwin-arm64',
2008
+ sha256: 'def456',
2009
+ size: 1700000
2010
+ }
2011
+ }
2012
+ }
2013
+ }
2014
+ }
2015
+ };
2016
+
2017
+ // TEST320: Plugin info construction
2018
+ function test320_pluginInfoConstruction() {
2019
+ const data = {
2020
+ id: 'testplugin',
2021
+ name: 'Test Plugin',
2022
+ version: '1.0.0',
2023
+ description: 'A test',
2024
+ teamId: 'TEAM123',
2025
+ signedAt: '2026-01-01',
2026
+ binaryName: 'test-binary',
2027
+ binarySha256: 'abc123',
2028
+ caps: [{urn: 'cap:in="media:void";op=test;out="media:void"', title: 'Test', description: ''}]
2029
+ };
2030
+ const plugin = new PluginInfo(data);
2031
+ assert(plugin.id === 'testplugin', 'ID should match');
2032
+ assert(plugin.teamId === 'TEAM123', 'Team ID should match');
2033
+ assert(plugin.caps.length === 1, 'Should have 1 cap');
2034
+ assert(plugin.caps[0].urn === 'cap:in="media:void";op=test;out="media:void"', 'Cap URN should match');
2035
+ }
2036
+
2037
+ // TEST321: Plugin info is signed check
2038
+ function test321_pluginInfoIsSigned() {
2039
+ const signed = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', caps: []});
2040
+ assert(signed.isSigned() === true, 'Plugin with teamId and signedAt should be signed');
2041
+
2042
+ const unsigned1 = new PluginInfo({id: 'test', teamId: '', signedAt: '2026-01-01', caps: []});
2043
+ assert(unsigned1.isSigned() === false, 'Plugin without teamId should not be signed');
2044
+
2045
+ const unsigned2 = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '', caps: []});
2046
+ assert(unsigned2.isSigned() === false, 'Plugin without signedAt should not be signed');
2047
+ }
2048
+
2049
+ // TEST322: Plugin info has binary check
2050
+ function test322_pluginInfoHasBinary() {
2051
+ const withBinary = new PluginInfo({id: 'test', binaryName: 'test-bin', binarySha256: 'abc', caps: []});
2052
+ assert(withBinary.hasBinary() === true, 'Plugin with binary info should return true');
2053
+
2054
+ const noBinary1 = new PluginInfo({id: 'test', binaryName: '', binarySha256: 'abc', caps: []});
2055
+ assert(noBinary1.hasBinary() === false, 'Plugin without binaryName should return false');
2056
+
2057
+ const noBinary2 = new PluginInfo({id: 'test', binaryName: 'test', binarySha256: '', caps: []});
2058
+ assert(noBinary2.hasBinary() === false, 'Plugin without binarySha256 should return false');
2059
+ }
2060
+
2061
+ // TEST323: PluginRepoServer validate registry
2062
+ function test323_pluginRepoServerValidateRegistry() {
2063
+ // Valid registry
2064
+ const server = new PluginRepoServer(sampleRegistry);
2065
+ assert(server.registry.schemaVersion === '3.0', 'Should accept valid registry');
2066
+
2067
+ // Invalid schema version
2068
+ let threw = false;
2069
+ try {
2070
+ new PluginRepoServer({schemaVersion: '2.0', plugins: {}});
2071
+ } catch (e) {
2072
+ threw = true;
2073
+ assert(e.message.includes('schema version'), 'Should reject wrong schema version');
2074
+ }
2075
+ assert(threw, 'Should throw for invalid schema');
2076
+
2077
+ // Missing plugins
2078
+ threw = false;
2079
+ try {
2080
+ new PluginRepoServer({schemaVersion: '3.0'});
2081
+ } catch (e) {
2082
+ threw = true;
2083
+ assert(e.message.includes('plugins'), 'Should reject missing plugins');
2084
+ }
2085
+ assert(threw, 'Should throw for missing plugins');
2086
+ }
2087
+
2088
+ // TEST324: PluginRepoServer transform to array
2089
+ function test324_pluginRepoServerTransformToArray() {
2090
+ const server = new PluginRepoServer(sampleRegistry);
2091
+ const plugins = server.transformToPluginArray();
2092
+
2093
+ assert(Array.isArray(plugins), 'Should return array');
2094
+ assert(plugins.length === 2, 'Should have 2 plugins');
2095
+
2096
+ const pdf = plugins.find(p => p.id === 'pdfcartridge');
2097
+ assert(pdf !== undefined, 'Should include pdfcartridge');
2098
+ assert(pdf.version === '0.81.5325', 'Should have latest version');
2099
+ assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
2100
+ assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
2101
+ assert(pdf.binaryName === 'pdfcartridge-0.81.5325-darwin-arm64', 'Should have binary name');
2102
+ assert(pdf.binarySha256 === '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19', 'Should have SHA256');
2103
+ assert(Array.isArray(pdf.caps), 'Should have caps array');
2104
+ assert(pdf.caps.length === 2, 'Should have 2 caps');
2105
+ }
2106
+
2107
+ // TEST325: PluginRepoServer get plugins
2108
+ function test325_pluginRepoServerGetPlugins() {
2109
+ const server = new PluginRepoServer(sampleRegistry);
2110
+ const response = server.getPlugins();
2111
+
2112
+ assert(response.plugins !== undefined, 'Should have plugins field');
2113
+ assert(Array.isArray(response.plugins), 'Plugins should be array');
2114
+ assert(response.plugins.length === 2, 'Should have 2 plugins');
2115
+ }
2116
+
2117
+ // TEST326: PluginRepoServer get plugin by ID
2118
+ function test326_pluginRepoServerGetPluginById() {
2119
+ const server = new PluginRepoServer(sampleRegistry);
2120
+
2121
+ const pdf = server.getPluginById('pdfcartridge');
2122
+ assert(pdf !== undefined, 'Should find pdfcartridge');
2123
+ assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
2124
+
2125
+ const notFound = server.getPluginById('nonexistent');
2126
+ assert(notFound === undefined, 'Should return undefined for missing plugin');
2127
+ }
2128
+
2129
+ // TEST327: PluginRepoServer search plugins
2130
+ function test327_pluginRepoServerSearchPlugins() {
2131
+ const server = new PluginRepoServer(sampleRegistry);
2132
+
2133
+ const pdfResults = server.searchPlugins('pdf');
2134
+ assert(pdfResults.length === 1, 'Should find 1 PDF plugin');
2135
+ assert(pdfResults[0].id === 'pdfcartridge', 'Should find pdfcartridge');
2136
+
2137
+ const metadataResults = server.searchPlugins('metadata');
2138
+ assert(metadataResults.length === 1, 'Should find plugin by cap title');
2139
+
2140
+ const noResults = server.searchPlugins('nonexistent');
2141
+ assert(noResults.length === 0, 'Should return empty for no matches');
2142
+ }
2143
+
2144
+ // TEST328: PluginRepoServer get by category
2145
+ function test328_pluginRepoServerGetByCategory() {
2146
+ const server = new PluginRepoServer(sampleRegistry);
2147
+
2148
+ const docPlugins = server.getPluginsByCategory('document');
2149
+ assert(docPlugins.length === 1, 'Should find 1 document plugin');
2150
+ assert(docPlugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2151
+
2152
+ const textPlugins = server.getPluginsByCategory('text');
2153
+ assert(textPlugins.length === 1, 'Should find 1 text plugin');
2154
+ assert(textPlugins[0].id === 'txtcartridge', 'Should be txtcartridge');
2155
+ }
2156
+
2157
+ // TEST329: PluginRepoServer get by cap
2158
+ function test329_pluginRepoServerGetByCap() {
2159
+ const server = new PluginRepoServer(sampleRegistry);
2160
+
2161
+ const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2162
+ const plugins = server.getPluginsByCap(disbindCap);
2163
+
2164
+ assert(plugins.length === 1, 'Should find 1 plugin with this cap');
2165
+ assert(plugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2166
+
2167
+ const metadataCap = 'cap:in="media:pdf";op=extract_metadata;out="media:file-metadata;textable;record"';
2168
+ const metadataPlugins = server.getPluginsByCap(metadataCap);
2169
+ assert(metadataPlugins.length === 1, 'Should find metadata cap');
2170
+ }
2171
+
2172
+ // TEST330: PluginRepoClient update cache
2173
+ function test330_pluginRepoClientUpdateCache() {
2174
+ const client = new PluginRepoClient(3600);
2175
+ const server = new PluginRepoServer(sampleRegistry);
2176
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2177
+
2178
+ client.updateCache('https://example.com/api/plugins', plugins);
2179
+
2180
+ const cache = client.caches.get('https://example.com/api/plugins');
2181
+ assert(cache !== undefined, 'Cache should exist');
2182
+ assert(cache.plugins.size === 2, 'Should have 2 plugins in cache');
2183
+ assert(cache.capToPlugins.size > 0, 'Should have cap mappings');
2184
+ }
2185
+
2186
+ // TEST331: PluginRepoClient get suggestions
2187
+ function test331_pluginRepoClientGetSuggestions() {
2188
+ const client = new PluginRepoClient(3600);
2189
+ const server = new PluginRepoServer(sampleRegistry);
2190
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2191
+
2192
+ client.updateCache('https://example.com/api/plugins', plugins);
2193
+
2194
+ const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2195
+ const suggestions = client.getSuggestionsForCap(disbindCap);
2196
+
2197
+ assert(suggestions.length === 1, 'Should find 1 suggestion');
2198
+ assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest pdfcartridge');
2199
+ assert(suggestions[0].capUrn === disbindCap, 'Should have correct cap URN');
2200
+ assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
2201
+ }
2202
+
2203
+ // TEST332: PluginRepoClient get plugin
2204
+ function test332_pluginRepoClientGetPlugin() {
2205
+ const client = new PluginRepoClient(3600);
2206
+ const server = new PluginRepoServer(sampleRegistry);
2207
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2208
+
2209
+ client.updateCache('https://example.com/api/plugins', plugins);
2210
+
2211
+ const plugin = client.getPlugin('pdfcartridge');
2212
+ assert(plugin !== null, 'Should find plugin');
2213
+ assert(plugin.id === 'pdfcartridge', 'Should have correct ID');
2214
+
2215
+ const notFound = client.getPlugin('nonexistent');
2216
+ assert(notFound === null, 'Should return null for missing plugin');
2217
+ }
2218
+
2219
+ // TEST333: PluginRepoClient get all caps
2220
+ function test333_pluginRepoClientGetAllCaps() {
2221
+ const client = new PluginRepoClient(3600);
2222
+ const server = new PluginRepoServer(sampleRegistry);
2223
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2224
+
2225
+ client.updateCache('https://example.com/api/plugins', plugins);
2226
+
2227
+ const caps = client.getAllAvailableCaps();
2228
+ assert(Array.isArray(caps), 'Should return array');
2229
+ assert(caps.length === 3, 'Should have 3 unique caps');
2230
+ assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
2231
+ }
2232
+
2233
+ // TEST334: PluginRepoClient needs sync
2234
+ function test334_pluginRepoClientNeedsSync() {
2235
+ const client = new PluginRepoClient(1); // 1 second TTL
2236
+ const server = new PluginRepoServer(sampleRegistry);
2237
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2238
+
2239
+ const urls = ['https://example.com/api/plugins'];
2240
+
2241
+ // Should need sync initially
2242
+ assert(client.needsSync(urls) === true, 'Should need sync with empty cache');
2243
+
2244
+ // Update cache
2245
+ client.updateCache(urls[0], plugins);
2246
+
2247
+ // Should not need sync immediately
2248
+ assert(client.needsSync(urls) === false, 'Should not need sync right after update');
2249
+
2250
+ // Wait for cache to expire (1 second)
2251
+ // Note: Can't test this synchronously, would need async test
2252
+ }
2253
+
2254
+ // TEST335: PluginRepoServer and Client integration
2255
+ function test335_pluginRepoServerClientIntegration() {
2256
+ // Server creates API response
2257
+ const server = new PluginRepoServer(sampleRegistry);
2258
+ const apiResponse = server.getPlugins();
2259
+
2260
+ // Client consumes API response
2261
+ const client = new PluginRepoClient(3600);
2262
+ const plugins = apiResponse.plugins.map(p => new PluginInfo(p));
2263
+ client.updateCache('https://example.com/api/plugins', plugins);
2264
+
2265
+ // Client can find plugin
2266
+ const plugin = client.getPlugin('pdfcartridge');
2267
+ assert(plugin !== null, 'Client should find plugin from server data');
2268
+ assert(plugin.isSigned(), 'Plugin should be signed');
2269
+ assert(plugin.hasBinary(), 'Plugin should have binary');
2270
+
2271
+ // Client can get suggestions
2272
+ const capUrn = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2273
+ const suggestions = client.getSuggestionsForCap(capUrn);
2274
+ assert(suggestions.length === 1, 'Should get suggestions');
2275
+ assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest correct plugin');
2276
+
2277
+ // Server can search
2278
+ const searchResults = server.searchPlugins('pdf');
2279
+ assert(searchResults.length === 1, 'Server search should work');
2280
+ assert(searchResults[0].id === plugin.id, 'Search and client should agree');
2281
+ }
2282
+
2283
+ // ============================================================================
2284
+ // media_urn.rs: TEST546-TEST558 (MediaUrn predicates)
2285
+ // ============================================================================
2286
+
2287
+ // TEST546: isImage returns true only when image marker tag is present
2288
+ function test546_isImage() {
2289
+ assert(MediaUrn.fromString(MEDIA_PNG).isImage(), 'MEDIA_PNG should be image');
2290
+ assert(MediaUrn.fromString(MEDIA_IMAGE_THUMBNAIL).isImage(), 'MEDIA_IMAGE_THUMBNAIL should be image');
2291
+ assert(MediaUrn.fromString('media:image;jpg').isImage(), 'media:image;jpg should be image');
2292
+ // Non-image types
2293
+ assert(!MediaUrn.fromString(MEDIA_PDF).isImage(), 'MEDIA_PDF should not be image');
2294
+ assert(!MediaUrn.fromString(MEDIA_STRING).isImage(), 'MEDIA_STRING should not be image');
2295
+ assert(!MediaUrn.fromString(MEDIA_AUDIO).isImage(), 'MEDIA_AUDIO should not be image');
2296
+ assert(!MediaUrn.fromString(MEDIA_VIDEO).isImage(), 'MEDIA_VIDEO should not be image');
2297
+ }
2298
+
2299
+ // TEST547: isAudio returns true only when audio marker tag is present
2300
+ function test547_isAudio() {
2301
+ assert(MediaUrn.fromString(MEDIA_AUDIO).isAudio(), 'MEDIA_AUDIO should be audio');
2302
+ assert(MediaUrn.fromString(MEDIA_AUDIO_SPEECH).isAudio(), 'MEDIA_AUDIO_SPEECH should be audio');
2303
+ assert(MediaUrn.fromString('media:audio;mp3').isAudio(), 'media:audio;mp3 should be audio');
2304
+ // Non-audio types
2305
+ assert(!MediaUrn.fromString(MEDIA_VIDEO).isAudio(), 'MEDIA_VIDEO should not be audio');
2306
+ assert(!MediaUrn.fromString(MEDIA_PNG).isAudio(), 'MEDIA_PNG should not be audio');
2307
+ assert(!MediaUrn.fromString(MEDIA_STRING).isAudio(), 'MEDIA_STRING should not be audio');
2308
+ }
2309
+
2310
+ // TEST548: isVideo returns true only when video marker tag is present
2311
+ function test548_isVideo() {
2312
+ assert(MediaUrn.fromString(MEDIA_VIDEO).isVideo(), 'MEDIA_VIDEO should be video');
2313
+ assert(MediaUrn.fromString('media:video;mp4').isVideo(), 'media:video;mp4 should be video');
2314
+ // Non-video types
2315
+ assert(!MediaUrn.fromString(MEDIA_AUDIO).isVideo(), 'MEDIA_AUDIO should not be video');
2316
+ assert(!MediaUrn.fromString(MEDIA_PNG).isVideo(), 'MEDIA_PNG should not be video');
2317
+ assert(!MediaUrn.fromString(MEDIA_STRING).isVideo(), 'MEDIA_STRING should not be video');
2318
+ }
2319
+
2320
+ // TEST549: isNumeric returns true only when numeric marker tag is present
2321
+ function test549_isNumeric() {
2322
+ assert(MediaUrn.fromString(MEDIA_INTEGER).isNumeric(), 'MEDIA_INTEGER should be numeric');
2323
+ assert(MediaUrn.fromString(MEDIA_NUMBER).isNumeric(), 'MEDIA_NUMBER should be numeric');
2324
+ assert(MediaUrn.fromString(MEDIA_INTEGER_ARRAY).isNumeric(), 'MEDIA_INTEGER_ARRAY should be numeric');
2325
+ assert(MediaUrn.fromString(MEDIA_NUMBER_ARRAY).isNumeric(), 'MEDIA_NUMBER_ARRAY should be numeric');
2326
+ // Non-numeric types
2327
+ assert(!MediaUrn.fromString(MEDIA_STRING).isNumeric(), 'MEDIA_STRING should not be numeric');
2328
+ assert(!MediaUrn.fromString(MEDIA_BOOLEAN).isNumeric(), 'MEDIA_BOOLEAN should not be numeric');
2329
+ assert(!MediaUrn.fromString(MEDIA_BINARY).isNumeric(), 'MEDIA_BINARY should not be numeric');
2330
+ }
2331
+
2332
+ // TEST550: isBool returns true only when bool marker tag is present
2333
+ function test550_isBool() {
2334
+ assert(MediaUrn.fromString(MEDIA_BOOLEAN).isBool(), 'MEDIA_BOOLEAN should be bool');
2335
+ assert(MediaUrn.fromString(MEDIA_BOOLEAN_ARRAY).isBool(), 'MEDIA_BOOLEAN_ARRAY should be bool');
2336
+ assert(MediaUrn.fromString(MEDIA_DECISION).isBool(), 'MEDIA_DECISION should be bool');
2337
+ assert(MediaUrn.fromString(MEDIA_DECISION_ARRAY).isBool(), 'MEDIA_DECISION_ARRAY should be bool');
2338
+ // Non-bool types
2339
+ assert(!MediaUrn.fromString(MEDIA_STRING).isBool(), 'MEDIA_STRING should not be bool');
2340
+ assert(!MediaUrn.fromString(MEDIA_INTEGER).isBool(), 'MEDIA_INTEGER should not be bool');
2341
+ assert(!MediaUrn.fromString(MEDIA_BINARY).isBool(), 'MEDIA_BINARY should not be bool');
2342
+ }
2343
+
2344
+ // TEST551: isFilePath returns true for scalar file-path, false for array
2345
+ function test551_isFilePath() {
2346
+ assert(MediaUrn.fromString(MEDIA_FILE_PATH).isFilePath(), 'MEDIA_FILE_PATH should be file-path');
2347
+ // Array file-path is NOT isFilePath (it's isFilePathArray)
2348
+ assert(!MediaUrn.fromString(MEDIA_FILE_PATH_ARRAY).isFilePath(), 'MEDIA_FILE_PATH_ARRAY should not be isFilePath');
2349
+ // Non-file-path types
2350
+ assert(!MediaUrn.fromString(MEDIA_STRING).isFilePath(), 'MEDIA_STRING should not be file-path');
2351
+ assert(!MediaUrn.fromString(MEDIA_BINARY).isFilePath(), 'MEDIA_BINARY should not be file-path');
2352
+ }
2353
+
2354
+ // TEST552: isFilePathArray returns true for list file-path, false for scalar
2355
+ function test552_isFilePathArray() {
2356
+ assert(MediaUrn.fromString(MEDIA_FILE_PATH_ARRAY).isFilePathArray(), 'MEDIA_FILE_PATH_ARRAY should be file-path-array');
2357
+ // Scalar file-path is NOT isFilePathArray
2358
+ assert(!MediaUrn.fromString(MEDIA_FILE_PATH).isFilePathArray(), 'MEDIA_FILE_PATH should not be isFilePathArray');
2359
+ // Non-file-path types
2360
+ assert(!MediaUrn.fromString(MEDIA_STRING_ARRAY).isFilePathArray(), 'MEDIA_STRING_ARRAY should not be file-path-array');
2361
+ }
2362
+
2363
+ // TEST553: isAnyFilePath returns true for both scalar and array file-path
2364
+ function test553_isAnyFilePath() {
2365
+ assert(MediaUrn.fromString(MEDIA_FILE_PATH).isAnyFilePath(), 'MEDIA_FILE_PATH should be any-file-path');
2366
+ assert(MediaUrn.fromString(MEDIA_FILE_PATH_ARRAY).isAnyFilePath(), 'MEDIA_FILE_PATH_ARRAY should be any-file-path');
2367
+ // Non-file-path types
2368
+ assert(!MediaUrn.fromString(MEDIA_STRING).isAnyFilePath(), 'MEDIA_STRING should not be any-file-path');
2369
+ assert(!MediaUrn.fromString(MEDIA_STRING_ARRAY).isAnyFilePath(), 'MEDIA_STRING_ARRAY should not be any-file-path');
2370
+ }
2371
+
2372
+ // TEST554: isCollection returns true when collection marker tag is present
2373
+ // TEST554: N/A for JS (MEDIA_COLLECTION constants removed - no longer exists)
2374
+ function test554_isCollection() {
2375
+ // Skip - collection types removed from capdag
2376
+ }
2377
+
2378
+ // TEST555: N/A for JS (with_tag/without_tag on MediaUrn - JS MediaUrn does not have these methods)
2379
+
2380
+ // TEST556: N/A for JS (image_media_urn_for_ext helper not in JS)
2381
+
2382
+ // TEST557: N/A for JS (audio_media_urn_for_ext helper not in JS)
2383
+
2384
+ // TEST558: predicates are consistent with constants - every constant triggers exactly the expected predicates
2385
+ function test558_predicateConstantConsistency() {
2386
+ // MEDIA_INTEGER must be numeric, text, scalar, NOT binary/bool/image/audio/video
2387
+ const intUrn = MediaUrn.fromString(MEDIA_INTEGER);
2388
+ assert(intUrn.isNumeric(), 'MEDIA_INTEGER must be numeric');
2389
+ assert(intUrn.isText(), 'MEDIA_INTEGER must be text');
2390
+ assert(intUrn.isScalar(), 'MEDIA_INTEGER must be scalar');
2391
+ assert(!intUrn.isBinary(), 'MEDIA_INTEGER must not be binary');
2392
+ assert(!intUrn.isBool(), 'MEDIA_INTEGER must not be bool');
2393
+ assert(!intUrn.isImage(), 'MEDIA_INTEGER must not be image');
2394
+ assert(!intUrn.isList(), 'MEDIA_INTEGER must not be list');
2395
+
2396
+ // MEDIA_BOOLEAN must be bool, text, scalar, NOT numeric
2397
+ const boolUrn = MediaUrn.fromString(MEDIA_BOOLEAN);
2398
+ assert(boolUrn.isBool(), 'MEDIA_BOOLEAN must be bool');
2399
+ assert(boolUrn.isText(), 'MEDIA_BOOLEAN must be text');
2400
+ assert(boolUrn.isScalar(), 'MEDIA_BOOLEAN must be scalar');
2401
+ assert(!boolUrn.isNumeric(), 'MEDIA_BOOLEAN must not be numeric');
2402
+
2403
+ // MEDIA_JSON must be json, text, record, scalar, NOT binary
2404
+ const jsonUrn = MediaUrn.fromString(MEDIA_JSON);
2405
+ assert(jsonUrn.isJson(), 'MEDIA_JSON must be json');
2406
+ assert(jsonUrn.isText(), 'MEDIA_JSON must be text');
2407
+ assert(jsonUrn.isRecord(), 'MEDIA_JSON must be record');
2408
+ assert(jsonUrn.isScalar(), 'MEDIA_JSON must be scalar (no list marker)');
2409
+ assert(!jsonUrn.isBinary(), 'MEDIA_JSON must not be binary');
2410
+ assert(!jsonUrn.isList(), 'MEDIA_JSON must not be list');
2411
+
2412
+ // MEDIA_VOID is void, NOT text/numeric — but IS binary (no textable tag)
2413
+ const voidUrn = MediaUrn.fromString(MEDIA_VOID);
2414
+ assert(voidUrn.isVoid(), 'MEDIA_VOID must be void');
2415
+ assert(!voidUrn.isText(), 'MEDIA_VOID must not be text');
2416
+ assert(voidUrn.isBinary(), 'MEDIA_VOID must be binary (no textable tag)');
2417
+ assert(!voidUrn.isNumeric(), 'MEDIA_VOID must not be numeric');
2418
+ }
2419
+
2420
+ // ============================================================================
2421
+ // cap_urn.rs: TEST559-TEST567 (CapUrn tier tests)
2422
+ // ============================================================================
2423
+
2424
+ // TEST559: withoutTag removes tag, ignores in/out, case-insensitive for keys
2425
+ function test559_withoutTag() {
2426
+ const cap = CapUrn.fromString('cap:in="media:void";op=test;ext=pdf;out="media:void"');
2427
+ const removed = cap.withoutTag('ext');
2428
+ assertEqual(removed.getTag('ext'), undefined, 'withoutTag should remove ext');
2429
+ assertEqual(removed.getTag('op'), 'test', 'withoutTag should preserve op');
2430
+
2431
+ // Case-insensitive removal
2432
+ const removed2 = cap.withoutTag('EXT');
2433
+ assertEqual(removed2.getTag('ext'), undefined, 'withoutTag should be case-insensitive');
2434
+
2435
+ // Removing in/out is silently ignored
2436
+ const same = cap.withoutTag('in');
2437
+ assertEqual(same.getInSpec(), 'media:void', 'withoutTag must not remove in');
2438
+ const same2 = cap.withoutTag('out');
2439
+ assertEqual(same2.getOutSpec(), 'media:void', 'withoutTag must not remove out');
2440
+
2441
+ // Removing non-existent tag is no-op
2442
+ const same3 = cap.withoutTag('nonexistent');
2443
+ assert(same3.equals(cap), 'Removing non-existent tag is no-op');
2444
+ }
2445
+
2446
+ // TEST560: withInSpec and withOutSpec change direction specs
2447
+ function test560_withInOutSpec() {
2448
+ const cap = CapUrn.fromString('cap:in="media:void";op=test;out="media:void"');
2449
+
2450
+ const changedIn = cap.withInSpec('media:');
2451
+ assertEqual(changedIn.getInSpec(), 'media:', 'withInSpec should change inSpec');
2452
+ assertEqual(changedIn.getOutSpec(), 'media:void', 'withInSpec should preserve outSpec');
2453
+ assertEqual(changedIn.getTag('op'), 'test', 'withInSpec should preserve tags');
2454
+
2455
+ const changedOut = cap.withOutSpec('media:string');
2456
+ assertEqual(changedOut.getInSpec(), 'media:void', 'withOutSpec should preserve inSpec');
2457
+ assertEqual(changedOut.getOutSpec(), 'media:string', 'withOutSpec should change outSpec');
2458
+
2459
+ // Chain both
2460
+ const changedBoth = cap.withInSpec('media:pdf').withOutSpec('media:txt;textable');
2461
+ assertEqual(changedBoth.getInSpec(), 'media:pdf', 'Chain should set inSpec');
2462
+ assertEqual(changedBoth.getOutSpec(), 'media:txt;textable', 'Chain should set outSpec');
2463
+ }
2464
+
2465
+ // TEST561: N/A for JS (in_media_urn/out_media_urn not in JS CapUrn)
2466
+
2467
+ // TEST562: N/A for JS (canonical_option not in JS CapUrn)
2468
+
2469
+ // TEST563: CapMatcher.findAllMatches returns all matching caps sorted by specificity
2470
+ function test563_findAllMatches() {
2471
+ const caps = [
2472
+ CapUrn.fromString('cap:in="media:void";op=test;out="media:void"'),
2473
+ CapUrn.fromString('cap:in="media:void";op=test;ext=pdf;out="media:void"'),
2474
+ CapUrn.fromString('cap:in="media:void";op=different;out="media:void"'),
2475
+ ];
2476
+
2477
+ const request = CapUrn.fromString('cap:in="media:void";op=test;out="media:void"');
2478
+ const matches = CapMatcher.findAllMatches(caps, request);
2479
+
2480
+ // Should find 2 matches (op=test and op=test;ext=pdf), not op=different
2481
+ assertEqual(matches.length, 2, 'Should find 2 matches');
2482
+ // Sorted by specificity descending: ext=pdf first (more specific)
2483
+ assert(matches[0].specificity() >= matches[1].specificity(), 'First match should be more specific');
2484
+ assertEqual(matches[0].getTag('ext'), 'pdf', 'Most specific match should have ext=pdf');
2485
+ }
2486
+
2487
+ // TEST564: CapMatcher.areCompatible detects bidirectional overlap
2488
+ function test564_areCompatible() {
2489
+ const caps1 = [
2490
+ CapUrn.fromString('cap:in="media:void";op=test;out="media:void"'),
2491
+ ];
2492
+ const caps2 = [
2493
+ CapUrn.fromString('cap:in="media:void";op=test;ext=pdf;out="media:void"'),
2494
+ ];
2495
+ const caps3 = [
2496
+ CapUrn.fromString('cap:in="media:void";op=different;out="media:void"'),
2497
+ ];
2498
+
2499
+ // caps1 (op=test) accepts caps2 (op=test;ext=pdf) -> compatible
2500
+ assert(CapMatcher.areCompatible(caps1, caps2), 'caps1 and caps2 should be compatible');
2501
+
2502
+ // caps1 (op=test) vs caps3 (op=different) -> not compatible
2503
+ assert(!CapMatcher.areCompatible(caps1, caps3), 'caps1 and caps3 should not be compatible');
2504
+
2505
+ // Empty sets are not compatible
2506
+ assert(!CapMatcher.areCompatible([], caps1), 'Empty vs non-empty should not be compatible');
2507
+ assert(!CapMatcher.areCompatible(caps1, []), 'Non-empty vs empty should not be compatible');
2508
+ }
2509
+
2510
+ // TEST565: N/A for JS (tags_to_string not in JS CapUrn)
2511
+
2512
+ // TEST566: withTag silently ignores in/out keys
2513
+ function test566_withTagIgnoresInOut() {
2514
+ const cap = CapUrn.fromString('cap:in="media:void";op=test;out="media:void"');
2515
+ // Attempting to set in/out via withTag is silently ignored
2516
+ const same = cap.withTag('in', 'media:');
2517
+ assertEqual(same.getInSpec(), 'media:void', 'withTag must not change in_spec');
2518
+
2519
+ const same2 = cap.withTag('out', 'media:');
2520
+ assertEqual(same2.getOutSpec(), 'media:void', 'withTag must not change out_spec');
2521
+ }
2522
+
2523
+ // TEST567: N/A for JS (conforms_to_str/accepts_str not in JS CapUrn)
2524
+
2525
+ // ============================================================================
2526
+ // cap_urn.rs: TEST639-TEST653 (Cap URN wildcard tests)
2527
+ // ============================================================================
2528
+
2529
+ // Note: Rust allows missing in/out to default to "media:" wildcard.
2530
+ // JS currently requires in/out (throws MISSING_IN_SPEC/MISSING_OUT_SPEC).
2531
+ // The following tests cover the wildcard behavior that IS applicable to JS.
2532
+
2533
+ // TEST639-642: N/A for JS (JS requires in/out, does not default to media: wildcard)
2534
+
2535
+ // TEST643: cap:in=*;out=* treated as wildcards
2536
+ function test643_explicitAsteriskIsWildcard() {
2537
+ const cap = CapUrn.fromString('cap:in=*;out=*');
2538
+ assertEqual(cap.getInSpec(), '*', 'in=* should be stored as wildcard');
2539
+ assertEqual(cap.getOutSpec(), '*', 'out=* should be stored as wildcard');
2540
+ }
2541
+
2542
+ // TEST644: cap:in=media:;out=* has specific in, wildcard out
2543
+ function test644_specificInWildcardOut() {
2544
+ const cap = CapUrn.fromString('cap:in=media:;out=*');
2545
+ assertEqual(cap.getInSpec(), 'media:', 'Should have specific in');
2546
+ assertEqual(cap.getOutSpec(), '*', 'Should have wildcard out');
2547
+ }
2548
+
2549
+ // TEST645: cap:in=*;out=media:text has wildcard in, specific out
2550
+ function test645_wildcardInSpecificOut() {
2551
+ const cap = CapUrn.fromString('cap:in=*;out=media:text');
2552
+ assertEqual(cap.getInSpec(), '*', 'Should have wildcard in');
2553
+ assertEqual(cap.getOutSpec(), 'media:text', 'Should have specific out');
2554
+ }
2555
+
2556
+ // TEST646: N/A for JS (JS allows in=foo since it just checks for media: or *)
2557
+ // TEST647: N/A for JS (JS allows out=bar since it just checks for media: or *)
2558
+
2559
+ // TEST648: Wildcard in/out match specific caps
2560
+ function test648_wildcardAcceptsSpecific() {
2561
+ const wildcard = CapUrn.fromString('cap:in=*;out=*');
2562
+ const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2563
+
2564
+ assert(wildcard.accepts(specific), 'Wildcard should accept specific');
2565
+ assert(specific.conformsTo(wildcard), 'Specific should conform to wildcard');
2566
+ }
2567
+
2568
+ // TEST649: Specificity - wildcard has 0, specific has tag count
2569
+ function test649_specificityScoring() {
2570
+ const wildcard = CapUrn.fromString('cap:in=*;out=*');
2571
+ const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2572
+
2573
+ assertEqual(wildcard.specificity(), 0, 'Wildcard cap should have 0 specificity');
2574
+ assert(specific.specificity() > 0, 'Specific cap should have non-zero specificity');
2575
+ }
2576
+
2577
+ // TEST650: N/A for JS (JS requires in/out, cap:in;out;op=test would fail parsing)
2578
+
2579
+ // TEST651: All identity forms with explicit wildcards produce the same CapUrn
2580
+ function test651_identityFormsEquivalent() {
2581
+ const forms = [
2582
+ 'cap:in=*;out=*',
2583
+ 'cap:in="media:";out="media:"',
2584
+ ];
2585
+
2586
+ const first = CapUrn.fromString(forms[0]);
2587
+ // All forms should produce equivalent caps (wildcard behavior)
2588
+ for (let i = 1; i < forms.length; i++) {
2589
+ const cap = CapUrn.fromString(forms[i]);
2590
+ // Both should accept specific caps
2591
+ const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2592
+ assert(first.accepts(specific), `Form 0 should accept specific`);
2593
+ assert(cap.accepts(specific), `Form ${i} should accept specific`);
2594
+ }
2595
+ }
2596
+
2597
+ // TEST652: N/A for JS (CAP_IDENTITY constant not in JS)
2598
+
2599
+ // TEST653: Identity (no extra tags) does not steal routes from specific handlers
2600
+ function test653_identityRoutingIsolation() {
2601
+ const identity = CapUrn.fromString('cap:in=*;out=*');
2602
+ const specificRequest = CapUrn.fromString('cap:in="media:void";op=test;out="media:void"');
2603
+
2604
+ // Identity has specificity 0 (no tags, wildcard directions)
2605
+ assertEqual(identity.specificity(), 0, 'Identity specificity should be 0');
2606
+
2607
+ // Specific request has higher specificity
2608
+ assert(specificRequest.specificity() > identity.specificity(),
2609
+ 'Specific request should have higher specificity than identity');
2610
+
2611
+ // CapMatcher should prefer specific over identity
2612
+ const specificCap = CapUrn.fromString('cap:in="media:void";op=test;out="media:void"');
2613
+ const best = CapMatcher.findBestMatch([identity, specificCap], specificRequest);
2614
+ assert(best !== null, 'Should find a match');
2615
+ assertEqual(best.getTag('op'), 'test', 'CapMatcher should prefer specific cap over identity');
2616
+ }
2617
+
2618
+ // ============================================================================
2619
+ // Test runner
2620
+ // ============================================================================
2621
+
2622
+ async function runTests() {
2623
+ console.log('Running capdag-js tests...\n');
2624
+
2625
+ // cap_urn.rs: TEST001-TEST050, TEST890-TEST891
2626
+ console.log('--- cap_urn.rs ---');
2627
+ runTest('TEST001: cap_urn_creation', test001_capUrnCreation);
2628
+ runTest('TEST002: direction_specs_required', test002_directionSpecsRequired);
2629
+ runTest('TEST003: direction_matching', test003_directionMatching);
2630
+ runTest('TEST004: unquoted_values_lowercased', test004_unquotedValuesLowercased);
2631
+ runTest('TEST005: quoted_values_preserve_case', test005_quotedValuesPreserveCase);
2632
+ runTest('TEST006: quoted_value_special_chars', test006_quotedValueSpecialChars);
2633
+ runTest('TEST007: quoted_value_escape_sequences', test007_quotedValueEscapeSequences);
2634
+ runTest('TEST008: mixed_quoted_unquoted', test008_mixedQuotedUnquoted);
2635
+ runTest('TEST009: unterminated_quote_error', test009_unterminatedQuoteError);
2636
+ runTest('TEST010: invalid_escape_sequence_error', test010_invalidEscapeSequenceError);
2637
+ runTest('TEST011: serialization_smart_quoting', test011_serializationSmartQuoting);
2638
+ runTest('TEST012: round_trip_simple', test012_roundTripSimple);
2639
+ runTest('TEST013: round_trip_quoted', test013_roundTripQuoted);
2640
+ runTest('TEST014: round_trip_escapes', test014_roundTripEscapes);
2641
+ runTest('TEST015: cap_prefix_required', test015_capPrefixRequired);
2642
+ runTest('TEST016: trailing_semicolon_equivalence', test016_trailingSemicolonEquivalence);
2643
+ runTest('TEST017: tag_matching', test017_tagMatching);
2644
+ runTest('TEST018: matching_case_sensitive_values', test018_matchingCaseSensitiveValues);
2645
+ runTest('TEST019: missing_tag_handling', test019_missingTagHandling);
2646
+ runTest('TEST020: specificity', test020_specificity);
2647
+ runTest('TEST021: builder', test021_builder);
2648
+ runTest('TEST022: builder_requires_direction', test022_builderRequiresDirection);
2649
+ runTest('TEST023: builder_preserves_case', test023_builderPreservesCase);
2650
+ runTest('TEST024: compatibility', test024_compatibility);
2651
+ runTest('TEST025: best_match', test025_bestMatch);
2652
+ runTest('TEST026: merge_and_subset', test026_mergeAndSubset);
2653
+ runTest('TEST027: wildcard_tag', test027_wildcardTag);
2654
+ runTest('TEST028: empty_cap_urn_not_allowed', test028_emptyCapUrnNotAllowed);
2655
+ runTest('TEST029: minimal_cap_urn', test029_minimalCapUrn);
2656
+ runTest('TEST030: extended_character_support', test030_extendedCharacterSupport);
2657
+ runTest('TEST031: wildcard_restrictions', test031_wildcardRestrictions);
2658
+ runTest('TEST032: duplicate_key_rejection', test032_duplicateKeyRejection);
2659
+ runTest('TEST033: numeric_key_restriction', test033_numericKeyRestriction);
2660
+ runTest('TEST034: empty_value_error', test034_emptyValueError);
2661
+ runTest('TEST035: has_tag_case_sensitive', test035_hasTagCaseSensitive);
2662
+ runTest('TEST036: with_tag_preserves_value', test036_withTagPreservesValue);
2663
+ runTest('TEST037: with_tag_rejects_empty_value', test037_withTagRejectsEmptyValue);
2664
+ runTest('TEST038: semantic_equivalence', test038_semanticEquivalence);
2665
+ runTest('TEST039: get_tag_returns_direction_specs', test039_getTagReturnsDirectionSpecs);
2666
+ runTest('TEST040: matching_semantics_exact_match', test040_matchingSemanticsExactMatch);
2667
+ runTest('TEST041: matching_semantics_cap_missing_tag', test041_matchingSemanticsCapMissingTag);
2668
+ runTest('TEST042: matching_semantics_cap_has_extra_tag', test042_matchingSemanticsCapHasExtraTag);
2669
+ runTest('TEST043: matching_semantics_request_has_wildcard', test043_matchingSemanticsRequestHasWildcard);
2670
+ runTest('TEST044: matching_semantics_cap_has_wildcard', test044_matchingSemanticsCapHasWildcard);
2671
+ runTest('TEST045: matching_semantics_value_mismatch', test045_matchingSemanticsValueMismatch);
2672
+ runTest('TEST046: matching_semantics_fallback_pattern', test046_matchingSemanticsFallbackPattern);
2673
+ runTest('TEST047: matching_semantics_thumbnail_void_input', test047_matchingSemanticsThumbnailVoidInput);
2674
+ runTest('TEST048: matching_semantics_wildcard_direction', test048_matchingSemanticsWildcardDirection);
2675
+ runTest('TEST049: matching_semantics_cross_dimension', test049_matchingSemanticsCrossDimension);
2676
+ runTest('TEST050: matching_semantics_direction_mismatch', test050_matchingSemanticsDirectionMismatch);
2677
+ runTest('TEST890: direction_semantic_matching', test890_directionSemanticMatching);
2678
+ runTest('TEST891: direction_semantic_specificity', test891_directionSemanticSpecificity);
2679
+
2680
+ // validation.rs: TEST053-TEST056
2681
+ console.log('\n--- validation.rs ---');
2682
+ console.log(' SKIP TEST053: N/A for JS (Rust-only validation infrastructure)');
2683
+ runTest('TEST054: xv5_inline_spec_redefinition_detected', test054_xv5InlineSpecRedefinitionDetected);
2684
+ runTest('TEST055: xv5_new_inline_spec_allowed', test055_xv5NewInlineSpecAllowed);
2685
+ runTest('TEST056: xv5_empty_media_specs_allowed', test056_xv5EmptyMediaSpecsAllowed);
2686
+
2687
+ // media_urn.rs: TEST060-TEST078
2688
+ console.log('\n--- media_urn.rs ---');
2689
+ runTest('TEST060: wrong_prefix_fails', test060_wrongPrefixFails);
2690
+ runTest('TEST061: is_binary', test061_isBinary);
2691
+ runTest('TEST062: is_record', test062_isRecord);
2692
+ runTest('TEST063: is_scalar', test063_isScalar);
2693
+ runTest('TEST064: is_list', test064_isList);
2694
+ runTest('TEST065: is_opaque', test065_isOpaque);
2695
+ runTest('TEST066: is_json', test066_isJson);
2696
+ runTest('TEST067: is_text', test067_isText);
2697
+ runTest('TEST068: is_void', test068_isVoid);
2698
+ console.log(' SKIP TEST069-070: N/A for JS (Rust binary_media_urn_for_ext/text_media_urn_for_ext)');
2699
+ runTest('TEST071: to_string_roundtrip', test071_toStringRoundtrip);
2700
+ runTest('TEST072: constants_parse', test072_constantsParse);
2701
+ console.log(' SKIP TEST073: N/A for JS (Rust extension helpers)');
2702
+ runTest('TEST074: media_urn_matching', test074_mediaUrnMatching);
2703
+ runTest('TEST075: accepts', test075_accepts);
2704
+ runTest('TEST076: specificity', test076_specificity);
2705
+ runTest('TEST077: serde_roundtrip (JSON.stringify)', test077_serdeRoundtrip);
2706
+ runTest('TEST078: debug_matching_behavior', test078_debugMatchingBehavior);
2707
+
2708
+ // media_spec.rs: TEST088-TEST110
2709
+ console.log('\n--- media_spec.rs ---');
2710
+ console.log(' SKIP TEST088-090: N/A for JS (async registry, Rust-only)');
2711
+ runTest('TEST091: resolve_custom_media_spec', test091_resolveCustomMediaSpec);
2712
+ runTest('TEST092: resolve_custom_with_schema', test092_resolveCustomWithSchema);
2713
+ runTest('TEST093: resolve_unresolvable_fails_hard', test093_resolveUnresolvableFailsHard);
2714
+ console.log(' SKIP TEST094: N/A for JS (no registry concept)');
2715
+ console.log(' SKIP TEST095-098: N/A for JS (Rust serde/validation)');
2716
+ runTest('TEST099: resolved_is_binary', test099_resolvedIsBinary);
2717
+ runTest('TEST100: resolved_is_record', test100_resolvedIsRecord);
2718
+ runTest('TEST101: resolved_is_scalar', test101_resolvedIsScalar);
2719
+ runTest('TEST102: resolved_is_list', test102_resolvedIsList);
2720
+ runTest('TEST103: resolved_is_json', test103_resolvedIsJson);
2721
+ runTest('TEST104: resolved_is_text', test104_resolvedIsText);
2722
+ runTest('TEST105: metadata_propagation', test105_metadataPropagation);
2723
+ runTest('TEST106: metadata_with_validation', test106_metadataWithValidation);
2724
+ runTest('TEST107: extensions_propagation', test107_extensionsPropagation);
2725
+ runTest('TEST108: extensions_serialization', test108_extensionsSerialization);
2726
+ runTest('TEST109: extensions_with_metadata_and_validation', test109_extensionsWithMetadataAndValidation);
2727
+ runTest('TEST110: multiple_extensions', test110_multipleExtensions);
2728
+
2729
+ // cap_matrix.rs: TEST117-TEST131
2730
+ console.log('\n--- cap_matrix.rs ---');
2731
+ runTest('TEST117: cap_block_more_specific_wins', test117_capBlockMoreSpecificWins);
2732
+ runTest('TEST118: cap_block_tie_goes_to_first', test118_capBlockTieGoesToFirst);
2733
+ runTest('TEST119: cap_block_polls_all', test119_capBlockPollsAll);
2734
+ runTest('TEST120: cap_block_no_match', test120_capBlockNoMatch);
2735
+ runTest('TEST121: cap_block_fallback_scenario', test121_capBlockFallbackScenario);
2736
+ runTest('TEST122: cap_block_can_method', test122_capBlockCanMethod);
2737
+ runTest('TEST123: cap_block_registry_management', test123_capBlockRegistryManagement);
2738
+ runTest('TEST124: cap_graph_basic_construction', test124_capGraphBasicConstruction);
2739
+ runTest('TEST125: cap_graph_outgoing_incoming', test125_capGraphOutgoingIncoming);
2740
+ runTest('TEST126: cap_graph_can_convert', test126_capGraphCanConvert);
2741
+ runTest('TEST127: cap_graph_find_path', test127_capGraphFindPath);
2742
+ runTest('TEST128: cap_graph_find_all_paths', test128_capGraphFindAllPaths);
2743
+ runTest('TEST129: cap_graph_direct_edges_sorted_by_specificity', test129_capGraphGetDirectEdges);
2744
+ runTest('TEST130: cap_graph_stats', test130_capGraphStats);
2745
+ runTest('TEST131: cap_block_graph_integration', test131_capGraphWithCapBlock);
2746
+ console.log(' SKIP TEST132-134: N/A (already covered by TEST129-131)');
2747
+
2748
+ // caller.rs: TEST156-TEST159
2749
+ console.log('\n--- caller.rs (StdinSource) ---');
2750
+ runTest('TEST156: stdin_source_from_data', test156_stdinSourceFromData);
2751
+ runTest('TEST157: stdin_source_from_file_reference', test157_stdinSourceFromFileReference);
2752
+ runTest('TEST158: stdin_source_empty_data', test158_stdinSourceWithEmptyData);
2753
+ runTest('TEST159: stdin_source_binary_content', test159_stdinSourceWithBinaryContent);
2754
+
2755
+ // caller.rs: TEST274-TEST283
2756
+ console.log('\n--- caller.rs (CapArgumentValue) ---');
2757
+ runTest('TEST274: cap_argument_value_new', test274_capArgumentValueNew);
2758
+ runTest('TEST275: cap_argument_value_from_str', test275_capArgumentValueFromStr);
2759
+ runTest('TEST276: cap_argument_value_as_str_valid', test276_capArgumentValueAsStrValid);
2760
+ runTest('TEST277: cap_argument_value_as_str_invalid_utf8', test277_capArgumentValueAsStrInvalidUtf8);
2761
+ runTest('TEST278: cap_argument_value_empty', test278_capArgumentValueEmpty);
2762
+ console.log(' SKIP TEST279-281: N/A for JS (Rust Debug/Clone/Send traits)');
2763
+ runTest('TEST282: cap_argument_value_unicode', test282_capArgumentValueUnicode);
2764
+ runTest('TEST283: cap_argument_value_large_binary', test283_capArgumentValueLargeBinary);
2765
+
2766
+ // standard/caps.rs: TEST304-TEST312
2767
+ console.log('\n--- standard/caps.rs ---');
2768
+ runTest('TEST304: media_availability_output_constant', test304_mediaAvailabilityOutputConstant);
2769
+ runTest('TEST305: media_path_output_constant', test305_mediaPathOutputConstant);
2770
+ runTest('TEST306: availability_and_path_output_distinct', test306_availabilityAndPathOutputDistinct);
2771
+ runTest('TEST307: model_availability_urn', test307_modelAvailabilityUrn);
2772
+ runTest('TEST308: model_path_urn', test308_modelPathUrn);
2773
+ runTest('TEST309: model_availability_and_path_are_distinct', test309_modelAvailabilityAndPathAreDistinct);
2774
+ runTest('TEST310: llm_conversation_urn_unconstrained', test310_llmConversationUrnUnconstrained);
2775
+ runTest('TEST311: llm_conversation_urn_specs', test311_llmConversationUrnSpecs);
2776
+ runTest('TEST312: all_urn_builders_produce_valid_urns', test312_allUrnBuildersProduceValidUrns);
2777
+
2778
+ // JS-specific tests (no Rust number)
2779
+ console.log('\n--- JS-specific ---');
2780
+ runTest('JS: build_extension_index', testJS_buildExtensionIndex);
2781
+ runTest('JS: media_urns_for_extension', testJS_mediaUrnsForExtension);
2782
+ runTest('JS: get_extension_mappings', testJS_getExtensionMappings);
2783
+ runTest('JS: cap_with_media_specs', testJS_capWithMediaSpecs);
2784
+ runTest('JS: cap_json_serialization', testJS_capJSONSerialization);
2785
+ runTest('JS: stdin_source_kind_constants', testJS_stdinSourceKindConstants);
2786
+ runTest('JS: stdin_source_null_data', testJS_stdinSourceNullData);
2787
+ const p1 = runTest('JS: args_passed_to_executeCap', testJS_argsPassedToExecuteCap);
2788
+ if (p1) await p1;
2789
+ const p2 = runTest('JS: binary_arg_passed_to_executeCap', testJS_binaryArgPassedToExecuteCap);
2790
+ if (p2) await p2;
2791
+ runTest('JS: media_spec_construction', testJS_mediaSpecConstruction);
2792
+
2793
+ // plugin_repo: PluginRepoServer and PluginRepoClient tests
2794
+ console.log('\n--- plugin_repo ---');
2795
+ runTest('TEST320: plugin_info_construction', test320_pluginInfoConstruction);
2796
+ runTest('TEST321: plugin_info_is_signed', test321_pluginInfoIsSigned);
2797
+ runTest('TEST322: plugin_info_has_binary', test322_pluginInfoHasBinary);
2798
+ runTest('TEST323: plugin_repo_server_validate_registry', test323_pluginRepoServerValidateRegistry);
2799
+ runTest('TEST324: plugin_repo_server_transform_to_array', test324_pluginRepoServerTransformToArray);
2800
+ runTest('TEST325: plugin_repo_server_get_plugins', test325_pluginRepoServerGetPlugins);
2801
+ runTest('TEST326: plugin_repo_server_get_plugin_by_id', test326_pluginRepoServerGetPluginById);
2802
+ runTest('TEST327: plugin_repo_server_search_plugins', test327_pluginRepoServerSearchPlugins);
2803
+ runTest('TEST328: plugin_repo_server_get_by_category', test328_pluginRepoServerGetByCategory);
2804
+ runTest('TEST329: plugin_repo_server_get_by_cap', test329_pluginRepoServerGetByCap);
2805
+ runTest('TEST330: plugin_repo_client_update_cache', test330_pluginRepoClientUpdateCache);
2806
+ runTest('TEST331: plugin_repo_client_get_suggestions', test331_pluginRepoClientGetSuggestions);
2807
+ runTest('TEST332: plugin_repo_client_get_plugin', test332_pluginRepoClientGetPlugin);
2808
+ runTest('TEST333: plugin_repo_client_get_all_caps', test333_pluginRepoClientGetAllCaps);
2809
+ runTest('TEST334: plugin_repo_client_needs_sync', test334_pluginRepoClientNeedsSync);
2810
+ runTest('TEST335: plugin_repo_server_client_integration', test335_pluginRepoServerClientIntegration);
2811
+
2812
+ // media_urn.rs: TEST546-TEST558 (MediaUrn predicates)
2813
+ console.log('\n--- media_urn.rs (predicates) ---');
2814
+ runTest('TEST546: is_image', test546_isImage);
2815
+ runTest('TEST547: is_audio', test547_isAudio);
2816
+ runTest('TEST548: is_video', test548_isVideo);
2817
+ runTest('TEST549: is_numeric', test549_isNumeric);
2818
+ runTest('TEST550: is_bool', test550_isBool);
2819
+ runTest('TEST551: is_file_path', test551_isFilePath);
2820
+ runTest('TEST552: is_file_path_array', test552_isFilePathArray);
2821
+ runTest('TEST553: is_any_file_path', test553_isAnyFilePath);
2822
+ console.log(' SKIP TEST554: N/A for JS (collection types removed from capdag)');
2823
+ console.log(' SKIP TEST555: N/A for JS (with_tag/without_tag on MediaUrn)');
2824
+ console.log(' SKIP TEST556: N/A for JS (image_media_urn_for_ext helper)');
2825
+ console.log(' SKIP TEST557: N/A for JS (audio_media_urn_for_ext helper)');
2826
+ runTest('TEST558: predicate_constant_consistency', test558_predicateConstantConsistency);
2827
+
2828
+ // cap_urn.rs: TEST559-TEST567 (CapUrn tier tests)
2829
+ console.log('\n--- cap_urn.rs (tier tests) ---');
2830
+ runTest('TEST559: without_tag', test559_withoutTag);
2831
+ runTest('TEST560: with_in_out_spec', test560_withInOutSpec);
2832
+ console.log(' SKIP TEST561: N/A for JS (in_media_urn/out_media_urn)');
2833
+ console.log(' SKIP TEST562: N/A for JS (canonical_option)');
2834
+ runTest('TEST563: find_all_matches', test563_findAllMatches);
2835
+ runTest('TEST564: are_compatible', test564_areCompatible);
2836
+ console.log(' SKIP TEST565: N/A for JS (tags_to_string)');
2837
+ runTest('TEST566: with_tag_ignores_in_out', test566_withTagIgnoresInOut);
2838
+ console.log(' SKIP TEST567: N/A for JS (conforms_to_str/accepts_str)');
2839
+
2840
+ // cap_urn.rs: TEST639-TEST653 (Cap URN wildcard tests)
2841
+ console.log('\n--- cap_urn.rs (wildcard tests) ---');
2842
+ console.log(' SKIP TEST639-642: N/A for JS (implicit wildcard defaults)');
2843
+ runTest('TEST643: explicit_asterisk_is_wildcard', test643_explicitAsteriskIsWildcard);
2844
+ runTest('TEST644: specific_in_wildcard_out', test644_specificInWildcardOut);
2845
+ runTest('TEST645: wildcard_in_specific_out', test645_wildcardInSpecificOut);
2846
+ console.log(' SKIP TEST646-647: N/A for JS (invalid spec validation differs)');
2847
+ runTest('TEST648: wildcard_accepts_specific', test648_wildcardAcceptsSpecific);
2848
+ runTest('TEST649: specificity_scoring', test649_specificityScoring);
2849
+ console.log(' SKIP TEST650: N/A for JS (requires in/out)');
2850
+ runTest('TEST651: identity_forms_equivalent', test651_identityFormsEquivalent);
2851
+ console.log(' SKIP TEST652: N/A for JS (CAP_IDENTITY constant)');
2852
+ runTest('TEST653: identity_routing_isolation', test653_identityRoutingIsolation);
2853
+
2854
+ // Summary
2855
+ console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
2856
+ if (failCount > 0) {
2857
+ console.log('ERR Some tests failed!');
2858
+ process.exit(1);
2859
+ } else {
2860
+ console.log('OK All tests passed!');
2861
+ }
2862
+ }
2863
+
2864
+ // Run the tests
2865
+ if (require.main === module) {
2866
+ runTests()
2867
+ .then(() => process.exit(0))
2868
+ .catch(error => {
2869
+ console.error('\nERR Test failed:', error.message);
2870
+ process.exit(1);
2871
+ });
2872
+ }
2873
+
2874
+ module.exports = { runTests };