capdag 0.119.263 → 0.124.274

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.
package/capdag.test.js CHANGED
@@ -8,7 +8,7 @@ const {
8
8
  Cap, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
9
9
  resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
10
10
  CapMatrixError, CapMatrix, BestCapSetMatch, CompositeCapSet, CapBlock,
11
- PluginInfo, PluginCapSummary, PluginSuggestion, PluginRepoClient, PluginRepoServer,
11
+ CartridgeInfo, CartridgeCapSummary, CartridgeSuggestion, CartridgeRepoClient, CartridgeRepoServer,
12
12
  CapGraphEdge, CapGraphStats, CapGraph,
13
13
  StdinSource, StdinSourceKind,
14
14
  validateNoMediaSpecRedefinitionSync,
@@ -1176,7 +1176,7 @@ function test110_multipleExtensions() {
1176
1176
  // TEST117: CapBlock finds more specific cap across registries
1177
1177
  function test117_capBlockMoreSpecificWins() {
1178
1178
  const providerRegistry = new CapMatrix();
1179
- const pluginRegistry = new CapMatrix();
1179
+ const cartridgeRegistry = new CapMatrix();
1180
1180
 
1181
1181
  const providerHost = new MockCapSet('provider');
1182
1182
  const providerCap = makeCap(
@@ -1185,22 +1185,22 @@ function test117_capBlockMoreSpecificWins() {
1185
1185
  );
1186
1186
  providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
1187
1187
 
1188
- const pluginHost = new MockCapSet('plugin');
1189
- const pluginCap = makeCap(
1188
+ const cartridgeHost = new MockCapSet('cartridge');
1189
+ const cartridgeCap = makeCap(
1190
1190
  'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
1191
- 'Plugin PDF Thumbnail Generator (specific)'
1191
+ 'Cartridge PDF Thumbnail Generator (specific)'
1192
1192
  );
1193
- pluginRegistry.registerCapSet('plugin', pluginHost, [pluginCap]);
1193
+ cartridgeRegistry.registerCapSet('cartridge', cartridgeHost, [cartridgeCap]);
1194
1194
 
1195
1195
  const composite = new CapBlock();
1196
1196
  composite.addRegistry('providers', providerRegistry);
1197
- composite.addRegistry('plugins', pluginRegistry);
1197
+ composite.addRegistry('cartridges', cartridgeRegistry);
1198
1198
 
1199
1199
  const request = 'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"';
1200
1200
  const best = composite.findBestCapSet(request);
1201
1201
 
1202
- assertEqual(best.registryName, 'plugins', 'More specific plugin should win');
1203
- assertEqual(best.cap.title, 'Plugin PDF Thumbnail Generator (specific)', 'Should get plugin cap');
1202
+ assertEqual(best.registryName, 'cartridges', 'More specific cartridge should win');
1203
+ assertEqual(best.cap.title, 'Cartridge PDF Thumbnail Generator (specific)', 'Should get cartridge cap');
1204
1204
  }
1205
1205
 
1206
1206
  // TEST118: CapBlock tie-breaking prefers first registry in order
@@ -1269,7 +1269,7 @@ function test120_capBlockNoMatch() {
1269
1269
  // TEST121: CapBlock fallback scenario where generic cap handles unknown file types
1270
1270
  function test121_capBlockFallbackScenario() {
1271
1271
  const providerRegistry = new CapMatrix();
1272
- const pluginRegistry = new CapMatrix();
1272
+ const cartridgeRegistry = new CapMatrix();
1273
1273
 
1274
1274
  const providerHost = new MockCapSet('provider_fallback');
1275
1275
  const providerCap = makeCap(
@@ -1278,20 +1278,20 @@ function test121_capBlockFallbackScenario() {
1278
1278
  );
1279
1279
  providerRegistry.registerCapSet('provider_fallback', providerHost, [providerCap]);
1280
1280
 
1281
- const pluginHost = new MockCapSet('pdf_plugin');
1282
- const pluginCap = makeCap(
1281
+ const cartridgeHost = new MockCapSet('pdf_cartridge');
1282
+ const cartridgeCap = makeCap(
1283
1283
  'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
1284
- 'PDF Thumbnail Plugin'
1284
+ 'PDF Thumbnail Cartridge'
1285
1285
  );
1286
- pluginRegistry.registerCapSet('pdf_plugin', pluginHost, [pluginCap]);
1286
+ cartridgeRegistry.registerCapSet('pdf_cartridge', cartridgeHost, [cartridgeCap]);
1287
1287
 
1288
1288
  const composite = new CapBlock();
1289
1289
  composite.addRegistry('providers', providerRegistry);
1290
- composite.addRegistry('plugins', pluginRegistry);
1290
+ composite.addRegistry('cartridges', cartridgeRegistry);
1291
1291
 
1292
- // PDF request -> plugin wins
1292
+ // PDF request -> cartridge wins
1293
1293
  const best = composite.findBestCapSet('cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"');
1294
- assertEqual(best.registryName, 'plugins', 'Plugin should win for PDF');
1294
+ assertEqual(best.registryName, 'cartridges', 'Cartridge should win for PDF');
1295
1295
 
1296
1296
  // WAV request -> provider wins (fallback)
1297
1297
  const bestWav = composite.findBestCapSet('cap:ext=wav;in="media:binary";op=generate_thumbnail;out="media:binary"');
@@ -1494,19 +1494,19 @@ function test130_capGraphStats() {
1494
1494
  // TEST131: CapGraph with CapBlock builds graph from multiple registries
1495
1495
  function test131_capGraphWithCapBlock() {
1496
1496
  const providerRegistry = new CapMatrix();
1497
- const pluginRegistry = new CapMatrix();
1497
+ const cartridgeRegistry = new CapMatrix();
1498
1498
  const providerHost = { executeCap: async () => ({ textOutput: 'provider' }) };
1499
- const pluginHost = { executeCap: async () => ({ textOutput: 'plugin' }) };
1499
+ const cartridgeHost = { executeCap: async () => ({ textOutput: 'cartridge' }) };
1500
1500
 
1501
1501
  const providerCap = makeGraphCap('media:binary', 'media:string', 'Provider Binary to String');
1502
1502
  providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
1503
1503
 
1504
- const pluginCap = makeGraphCap('media:string', 'media:object', 'Plugin String to Object');
1505
- pluginRegistry.registerCapSet('plugin', pluginHost, [pluginCap]);
1504
+ const cartridgeCap = makeGraphCap('media:string', 'media:object', 'Cartridge String to Object');
1505
+ cartridgeRegistry.registerCapSet('cartridge', cartridgeHost, [cartridgeCap]);
1506
1506
 
1507
1507
  const cube = new CapBlock();
1508
1508
  cube.addRegistry('providers', providerRegistry);
1509
- cube.addRegistry('plugins', pluginRegistry);
1509
+ cube.addRegistry('cartridges', cartridgeRegistry);
1510
1510
  const graph = cube.graph();
1511
1511
 
1512
1512
  assert(graph.canConvert('media:binary', 'media:object'), 'Should convert across registries');
@@ -1514,7 +1514,7 @@ function test131_capGraphWithCapBlock() {
1514
1514
  assert(path !== null, 'Should find path');
1515
1515
  assertEqual(path.length, 2, 'Path through 2 registries');
1516
1516
  assertEqual(path[0].registryName, 'providers', 'First edge from providers');
1517
- assertEqual(path[1].registryName, 'plugins', 'Second edge from plugins');
1517
+ assertEqual(path[1].registryName, 'cartridges', 'Second edge from cartridges');
1518
1518
  }
1519
1519
 
1520
1520
  // TEST132: N/A (already covered by TEST129)
@@ -2008,14 +2008,14 @@ function testJS_mediaSpecConstruction() {
2008
2008
  }
2009
2009
 
2010
2010
  // =============================================================================
2011
- // Plugin Repository Tests (TEST320-TEST335)
2011
+ // Cartridge Repository Tests (TEST320-TEST335)
2012
2012
  // =============================================================================
2013
2013
 
2014
2014
  // Sample registry for testing
2015
2015
  const sampleRegistry = {
2016
2016
  schemaVersion: '3.0',
2017
2017
  lastUpdated: '2026-02-07T16:48:28Z',
2018
- plugins: {
2018
+ cartridges: {
2019
2019
  pdfcartridge: {
2020
2020
  name: 'pdfcartridge',
2021
2021
  description: 'PDF document processor',
@@ -2048,11 +2048,6 @@ const sampleRegistry = {
2048
2048
  name: 'pdfcartridge-0.81.5325.pkg',
2049
2049
  sha256: '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0',
2050
2050
  size: 5187485
2051
- },
2052
- binary: {
2053
- name: 'pdfcartridge-0.81.5325-darwin-arm64',
2054
- sha256: '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19',
2055
- size: 12980288
2056
2051
  }
2057
2052
  }
2058
2053
  }
@@ -2084,11 +2079,6 @@ const sampleRegistry = {
2084
2079
  name: 'txtcartridge-0.54.6408.pkg',
2085
2080
  sha256: 'abc123',
2086
2081
  size: 821000
2087
- },
2088
- binary: {
2089
- name: 'txtcartridge-0.54.6408-darwin-arm64',
2090
- sha256: 'def456',
2091
- size: 1700000
2092
2082
  }
2093
2083
  }
2094
2084
  }
@@ -2096,215 +2086,215 @@ const sampleRegistry = {
2096
2086
  }
2097
2087
  };
2098
2088
 
2099
- // TEST320: Plugin info construction
2100
- function test320_pluginInfoConstruction() {
2089
+ // TEST320: Cartridge info construction
2090
+ function test320_cartridgeInfoConstruction() {
2101
2091
  const data = {
2102
- id: 'testplugin',
2103
- name: 'Test Plugin',
2092
+ id: 'testcartridge',
2093
+ name: 'Test Cartridge',
2104
2094
  version: '1.0.0',
2105
2095
  description: 'A test',
2106
2096
  teamId: 'TEAM123',
2107
2097
  signedAt: '2026-01-01',
2108
- binaryName: 'test-binary',
2109
- binarySha256: 'abc123',
2098
+ packageName: 'test-1.0.0.pkg',
2099
+ packageSha256: 'abc123',
2110
2100
  caps: [{urn: 'cap:in="media:void";op=test;out="media:void"', title: 'Test', description: ''}]
2111
2101
  };
2112
- const plugin = new PluginInfo(data);
2113
- assert(plugin.id === 'testplugin', 'ID should match');
2114
- assert(plugin.teamId === 'TEAM123', 'Team ID should match');
2115
- assert(plugin.caps.length === 1, 'Should have 1 cap');
2116
- assert(plugin.caps[0].urn === 'cap:in="media:void";op=test;out="media:void"', 'Cap URN should match');
2102
+ const cartridge = new CartridgeInfo(data);
2103
+ assert(cartridge.id === 'testcartridge', 'ID should match');
2104
+ assert(cartridge.teamId === 'TEAM123', 'Team ID should match');
2105
+ assert(cartridge.caps.length === 1, 'Should have 1 cap');
2106
+ assert(cartridge.caps[0].urn === 'cap:in="media:void";op=test;out="media:void"', 'Cap URN should match');
2117
2107
  }
2118
2108
 
2119
- // TEST321: Plugin info is signed check
2120
- function test321_pluginInfoIsSigned() {
2121
- const signed = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', caps: []});
2122
- assert(signed.isSigned() === true, 'Plugin with teamId and signedAt should be signed');
2109
+ // TEST321: Cartridge info is signed check
2110
+ function test321_cartridgeInfoIsSigned() {
2111
+ const signed = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', caps: []});
2112
+ assert(signed.isSigned() === true, 'Cartridge with teamId and signedAt should be signed');
2123
2113
 
2124
- const unsigned1 = new PluginInfo({id: 'test', teamId: '', signedAt: '2026-01-01', caps: []});
2125
- assert(unsigned1.isSigned() === false, 'Plugin without teamId should not be signed');
2114
+ const unsigned1 = new CartridgeInfo({id: 'test', teamId: '', signedAt: '2026-01-01', caps: []});
2115
+ assert(unsigned1.isSigned() === false, 'Cartridge without teamId should not be signed');
2126
2116
 
2127
- const unsigned2 = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '', caps: []});
2128
- assert(unsigned2.isSigned() === false, 'Plugin without signedAt should not be signed');
2117
+ const unsigned2 = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '', caps: []});
2118
+ assert(unsigned2.isSigned() === false, 'Cartridge without signedAt should not be signed');
2129
2119
  }
2130
2120
 
2131
- // TEST322: Plugin info has binary check
2132
- function test322_pluginInfoHasBinary() {
2133
- const withBinary = new PluginInfo({id: 'test', binaryName: 'test-bin', binarySha256: 'abc', caps: []});
2134
- assert(withBinary.hasBinary() === true, 'Plugin with binary info should return true');
2121
+ // TEST322: Cartridge info has package check
2122
+ function test322_cartridgeInfoHasPackage() {
2123
+ const withPkg = new CartridgeInfo({id: 'test', packageName: 'test.pkg', packageSha256: 'abc', caps: []});
2124
+ assert(withPkg.hasPackage() === true, 'Cartridge with package info should return true');
2135
2125
 
2136
- const noBinary1 = new PluginInfo({id: 'test', binaryName: '', binarySha256: 'abc', caps: []});
2137
- assert(noBinary1.hasBinary() === false, 'Plugin without binaryName should return false');
2126
+ const noPkg1 = new CartridgeInfo({id: 'test', packageName: '', packageSha256: 'abc', caps: []});
2127
+ assert(noPkg1.hasPackage() === false, 'Cartridge without packageName should return false');
2138
2128
 
2139
- const noBinary2 = new PluginInfo({id: 'test', binaryName: 'test', binarySha256: '', caps: []});
2140
- assert(noBinary2.hasBinary() === false, 'Plugin without binarySha256 should return false');
2129
+ const noPkg2 = new CartridgeInfo({id: 'test', packageName: 'test.pkg', packageSha256: '', caps: []});
2130
+ assert(noPkg2.hasPackage() === false, 'Cartridge without packageSha256 should return false');
2141
2131
  }
2142
2132
 
2143
- // TEST323: PluginRepoServer validate registry
2144
- function test323_pluginRepoServerValidateRegistry() {
2133
+ // TEST323: CartridgeRepoServer validate registry
2134
+ function test323_cartridgeRepoServerValidateRegistry() {
2145
2135
  // Valid registry
2146
- const server = new PluginRepoServer(sampleRegistry);
2136
+ const server = new CartridgeRepoServer(sampleRegistry);
2147
2137
  assert(server.registry.schemaVersion === '3.0', 'Should accept valid registry');
2148
2138
 
2149
2139
  // Invalid schema version
2150
2140
  let threw = false;
2151
2141
  try {
2152
- new PluginRepoServer({schemaVersion: '2.0', plugins: {}});
2142
+ new CartridgeRepoServer({schemaVersion: '2.0', cartridges: {}});
2153
2143
  } catch (e) {
2154
2144
  threw = true;
2155
2145
  assert(e.message.includes('schema version'), 'Should reject wrong schema version');
2156
2146
  }
2157
2147
  assert(threw, 'Should throw for invalid schema');
2158
2148
 
2159
- // Missing plugins
2149
+ // Missing cartridges
2160
2150
  threw = false;
2161
2151
  try {
2162
- new PluginRepoServer({schemaVersion: '3.0'});
2152
+ new CartridgeRepoServer({schemaVersion: '3.0'});
2163
2153
  } catch (e) {
2164
2154
  threw = true;
2165
- assert(e.message.includes('plugins'), 'Should reject missing plugins');
2155
+ assert(e.message.includes('cartridges'), 'Should reject missing cartridges');
2166
2156
  }
2167
- assert(threw, 'Should throw for missing plugins');
2157
+ assert(threw, 'Should throw for missing cartridges');
2168
2158
  }
2169
2159
 
2170
- // TEST324: PluginRepoServer transform to array
2171
- function test324_pluginRepoServerTransformToArray() {
2172
- const server = new PluginRepoServer(sampleRegistry);
2173
- const plugins = server.transformToPluginArray();
2160
+ // TEST324: CartridgeRepoServer transform to array
2161
+ function test324_cartridgeRepoServerTransformToArray() {
2162
+ const server = new CartridgeRepoServer(sampleRegistry);
2163
+ const cartridges = server.transformToCartridgeArray();
2174
2164
 
2175
- assert(Array.isArray(plugins), 'Should return array');
2176
- assert(plugins.length === 2, 'Should have 2 plugins');
2177
-
2178
- const pdf = plugins.find(p => p.id === 'pdfcartridge');
2165
+ assert(Array.isArray(cartridges), 'Should return array');
2166
+ assert(cartridges.length === 2, 'Should have 2 cartridges');
2167
+
2168
+ const pdf = cartridges.find(p => p.id === 'pdfcartridge');
2179
2169
  assert(pdf !== undefined, 'Should include pdfcartridge');
2180
2170
  assert(pdf.version === '0.81.5325', 'Should have latest version');
2181
2171
  assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
2182
2172
  assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
2183
- assert(pdf.binaryName === 'pdfcartridge-0.81.5325-darwin-arm64', 'Should have binary name');
2184
- assert(pdf.binarySha256 === '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19', 'Should have SHA256');
2173
+ assert(pdf.packageName === 'pdfcartridge-0.81.5325.pkg', 'Should have package name');
2174
+ assert(pdf.packageSha256 === '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0', 'Should have package SHA256');
2185
2175
  assert(Array.isArray(pdf.caps), 'Should have caps array');
2186
2176
  assert(pdf.caps.length === 2, 'Should have 2 caps');
2187
2177
  }
2188
2178
 
2189
- // TEST325: PluginRepoServer get plugins
2190
- function test325_pluginRepoServerGetPlugins() {
2191
- const server = new PluginRepoServer(sampleRegistry);
2192
- const response = server.getPlugins();
2179
+ // TEST325: CartridgeRepoServer get cartridges
2180
+ function test325_cartridgeRepoServerGetCartridges() {
2181
+ const server = new CartridgeRepoServer(sampleRegistry);
2182
+ const response = server.getCartridges();
2193
2183
 
2194
- assert(response.plugins !== undefined, 'Should have plugins field');
2195
- assert(Array.isArray(response.plugins), 'Plugins should be array');
2196
- assert(response.plugins.length === 2, 'Should have 2 plugins');
2184
+ assert(response.cartridges !== undefined, 'Should have cartridges field');
2185
+ assert(Array.isArray(response.cartridges), 'Cartridges should be array');
2186
+ assert(response.cartridges.length === 2, 'Should have 2 cartridges');
2197
2187
  }
2198
2188
 
2199
- // TEST326: PluginRepoServer get plugin by ID
2200
- function test326_pluginRepoServerGetPluginById() {
2201
- const server = new PluginRepoServer(sampleRegistry);
2189
+ // TEST326: CartridgeRepoServer get cartridge by ID
2190
+ function test326_cartridgeRepoServerGetCartridgeById() {
2191
+ const server = new CartridgeRepoServer(sampleRegistry);
2202
2192
 
2203
- const pdf = server.getPluginById('pdfcartridge');
2193
+ const pdf = server.getCartridgeById('pdfcartridge');
2204
2194
  assert(pdf !== undefined, 'Should find pdfcartridge');
2205
2195
  assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
2206
2196
 
2207
- const notFound = server.getPluginById('nonexistent');
2208
- assert(notFound === undefined, 'Should return undefined for missing plugin');
2197
+ const notFound = server.getCartridgeById('nonexistent');
2198
+ assert(notFound === undefined, 'Should return undefined for missing cartridge');
2209
2199
  }
2210
2200
 
2211
- // TEST327: PluginRepoServer search plugins
2212
- function test327_pluginRepoServerSearchPlugins() {
2213
- const server = new PluginRepoServer(sampleRegistry);
2201
+ // TEST327: CartridgeRepoServer search cartridges
2202
+ function test327_cartridgeRepoServerSearchCartridges() {
2203
+ const server = new CartridgeRepoServer(sampleRegistry);
2214
2204
 
2215
- const pdfResults = server.searchPlugins('pdf');
2216
- assert(pdfResults.length === 1, 'Should find 1 PDF plugin');
2205
+ const pdfResults = server.searchCartridges('pdf');
2206
+ assert(pdfResults.length === 1, 'Should find 1 PDF cartridge');
2217
2207
  assert(pdfResults[0].id === 'pdfcartridge', 'Should find pdfcartridge');
2218
2208
 
2219
- const metadataResults = server.searchPlugins('metadata');
2220
- assert(metadataResults.length === 1, 'Should find plugin by cap title');
2209
+ const metadataResults = server.searchCartridges('metadata');
2210
+ assert(metadataResults.length === 1, 'Should find cartridge by cap title');
2221
2211
 
2222
- const noResults = server.searchPlugins('nonexistent');
2212
+ const noResults = server.searchCartridges('nonexistent');
2223
2213
  assert(noResults.length === 0, 'Should return empty for no matches');
2224
2214
  }
2225
2215
 
2226
- // TEST328: PluginRepoServer get by category
2227
- function test328_pluginRepoServerGetByCategory() {
2228
- const server = new PluginRepoServer(sampleRegistry);
2216
+ // TEST328: CartridgeRepoServer get by category
2217
+ function test328_cartridgeRepoServerGetByCategory() {
2218
+ const server = new CartridgeRepoServer(sampleRegistry);
2229
2219
 
2230
- const docPlugins = server.getPluginsByCategory('document');
2231
- assert(docPlugins.length === 1, 'Should find 1 document plugin');
2232
- assert(docPlugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2220
+ const docCartridges = server.getCartridgesByCategory('document');
2221
+ assert(docCartridges.length === 1, 'Should find 1 document cartridge');
2222
+ assert(docCartridges[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2233
2223
 
2234
- const textPlugins = server.getPluginsByCategory('text');
2235
- assert(textPlugins.length === 1, 'Should find 1 text plugin');
2236
- assert(textPlugins[0].id === 'txtcartridge', 'Should be txtcartridge');
2224
+ const textCartridges = server.getCartridgesByCategory('text');
2225
+ assert(textCartridges.length === 1, 'Should find 1 text cartridge');
2226
+ assert(textCartridges[0].id === 'txtcartridge', 'Should be txtcartridge');
2237
2227
  }
2238
2228
 
2239
- // TEST329: PluginRepoServer get by cap
2240
- function test329_pluginRepoServerGetByCap() {
2241
- const server = new PluginRepoServer(sampleRegistry);
2229
+ // TEST329: CartridgeRepoServer get by cap
2230
+ function test329_cartridgeRepoServerGetByCap() {
2231
+ const server = new CartridgeRepoServer(sampleRegistry);
2242
2232
 
2243
2233
  const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2244
- const plugins = server.getPluginsByCap(disbindCap);
2234
+ const cartridges = server.getCartridgesByCap(disbindCap);
2245
2235
 
2246
- assert(plugins.length === 1, 'Should find 1 plugin with this cap');
2247
- assert(plugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2236
+ assert(cartridges.length === 1, 'Should find 1 cartridge with this cap');
2237
+ assert(cartridges[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2248
2238
 
2249
2239
  const metadataCap = 'cap:in="media:pdf";op=extract_metadata;out="media:file-metadata;textable;record"';
2250
- const metadataPlugins = server.getPluginsByCap(metadataCap);
2251
- assert(metadataPlugins.length === 1, 'Should find metadata cap');
2240
+ const metadataCartridges = server.getCartridgesByCap(metadataCap);
2241
+ assert(metadataCartridges.length === 1, 'Should find metadata cap');
2252
2242
  }
2253
2243
 
2254
- // TEST330: PluginRepoClient update cache
2255
- function test330_pluginRepoClientUpdateCache() {
2256
- const client = new PluginRepoClient(3600);
2257
- const server = new PluginRepoServer(sampleRegistry);
2258
- const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2244
+ // TEST330: CartridgeRepoClient update cache
2245
+ function test330_cartridgeRepoClientUpdateCache() {
2246
+ const client = new CartridgeRepoClient(3600);
2247
+ const server = new CartridgeRepoServer(sampleRegistry);
2248
+ const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
2259
2249
 
2260
- client.updateCache('https://example.com/api/plugins', plugins);
2250
+ client.updateCache('https://example.com/api/cartridges', cartridges);
2261
2251
 
2262
- const cache = client.caches.get('https://example.com/api/plugins');
2252
+ const cache = client.caches.get('https://example.com/api/cartridges');
2263
2253
  assert(cache !== undefined, 'Cache should exist');
2264
- assert(cache.plugins.size === 2, 'Should have 2 plugins in cache');
2265
- assert(cache.capToPlugins.size > 0, 'Should have cap mappings');
2254
+ assert(cache.cartridges.size === 2, 'Should have 2 cartridges in cache');
2255
+ assert(cache.capToCartridges.size > 0, 'Should have cap mappings');
2266
2256
  }
2267
2257
 
2268
- // TEST331: PluginRepoClient get suggestions
2269
- function test331_pluginRepoClientGetSuggestions() {
2270
- const client = new PluginRepoClient(3600);
2271
- const server = new PluginRepoServer(sampleRegistry);
2272
- const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2258
+ // TEST331: CartridgeRepoClient get suggestions
2259
+ function test331_cartridgeRepoClientGetSuggestions() {
2260
+ const client = new CartridgeRepoClient(3600);
2261
+ const server = new CartridgeRepoServer(sampleRegistry);
2262
+ const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
2273
2263
 
2274
- client.updateCache('https://example.com/api/plugins', plugins);
2264
+ client.updateCache('https://example.com/api/cartridges', cartridges);
2275
2265
 
2276
2266
  const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2277
2267
  const suggestions = client.getSuggestionsForCap(disbindCap);
2278
2268
 
2279
2269
  assert(suggestions.length === 1, 'Should find 1 suggestion');
2280
- assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest pdfcartridge');
2270
+ assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest pdfcartridge');
2281
2271
  assert(suggestions[0].capUrn === disbindCap, 'Should have correct cap URN');
2282
2272
  assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
2283
2273
  }
2284
2274
 
2285
- // TEST332: PluginRepoClient get plugin
2286
- function test332_pluginRepoClientGetPlugin() {
2287
- const client = new PluginRepoClient(3600);
2288
- const server = new PluginRepoServer(sampleRegistry);
2289
- const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2275
+ // TEST332: CartridgeRepoClient get cartridge
2276
+ function test332_cartridgeRepoClientGetCartridge() {
2277
+ const client = new CartridgeRepoClient(3600);
2278
+ const server = new CartridgeRepoServer(sampleRegistry);
2279
+ const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
2290
2280
 
2291
- client.updateCache('https://example.com/api/plugins', plugins);
2281
+ client.updateCache('https://example.com/api/cartridges', cartridges);
2292
2282
 
2293
- const plugin = client.getPlugin('pdfcartridge');
2294
- assert(plugin !== null, 'Should find plugin');
2295
- assert(plugin.id === 'pdfcartridge', 'Should have correct ID');
2283
+ const cartridge = client.getCartridge('pdfcartridge');
2284
+ assert(cartridge !== null, 'Should find cartridge');
2285
+ assert(cartridge.id === 'pdfcartridge', 'Should have correct ID');
2296
2286
 
2297
- const notFound = client.getPlugin('nonexistent');
2298
- assert(notFound === null, 'Should return null for missing plugin');
2287
+ const notFound = client.getCartridge('nonexistent');
2288
+ assert(notFound === null, 'Should return null for missing cartridge');
2299
2289
  }
2300
2290
 
2301
- // TEST333: PluginRepoClient get all caps
2302
- function test333_pluginRepoClientGetAllCaps() {
2303
- const client = new PluginRepoClient(3600);
2304
- const server = new PluginRepoServer(sampleRegistry);
2305
- const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2291
+ // TEST333: CartridgeRepoClient get all caps
2292
+ function test333_cartridgeRepoClientGetAllCaps() {
2293
+ const client = new CartridgeRepoClient(3600);
2294
+ const server = new CartridgeRepoServer(sampleRegistry);
2295
+ const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
2306
2296
 
2307
- client.updateCache('https://example.com/api/plugins', plugins);
2297
+ client.updateCache('https://example.com/api/cartridges', cartridges);
2308
2298
 
2309
2299
  const caps = client.getAllAvailableCaps();
2310
2300
  assert(Array.isArray(caps), 'Should return array');
@@ -2312,19 +2302,19 @@ function test333_pluginRepoClientGetAllCaps() {
2312
2302
  assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
2313
2303
  }
2314
2304
 
2315
- // TEST334: PluginRepoClient needs sync
2316
- function test334_pluginRepoClientNeedsSync() {
2317
- const client = new PluginRepoClient(1); // 1 second TTL
2318
- const server = new PluginRepoServer(sampleRegistry);
2319
- const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2305
+ // TEST334: CartridgeRepoClient needs sync
2306
+ function test334_cartridgeRepoClientNeedsSync() {
2307
+ const client = new CartridgeRepoClient(1); // 1 second TTL
2308
+ const server = new CartridgeRepoServer(sampleRegistry);
2309
+ const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
2320
2310
 
2321
- const urls = ['https://example.com/api/plugins'];
2311
+ const urls = ['https://example.com/api/cartridges'];
2322
2312
 
2323
2313
  // Should need sync initially
2324
2314
  assert(client.needsSync(urls) === true, 'Should need sync with empty cache');
2325
2315
 
2326
2316
  // Update cache
2327
- client.updateCache(urls[0], plugins);
2317
+ client.updateCache(urls[0], cartridges);
2328
2318
 
2329
2319
  // Should not need sync immediately
2330
2320
  assert(client.needsSync(urls) === false, 'Should not need sync right after update');
@@ -2333,33 +2323,33 @@ function test334_pluginRepoClientNeedsSync() {
2333
2323
  // Note: Can't test this synchronously, would need async test
2334
2324
  }
2335
2325
 
2336
- // TEST335: PluginRepoServer and Client integration
2337
- function test335_pluginRepoServerClientIntegration() {
2326
+ // TEST335: CartridgeRepoServer and Client integration
2327
+ function test335_cartridgeRepoServerClientIntegration() {
2338
2328
  // Server creates API response
2339
- const server = new PluginRepoServer(sampleRegistry);
2340
- const apiResponse = server.getPlugins();
2329
+ const server = new CartridgeRepoServer(sampleRegistry);
2330
+ const apiResponse = server.getCartridges();
2341
2331
 
2342
2332
  // Client consumes API response
2343
- const client = new PluginRepoClient(3600);
2344
- const plugins = apiResponse.plugins.map(p => new PluginInfo(p));
2345
- client.updateCache('https://example.com/api/plugins', plugins);
2333
+ const client = new CartridgeRepoClient(3600);
2334
+ const cartridges = apiResponse.cartridges.map(p => new CartridgeInfo(p));
2335
+ client.updateCache('https://example.com/api/cartridges', cartridges);
2346
2336
 
2347
- // Client can find plugin
2348
- const plugin = client.getPlugin('pdfcartridge');
2349
- assert(plugin !== null, 'Client should find plugin from server data');
2350
- assert(plugin.isSigned(), 'Plugin should be signed');
2351
- assert(plugin.hasBinary(), 'Plugin should have binary');
2337
+ // Client can find cartridge
2338
+ const cartridge = client.getCartridge('pdfcartridge');
2339
+ assert(cartridge !== null, 'Client should find cartridge from server data');
2340
+ assert(cartridge.isSigned(), 'Cartridge should be signed');
2341
+ assert(cartridge.hasPackage(), 'Cartridge should have package');
2352
2342
 
2353
2343
  // Client can get suggestions
2354
2344
  const capUrn = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
2355
2345
  const suggestions = client.getSuggestionsForCap(capUrn);
2356
2346
  assert(suggestions.length === 1, 'Should get suggestions');
2357
- assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest correct plugin');
2347
+ assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest correct cartridge');
2358
2348
 
2359
2349
  // Server can search
2360
- const searchResults = server.searchPlugins('pdf');
2350
+ const searchResults = server.searchCartridges('pdf');
2361
2351
  assert(searchResults.length === 1, 'Server search should work');
2362
- assert(searchResults[0].id === plugin.id, 'Search and client should agree');
2352
+ assert(searchResults[0].id === cartridge.id, 'Search and client should agree');
2363
2353
  }
2364
2354
 
2365
2355
  // ============================================================================
@@ -3757,11 +3747,13 @@ const {
3757
3747
  buildStrandGraphData: rendererBuildStrandGraphData,
3758
3748
  collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
3759
3749
  buildRunGraphData: rendererBuildRunGraphData,
3760
- buildMachineGraphData: rendererBuildMachineGraphData,
3750
+ buildEditorGraphData: rendererBuildEditorGraphData,
3751
+ buildResolvedMachineGraphData: rendererBuildResolvedMachineGraphData,
3761
3752
  classifyStrandCapSteps: rendererClassifyStrandCapSteps,
3762
3753
  validateStrandPayload: rendererValidateStrandPayload,
3763
3754
  validateRunPayload: rendererValidateRunPayload,
3764
- validateMachinePayload: rendererValidateMachinePayload,
3755
+ validateEditorGraphPayload: rendererValidateEditorGraphPayload,
3756
+ validateResolvedMachinePayload: rendererValidateResolvedMachinePayload,
3765
3757
  validateStrandStep: rendererValidateStrandStep,
3766
3758
  validateBodyOutcome: rendererValidateBodyOutcome,
3767
3759
  } = require('./cap-graph-renderer.js');
@@ -4205,20 +4197,20 @@ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
4205
4197
  assert(threw, 'nested ForEach without outer body cap must throw');
4206
4198
  }
4207
4199
 
4208
- function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker() {
4209
- // User spec: ForEach/Collect are NOT rendered as nodes. The
4210
- // transition is labeled with the enclosed cap's title + a
4211
- // cardinality marker. For a single-cap body the marker is n→n
4212
- // (iterate + collect combined) because the same cap is both
4213
- // the body entry and the body exit.
4200
+ function testRenderer_collapseStrand_singleCapBodyKeepsCapOwnLabel() {
4201
+ // User spec: ForEach/Collect are NOT rendered as nodes, and
4202
+ // the collapse does NOT relabel cap edges. Each cap edge
4203
+ // carries whatever label the strand builder emitted the
4204
+ // cap's own cardinality marker (from its input/output sequence
4205
+ // flags) is the only source of truth.
4214
4206
  //
4215
- // Strand [ForEach, Cap(extract), Collect], source=pdf;list,
4216
- // target=txt;list target is NOT equivalent to cap's to_spec
4217
- // media:txt so the output node is retained.
4207
+ // Strand [ForEach, Cap(extract, in=1, out=1), Collect],
4208
+ // source=pdf;list, target=txt;list. The extract cap itself is
4209
+ // 1→1, so its edge label has NO cardinality marker.
4218
4210
  //
4219
4211
  // Expected render shape: 3 nodes (input_slot, step_1, output),
4220
- // with the entry edge labeled "extract (n→n)" and a plain
4221
- // unlabeled connector to the output.
4212
+ // with the entry edge labeled "extract" and an unlabeled
4213
+ // connector bridge to the output.
4222
4214
  const payload = {
4223
4215
  source_spec: 'media:pdf;list',
4224
4216
  target_spec: 'media:txt;list',
@@ -4236,44 +4228,32 @@ function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMa
4236
4228
  JSON.stringify(['input_slot', 'output', 'step_1']),
4237
4229
  'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
4238
4230
 
4239
- // Exactly one edge from input_slot → step_1, carrying the cap
4240
- // title + iterate+collect cardinality marker.
4231
+ // Exactly one edge input_slot → step_1 carrying just the cap
4232
+ // title no (1→n) or (n→n) marker because the cap's own
4233
+ // cardinality is 1→1.
4241
4234
  const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
4242
4235
  assertEqual(entryEdges.length, 1,
4243
4236
  'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
4244
- assertEqual(entryEdges[0].label, 'extract (n\u2192n)',
4245
- 'single-cap-body edge is labeled "<cap_title> (nn)"');
4237
+ assertEqual(entryEdges[0].label, 'extract',
4238
+ 'entry edge carries just the cap title (cap is 11, no marker)');
4246
4239
 
4247
- // The exit side is a plain unlabeled connector — the cap title
4248
- // is already shown on the entry edge.
4240
+ // The collect bridge is an unlabeled connector.
4249
4241
  const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
4250
4242
  assertEqual(exitEdges.length, 1,
4251
4243
  'there is exactly one exit edge step_1 → output');
4252
4244
  assertEqual(exitEdges[0].label, '',
4253
- 'exit connector for a single-cap body is unlabeled (cap title already shown on entry edge)');
4245
+ 'collect bridge is unlabeled');
4254
4246
  }
4255
4247
 
4256
4248
  function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
4257
- // [Cap_a, ForEach, Cap_b] with no Collect, source=media:a,
4258
- // target=media:c. Cap_b's to_spec is media:c which is
4259
- // equivalent to target_spec, so the output node is merged
4260
- // into step_2.
4249
+ // [Cap_a(1→1), ForEach, Cap_b(1→1)] with no Collect,
4250
+ // source=media:a, target=media:c. Cap_b's to_spec is media:c
4251
+ // which is equivalent to target_spec, so the output node is
4252
+ // merged into step_2.
4261
4253
  //
4262
- // Raw topology:
4263
- // input_slot step_0 (cap_a) normal, not in any body
4264
- // step_0 step_2 (cap_b, foreachEntry=true — phantom under
4265
- // plan builder, but the foreach body entry
4266
- // under the render model)
4267
- // step_0 → step_1 (foreach direct)
4268
- // step_1 → step_2 (iteration)
4269
- // step_2 → output (trailing connector, empty label)
4270
- //
4271
- // Collapse:
4272
- // - step_1 (foreach) removed with its iteration edges.
4273
- // - step_0 → step_2 relabeled "b (1→n)" via foreachEntry.
4274
- // - step_2 → output merged because upstream.fullUrn equivalent
4275
- // to target_spec (both media:c); step_2 takes the target
4276
- // display label.
4254
+ // Since both caps are 1→1, neither carries a cardinality
4255
+ // marker in the render. The foreach step is just dropped;
4256
+ // no relabeling.
4277
4257
  //
4278
4258
  // Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
4279
4259
  const payload = {
@@ -4293,18 +4273,19 @@ function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
4293
4273
  JSON.stringify(['input_slot', 'step_0', 'step_2']),
4294
4274
  'foreach node removed and output merged into step_2 (same URN as target)');
4295
4275
 
4296
- // Exactly one edge from step_0 to step_2, labeled with cap_b's
4297
- // title + (1→n) marker.
4276
+ // Exactly one edge from step_0 to step_2, labeled with just
4277
+ // cap_b's title no foreach marker because cap_b is 1→1 and
4278
+ // the collapse doesn't relabel cap edges.
4298
4279
  const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4299
4280
  assertEqual(step0ToStep2.length, 1,
4300
4281
  'exactly one step_0 → step_2 edge after dropping the foreach iteration');
4301
- assertEqual(step0ToStep2[0].label, 'b (1\u2192n)',
4302
- 'the foreach-entry edge is labeled "<cap_b_title> (1→n)"');
4282
+ assertEqual(step0ToStep2[0].label, 'b',
4283
+ 'cap_b edge carries just its title (1→1 cap, no marker)');
4303
4284
 
4304
- // Cap_a's edge is unchanged (not inside a foreach body).
4285
+ // Cap_a's edge is unchanged.
4305
4286
  const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4306
4287
  assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
4307
- assertEqual(capA.label, 'a', 'cap_a edge carries just its title (no cardinality marker since 1→1)');
4288
+ assertEqual(capA.label, 'a', 'cap_a edge carries just its title');
4308
4289
 
4309
4290
  // After merging, step_2 becomes the render target — no separate
4310
4291
  // output node exists.
@@ -4351,8 +4332,8 @@ function testRenderer_collapseStrand_standaloneCollectCollapses() {
4351
4332
 
4352
4333
  const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
4353
4334
  assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
4354
- assertEqual(collectEdge.label, 'collect',
4355
- 'the synthesized bridging edge for a standalone Collect is labeled "collect"');
4335
+ assertEqual(collectEdge.label, '',
4336
+ 'the synthesized bridging edge for a standalone Collect is an unlabeled connector (cap labels carry all cardinality info)');
4356
4337
  }
4357
4338
 
4358
4339
  function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
@@ -4362,18 +4343,16 @@ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
4362
4343
  // the last cap's to_spec).
4363
4344
  //
4364
4345
  // Expected render shape after collapse:
4365
- // input_slot → step_0 labeled "Disbind (1→n)" — from Disbind's
4366
- // own output_is_sequence flag, computed at build time.
4367
- // step_0 step_2 labeled "Make a Decision (1→n)" — because
4368
- // make_decision is the first cap inside an unclosed
4369
- // ForEach body (foreachEntry=true).
4346
+ // input_slot → step_0 labeled "Disbind PDF Into Pages (1→n)"
4347
+ // — from Disbind's own output_is_sequence flag, computed
4348
+ // at build time by the strand builder.
4349
+ // step_0 step_2 labeled "Make a Decision" — no marker
4350
+ // because make_decision is 1→1. The collapse does NOT
4351
+ // add a (1→n) marker on this edge — the (1→n) belongs
4352
+ // to the cap that actually produces the sequence
4353
+ // (Disbind), NOT the cap that consumes one item from it.
4370
4354
  // No separate output node because step_2's to_spec equals the
4371
4355
  // strand target.
4372
- //
4373
- // If this test fails, the runtime bug would manifest as either
4374
- // (a) a duplicate target node, (b) a "for each" labeled edge
4375
- // where the cap title should be, or (c) the phantom direct cap
4376
- // edge not being relabeled.
4377
4356
  const payload = {
4378
4357
  source_spec: 'media:pdf',
4379
4358
  target_spec: 'media:decision',
@@ -4398,14 +4377,18 @@ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
4398
4377
  assertEqual(disbind.label, 'Disbind (1\u2192n)',
4399
4378
  'Disbind edge reflects its own output_is_sequence=true cardinality');
4400
4379
 
4401
- // make_decision cap edge is the foreach entry — the plan-builder
4402
- // phantom direct edge becomes the render-visible cap edge with
4403
- // (1→n) appended to the cap title.
4380
+ // make_decision cap edge — the plan-builder phantom direct
4381
+ // edge becomes the render-visible cap edge, carrying just the
4382
+ // cap title. No (1→n) marker: make_decision is 1→1, and the
4383
+ // collapse does NOT add cardinality markers based on foreach
4384
+ // context. The fan-out semantics come from Disbind's own
4385
+ // output_is_sequence flag, which is already visible on the
4386
+ // Disbind edge.
4404
4387
  const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4405
4388
  assertEqual(makeDecision.length, 1,
4406
4389
  'exactly one edge from Text Page to Decision (phantom not duplicated)');
4407
- assertEqual(makeDecision[0].label, 'Make a Decision (1\u2192n)',
4408
- 'the foreach entry edge is labeled "<cap_title> (1→n)", not "for each"');
4390
+ assertEqual(makeDecision[0].label, 'Make a Decision',
4391
+ 'the make_decision edge carries just its title — 1→1 cap, no marker');
4409
4392
 
4410
4393
  // Duplicate target must be gone.
4411
4394
  const outputNode = collapsed.nodes.find(n => n.id === 'output');
@@ -4492,12 +4475,16 @@ function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
4492
4475
  }
4493
4476
 
4494
4477
  function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
4495
- // 6 successes, 4 failures. visible_success_count=3, visible_failure_count=2,
4496
- // total_body_count=10. The builder must:
4497
- // - render exactly 3 success replicas (one node per cap step per body)
4498
- // - render exactly 2 failure replicas
4499
- // - emit a success show-more node with hidden count 3
4500
- // - emit a failure show-more node with hidden count 2
4478
+ // 6 successes, 4 failures. visible=3+2, total=10. Body has 2
4479
+ // caps (a, b). Each body replica is a chain of:
4480
+ // entry node + body_step_0 (cap a) + body_step_1 (cap b)
4481
+ // = 3 nodes per body. Failed bodies truncate at failed_cap
4482
+ // (cap b, idx=1) so `traceEnd=2` same 3-node chain.
4483
+ //
4484
+ // Total replica nodes: (3 success × 3) + (2 failure × 3) = 15.
4485
+ //
4486
+ // Show-more nodes: one for 3 hidden successes, one for 2 hidden
4487
+ // failures.
4501
4488
  const strand = {
4502
4489
  source_spec: 'media:pdf;list',
4503
4490
  target_spec: 'media:txt',
@@ -4533,17 +4520,14 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
4533
4520
  };
4534
4521
  const built = rendererBuildRunGraphData(payload);
4535
4522
 
4536
- // Count replica nodes by classes.
4537
4523
  let successNodes = 0;
4538
4524
  let failureNodes = 0;
4539
4525
  for (const n of built.replicaNodes) {
4540
4526
  if (n.classes === 'body-success') successNodes++;
4541
4527
  if (n.classes === 'body-failure') failureNodes++;
4542
4528
  }
4543
- assertEqual(successNodes, 3 * 2, 'three successful bodies × two cap steps each = 6 success nodes');
4544
- // Failed bodies truncate at failed_cap (cap b = second cap), so trace
4545
- // length includes both cap a and cap b → 2 nodes per failed body.
4546
- assertEqual(failureNodes, 2 * 2, 'two failed bodies × two nodes each (trace truncated at failed_cap)');
4529
+ assertEqual(successNodes, 3 * 3, '3 success bodies × (1 entry + 2 body caps) = 9 success replica nodes');
4530
+ assertEqual(failureNodes, 2 * 3, '2 failure bodies × (1 entry + 2 body caps) = 6 failure replica nodes');
4547
4531
 
4548
4532
  // Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
4549
4533
  const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
@@ -4555,9 +4539,12 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
4555
4539
  }
4556
4540
 
4557
4541
  function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
4558
- // A failure without failed_cap (e.g. infrastructure failure) must still
4559
- // render the full body trace — the builder must not crash or produce
4560
- // zero replicas.
4542
+ // A failure without failed_cap (infrastructure failure) must
4543
+ // still render the full body trace — the builder must not
4544
+ // crash or produce zero replicas.
4545
+ //
4546
+ // Strand [ForEach, Cap, Collect] → body has 1 cap. Each body
4547
+ // replica emits 1 entry node + 1 body cap node = 2 nodes.
4561
4548
  const strand = {
4562
4549
  source_spec: 'media:pdf;list',
4563
4550
  target_spec: 'media:txt',
@@ -4581,7 +4568,7 @@ function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace(
4581
4568
  for (const n of built.replicaNodes) {
4582
4569
  if (n.classes === 'body-failure') failureNodes++;
4583
4570
  }
4584
- assertEqual(failureNodes, 1, 'full trace (one cap) renders as one failure node');
4571
+ assertEqual(failureNodes, 2, 'entry + body cap = 2 failure replica nodes');
4585
4572
  }
4586
4573
 
4587
4574
  function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
@@ -4632,8 +4619,9 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
4632
4619
  for (const n of built.replicaNodes) {
4633
4620
  if (n.classes === 'body-failure') failureNodes++;
4634
4621
  }
4635
- // cap x + cap y = 2 nodes for a body trace that terminates at cap y.
4636
- assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
4622
+ // 1 entry + 2 body step nodes (cap x and cap y, truncated
4623
+ // at cap y) = 3 failure replica nodes.
4624
+ assertEqual(failureNodes, 3, 'trace truncates at cap y via isEquivalent, yielding entry + 2 cap nodes');
4637
4625
  }
4638
4626
 
4639
4627
  function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
@@ -4727,26 +4715,35 @@ function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
4727
4715
  assertEqual(foreachEntry, undefined,
4728
4716
  'backbone foreach-entry edge must be dropped when replicas exist');
4729
4717
 
4730
- // Failures are rendered as red replica trails that don't merge.
4718
+ // Each failed body renders as an entry node + N body-step
4719
+ // nodes. Body has 1 cap (make_decision), so 2 nodes per body.
4720
+ // 2 failed bodies × 2 nodes = 4 failure replica nodes.
4731
4721
  const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
4732
- assertEqual(failureNodes.length, 2,
4733
- 'two failed bodies render two replica nodes (truncated at failed_cap)');
4722
+ assertEqual(failureNodes.length, 4,
4723
+ 'two failed bodies × (entry + 1 body cap) = 4 failure replica nodes');
4734
4724
 
4735
- // The pre-foreach cap node (step_0, "Disbind") is still present
4736
- // along with its incoming edge only the post-body parts were
4737
- // dropped.
4725
+ // Disbind is the sequence producer, so its backbone node
4726
+ // (step_0) is ALSO dropped — the per-body entry nodes own
4727
+ // the per-item rendering. The only surviving backbone node
4728
+ // is the input_slot (avid-optic source PDF).
4738
4729
  const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
4739
- assertEqual(hasStep0, true, 'pre-foreach cap node survives');
4740
- const disbindEdge = built.strandBuilt.edges.find(e =>
4741
- e.source === 'input_slot' && e.target === 'step_0');
4742
- assert(disbindEdge !== undefined, 'pre-foreach cap edge (Disbind) survives');
4743
- }
4744
-
4745
- function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
4746
- // When at least one successful replica merges into the target,
4747
- // the backbone foreach-entry edge is dropped — the replica's
4748
- // own chain represents the execution and the backbone would
4749
- // otherwise duplicate the path.
4730
+ assertEqual(hasStep0, false,
4731
+ 'sequence producer backbone node (Disbind output) is dropped; replicas own the per-body rendering');
4732
+ const hasInputSlot = built.strandBuilt.nodes.some(n => n.id === 'input_slot');
4733
+ assertEqual(hasInputSlot, true, 'input_slot survives as the shared source');
4734
+ }
4735
+
4736
+ function testRenderer_buildRunGraphData_unclosedForeachSuccessNoMerge() {
4737
+ // Strand without a Collect: [Disbind, ForEach, make_decision].
4738
+ // Under the machfab realize_strand semantics there's no Collect,
4739
+ // so each body produces its OWN terminal output. Successful
4740
+ // replicas do NOT merge into a shared target — each body has
4741
+ // its own separate decision.
4742
+ //
4743
+ // Expected replica shape per body:
4744
+ // anchorNode (pre-foreach backbone) → entry (per-body Text Page)
4745
+ // → body_n_0 (per-body Decision)
4746
+ // (no merge edge back into the backbone)
4750
4747
  const strand = {
4751
4748
  source_spec: 'media:pdf',
4752
4749
  target_spec: 'media:decision',
@@ -4767,31 +4764,84 @@ function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
4767
4764
  };
4768
4765
  const built = rendererBuildRunGraphData(payload);
4769
4766
 
4770
- // The backbone foreach-entry edge is gone.
4771
- const foreachEntry = built.strandBuilt.edges.find(e =>
4772
- e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
4773
- assertEqual(foreachEntry, undefined,
4774
- 'foreach-entry backbone edge dropped when at least one success exists');
4775
-
4776
- // step_2 (target) stays because the replica merge edge lands on it.
4767
+ // step_2 (the merged strand target) was dropped because it's
4768
+ // a body cap step and there's no Collect to merge into.
4777
4769
  const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
4778
- assertEqual(hasStep2, true,
4779
- 'strand target node survives when successful replicas merge into it');
4770
+ assertEqual(hasStep2, false,
4771
+ 'body cap node dropped; without Collect there is no shared merge target');
4772
+
4773
+ // Each body produces its own entry + body cap chain: 2 nodes.
4774
+ const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
4775
+ assertEqual(successNodes.length, 2,
4776
+ 'one successful body × (entry + 1 body cap) = 2 replica nodes');
4777
+
4778
+ // No replica edge targets the (now non-existent) step_2.
4779
+ const mergeEdges = built.replicaEdges.filter(e =>
4780
+ e.data && e.data.target === 'step_2');
4781
+ assertEqual(mergeEdges.length, 0,
4782
+ 'no merge edges to step_2 because there is no Collect');
4783
+
4784
+ // The fork edge from anchor (input_slot, because Disbind IS
4785
+ // the sequence producer whose backbone node is dropped) to
4786
+ // the per-body entry IS present.
4787
+ const forkEdges = built.replicaEdges.filter(e =>
4788
+ e.data && e.data.source === 'input_slot' && e.classes === 'body-success');
4789
+ assertEqual(forkEdges.length, 1, 'fork edge input_slot → body-0-entry exists');
4790
+ }
4791
+
4792
+ function testRenderer_buildRunGraphData_closedForeachSuccessMergesAtCollectTarget() {
4793
+ // With a Collect closing the body, successful replicas DO merge
4794
+ // into the post-collect target so the flow converges.
4795
+ // Strand: [Disbind, ForEach, Cap_a, Cap_b, Collect] with a
4796
+ // downstream cap after Collect to make the post-collect target
4797
+ // a real separate node.
4798
+ //
4799
+ // Actually simpler: [ForEach, Cap_a, Collect] with source=list
4800
+ // and target=list.
4801
+ const strand = {
4802
+ source_spec: 'media:pdf;list',
4803
+ target_spec: 'media:txt;list',
4804
+ steps: [
4805
+ makeForEachStep('media:pdf;list'),
4806
+ makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
4807
+ makeCollectStep('media:txt'),
4808
+ ],
4809
+ };
4810
+ const payload = {
4811
+ resolved_strand: strand,
4812
+ body_outcomes: [
4813
+ { body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
4814
+ ],
4815
+ visible_success_count: 3,
4816
+ visible_failure_count: 3,
4817
+ total_body_count: 1,
4818
+ };
4819
+ const built = rendererBuildRunGraphData(payload);
4820
+
4821
+ // The post-collect target (output) is still present — it's the
4822
+ // strand target. Successful replicas merge into it.
4823
+ const hasOutput = built.strandBuilt.nodes.some(n => n.id === 'output');
4824
+ assertEqual(hasOutput, true,
4825
+ 'post-collect target (output) stays because successful replicas merge into it');
4780
4826
 
4781
- // Exactly one successful replica node and one merge edge.
4827
+ // One body × (entry + 1 body cap) = 2 replica nodes.
4782
4828
  const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
4783
- assertEqual(successNodes.length, 1, 'one replica node for one successful body');
4829
+ assertEqual(successNodes.length, 2,
4830
+ 'one successful body × (entry + 1 body cap) = 2 replica nodes');
4831
+
4832
+ // One merge edge from the last body cap to the output node.
4784
4833
  const mergeEdges = built.replicaEdges.filter(e =>
4785
- e.data && e.data.target === 'step_2' && e.classes === 'body-success');
4786
- assertEqual(mergeEdges.length, 1, 'one merge edge from replica to target');
4834
+ e.data && e.data.target === 'output' && e.classes === 'body-success');
4835
+ assertEqual(mergeEdges.length, 1,
4836
+ 'one merge edge from body cap replica to collect target');
4787
4837
  }
4788
4838
 
4789
- // ---------------- machine builder ----------------
4839
+ // ---------------- editor-graph builder ----------------
4790
4840
 
4791
- function testRenderer_validateMachinePayload_rejectsUnknownKind() {
4841
+ function testRenderer_validateEditorGraphPayload_rejectsUnknownKind() {
4792
4842
  let threw = false;
4793
4843
  try {
4794
- rendererValidateMachinePayload({
4844
+ rendererValidateEditorGraphPayload({
4795
4845
  elements: [{ kind: 'widget', graph_id: 'w1' }],
4796
4846
  });
4797
4847
  } catch (e) {
@@ -4802,32 +4852,102 @@ function testRenderer_validateMachinePayload_rejectsUnknownKind() {
4802
4852
  assert(threw, 'unknown element kind must throw');
4803
4853
  }
4804
4854
 
4805
- function testRenderer_buildMachineGraphData_preservesTokenIds() {
4806
- // Token IDs are the bridge for editor cross-highlighting. Every
4807
- // element MUST carry its tokenId through into the cytoscape data.
4855
+ function testRenderer_buildEditorGraphData_collapsesCapsIntoLabeledEdges() {
4856
+ // The notation analyzer emits a bipartite chain per cap
4857
+ // application: data_node arg_edge cap_node arg_edge
4858
+ // data_node. The machine builder collapses each cap into a
4859
+ // single labeled edge between the input and output data slots.
4860
+ // Cap nodes do NOT appear as cytoscape nodes. Cap tokenIds are
4861
+ // carried on the synthesized edge so editor cross-highlight
4862
+ // still resolves from the rendered edge to the cap's source
4863
+ // text.
4864
+ const data = {
4865
+ elements: [
4866
+ { kind: 'node', graph_id: 'n_src', label: 'n0', token_id: 't-src' },
4867
+ { kind: 'node', graph_id: 'n_dst', label: 'n1', token_id: 't-dst' },
4868
+ { kind: 'cap', graph_id: 'c1', label: 'my_cap', token_id: 't-cap', linked_cap_urn: 'cap:...' },
4869
+ { kind: 'edge', graph_id: 'e_in', source_graph_id: 'n_src', target_graph_id: 'c1', label: 'in', token_id: 't-ein' },
4870
+ { kind: 'edge', graph_id: 'e_out', source_graph_id: 'c1', target_graph_id: 'n_dst', label: 'out', token_id: 't-eout' },
4871
+ ],
4872
+ };
4873
+ const built = rendererBuildEditorGraphData(data);
4874
+
4875
+ // Only data-slot nodes survive. Cap is NOT a node.
4876
+ assertEqual(built.nodes.length, 2, 'only data-slot nodes are rendered');
4877
+ const nodeIds = built.nodes.map(n => n.data.id).sort();
4878
+ assertEqual(JSON.stringify(nodeIds), JSON.stringify(['n_dst', 'n_src']),
4879
+ 'data-slot nodes preserved verbatim');
4880
+ assertEqual(built.nodes.every(n => n.data.kind === 'node'), true,
4881
+ 'every surviving node has kind=node (no cap nodes)');
4882
+
4883
+ // The cap is collapsed to a single labeled edge.
4884
+ assertEqual(built.edges.length, 1, 'one collapsed edge per cap application');
4885
+ const edge = built.edges[0];
4886
+ assertEqual(edge.data.source, 'n_src', 'edge source is the cap input data slot');
4887
+ assertEqual(edge.data.target, 'n_dst', 'edge target is the cap output data slot');
4888
+ assertEqual(edge.data.label, 'my_cap', 'edge label is the cap title');
4889
+ assertEqual(edge.data.tokenId, 't-cap',
4890
+ 'edge carries the cap node tokenId so editor cross-highlight points to the cap in source text');
4891
+ }
4892
+
4893
+ function testRenderer_buildEditorGraphData_loopMarkedEdgeGetsLoopClass() {
4894
+ // A cap marked `is_loop: true` must produce a `machine-loop`
4895
+ // edge so the stylesheet's dashed amber rule applies.
4896
+ const data = {
4897
+ elements: [
4898
+ { kind: 'node', graph_id: 'a', label: 'a', token_id: 't-a' },
4899
+ { kind: 'node', graph_id: 'b', label: 'b', token_id: 't-b' },
4900
+ { kind: 'cap', graph_id: 'c', label: 'looped', token_id: 't-c', is_loop: true },
4901
+ { kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
4902
+ { kind: 'edge', graph_id: 'e2', source_graph_id: 'c', target_graph_id: 'b', token_id: 't-e2' },
4903
+ ],
4904
+ };
4905
+ const built = rendererBuildEditorGraphData(data);
4906
+ assertEqual(built.edges.length, 1, 'one collapsed edge');
4907
+ assert(built.edges[0].classes.indexOf('machine-loop') >= 0,
4908
+ 'loop-marked cap emits machine-loop class on the collapsed edge');
4909
+ }
4910
+
4911
+ function testRenderer_buildEditorGraphData_cardinalityFromDataSlotSequenceFlags() {
4912
+ // Cardinality markers come from the source and target data
4913
+ // slots' `is_sequence` flags. A cap whose output data slot has
4914
+ // `is_sequence=true` shows "(1→n)" on its collapsed edge.
4808
4915
  const data = {
4809
4916
  elements: [
4810
- { kind: 'node', graph_id: 'n1', label: 'a', token_id: 't-node-a' },
4811
- { kind: 'cap', graph_id: 'c1', label: 'fn', token_id: 't-cap-fn' },
4812
- { kind: 'edge', graph_id: 'e1', source_graph_id: 'n1', target_graph_id: 'c1', label: '', token_id: 't-edge-1' },
4917
+ { kind: 'node', graph_id: 'a', label: 'pdf', token_id: 't-a', is_sequence: false },
4918
+ { kind: 'node', graph_id: 'b', label: 'pages', token_id: 't-b', is_sequence: true },
4919
+ { kind: 'cap', graph_id: 'c', label: 'disbind', token_id: 't-c' },
4920
+ { kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
4921
+ { kind: 'edge', graph_id: 'e2', source_graph_id: 'c', target_graph_id: 'b', token_id: 't-e2' },
4813
4922
  ],
4814
4923
  };
4815
- const built = rendererBuildMachineGraphData(data);
4816
- const nodeTokens = built.nodes.map(n => n.data.tokenId).sort();
4817
- assertEqual(JSON.stringify(nodeTokens), JSON.stringify(['t-cap-fn', 't-node-a']),
4818
- 'node tokenIds must round-trip');
4819
- assertEqual(built.edges.length, 1, 'one edge');
4820
- assertEqual(built.edges[0].data.tokenId, 't-edge-1', 'edge tokenId must round-trip');
4821
- // Kinds are carried as element data for editor-side filtering.
4822
- const kinds = built.nodes.map(n => n.data.kind).sort();
4823
- assertEqual(JSON.stringify(kinds), JSON.stringify(['cap', 'node']),
4824
- 'element kinds must be preserved');
4825
- }
4826
-
4827
- function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
4924
+ const built = rendererBuildEditorGraphData(data);
4925
+ assertEqual(built.edges.length, 1, 'one collapsed edge');
4926
+ assertEqual(built.edges[0].data.label, 'disbind (1\u2192n)',
4927
+ 'cardinality marker "(1→n)" derived from output data slot is_sequence=true');
4928
+ }
4929
+
4930
+ function testRenderer_buildEditorGraphData_capWithoutCompleteArgsIsDropped() {
4931
+ // A cap with no incoming or no outgoing argument edges (e.g.
4932
+ // the user is mid-typing) contributes nothing to the render.
4933
+ // The data slots are still emitted.
4934
+ const data = {
4935
+ elements: [
4936
+ { kind: 'node', graph_id: 'a', label: 'a', token_id: 't-a' },
4937
+ { kind: 'cap', graph_id: 'c', label: 'halfway', token_id: 't-c' },
4938
+ { kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
4939
+ ],
4940
+ };
4941
+ const built = rendererBuildEditorGraphData(data);
4942
+ assertEqual(built.nodes.length, 1, 'data slot emitted');
4943
+ assertEqual(built.edges.length, 0,
4944
+ 'incomplete cap (no outgoing argument) drops out of the render');
4945
+ }
4946
+
4947
+ function testRenderer_buildEditorGraphData_rejectsEdgeWithMissingSource() {
4828
4948
  let threw = false;
4829
4949
  try {
4830
- rendererBuildMachineGraphData({
4950
+ rendererBuildEditorGraphData({
4831
4951
  elements: [
4832
4952
  { kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
4833
4953
  ],
@@ -4838,6 +4958,261 @@ function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
4838
4958
  assert(threw, 'edge without source_graph_id must throw');
4839
4959
  }
4840
4960
 
4961
+ // ---------------- resolved-machine builder ----------------
4962
+
4963
+ function testRenderer_buildResolvedMachineGraphData_singleStrandLinearChain() {
4964
+ // A single-strand machine: media:pdf → extract → media:txt
4965
+ // → embed → media:embedding. Two edges, three nodes, no
4966
+ // loops, no fan-in. Tests the basic shape — nodes and
4967
+ // edges flow through verbatim from the resolved machine
4968
+ // payload.
4969
+ const payload = {
4970
+ strands: [
4971
+ {
4972
+ nodes: [
4973
+ { id: 'n0', urn: 'media:pdf' },
4974
+ { id: 'n1', urn: 'media:txt;textable' },
4975
+ { id: 'n2', urn: 'media:embedding;record' },
4976
+ ],
4977
+ edges: [
4978
+ {
4979
+ alias: 'edge_0',
4980
+ cap_urn: 'cap:in=media:pdf;op=extract;out=media:txt;textable',
4981
+ is_loop: false,
4982
+ assignment: [
4983
+ { cap_arg_media_urn: 'media:pdf', source_node: 'n0' },
4984
+ ],
4985
+ target_node: 'n1',
4986
+ },
4987
+ {
4988
+ alias: 'edge_1',
4989
+ cap_urn: 'cap:in=media:textable;op=embed;out=media:embedding;record',
4990
+ is_loop: false,
4991
+ assignment: [
4992
+ { cap_arg_media_urn: 'media:textable', source_node: 'n1' },
4993
+ ],
4994
+ target_node: 'n2',
4995
+ },
4996
+ ],
4997
+ input_anchor_nodes: ['n0'],
4998
+ output_anchor_nodes: ['n2'],
4999
+ },
5000
+ ],
5001
+ };
5002
+ const built = rendererBuildResolvedMachineGraphData(payload);
5003
+ assertEqual(built.nodes.length, 3, 'three data-slot nodes');
5004
+ assertEqual(built.edges.length, 2, 'two cap edges (one assignment each)');
5005
+ // First edge connects n0 → n1, second connects n1 → n2.
5006
+ const edges = built.edges.map(e => `${e.data.source}->${e.data.target}`);
5007
+ assertEqual(edges[0], 'n0->n1', 'first edge wires n0 to n1');
5008
+ assertEqual(edges[1], 'n1->n2', 'second edge wires n1 to n2');
5009
+ // Anchor nodes carry the strand-source / strand-target classes.
5010
+ const n0 = built.nodes.find(n => n.data.id === 'n0');
5011
+ const n2 = built.nodes.find(n => n.data.id === 'n2');
5012
+ assert(n0.classes.indexOf('strand-source') >= 0,
5013
+ 'input anchor node carries strand-source class');
5014
+ assert(n2.classes.indexOf('strand-target') >= 0,
5015
+ 'output anchor node carries strand-target class');
5016
+ }
5017
+
5018
+ function testRenderer_buildResolvedMachineGraphData_loopEdgeGetsLoopClass() {
5019
+ // An is_loop edge corresponds to a strand step inside a
5020
+ // ForEach body. The renderer must mark it with the
5021
+ // `machine-loop` class so the dashed amber rule applies.
5022
+ const payload = {
5023
+ strands: [
5024
+ {
5025
+ nodes: [
5026
+ { id: 'n0', urn: 'media:page;textable' },
5027
+ { id: 'n1', urn: 'media:decision;json;record;textable' },
5028
+ ],
5029
+ edges: [
5030
+ {
5031
+ alias: 'edge_0',
5032
+ cap_urn: 'cap:in=media:textable;op=make_decision;out=media:decision;json;record;textable',
5033
+ is_loop: true,
5034
+ assignment: [
5035
+ { cap_arg_media_urn: 'media:textable', source_node: 'n0' },
5036
+ ],
5037
+ target_node: 'n1',
5038
+ },
5039
+ ],
5040
+ input_anchor_nodes: ['n0'],
5041
+ output_anchor_nodes: ['n1'],
5042
+ },
5043
+ ],
5044
+ };
5045
+ const built = rendererBuildResolvedMachineGraphData(payload);
5046
+ assertEqual(built.edges.length, 1, 'one cap edge');
5047
+ assert(built.edges[0].classes.indexOf('machine-loop') >= 0,
5048
+ 'is_loop=true must produce a machine-loop class on the cap edge');
5049
+ }
5050
+
5051
+ function testRenderer_buildResolvedMachineGraphData_fanInProducesEdgePerAssignment() {
5052
+ // A cap with two input args (a fan-in) gets one rendered
5053
+ // edge per (source_node, target_node) pair so cytoscape can
5054
+ // draw both incoming wires. Both edges share the cap title
5055
+ // and color so they read as a single fan-in.
5056
+ const payload = {
5057
+ strands: [
5058
+ {
5059
+ nodes: [
5060
+ { id: 'n0', urn: 'media:image;png' },
5061
+ { id: 'n1', urn: 'media:model-spec;textable' },
5062
+ { id: 'n2', urn: 'media:image-description;textable' },
5063
+ ],
5064
+ edges: [
5065
+ {
5066
+ alias: 'edge_0',
5067
+ cap_urn: 'cap:in=media:image;png;op=describe_image;out=media:image-description;textable',
5068
+ is_loop: false,
5069
+ assignment: [
5070
+ { cap_arg_media_urn: 'media:image;png', source_node: 'n0' },
5071
+ { cap_arg_media_urn: 'media:model-spec;textable', source_node: 'n1' },
5072
+ ],
5073
+ target_node: 'n2',
5074
+ },
5075
+ ],
5076
+ input_anchor_nodes: ['n0', 'n1'],
5077
+ output_anchor_nodes: ['n2'],
5078
+ },
5079
+ ],
5080
+ };
5081
+ const built = rendererBuildResolvedMachineGraphData(payload);
5082
+ assertEqual(built.edges.length, 2, 'two rendered edges, one per assignment binding');
5083
+ const sources = built.edges.map(e => e.data.source).sort();
5084
+ assertEqual(JSON.stringify(sources), JSON.stringify(['n0', 'n1']),
5085
+ 'each binding gets its own source-node edge into the same target');
5086
+ assertEqual(built.edges[0].data.target, 'n2', 'first edge targets n2');
5087
+ assertEqual(built.edges[1].data.target, 'n2', 'second edge targets n2');
5088
+ }
5089
+
5090
+ function testRenderer_buildResolvedMachineGraphData_multiStrandKeepsStrandsDisjoint() {
5091
+ // Two strands inside one machine. Each strand has its own
5092
+ // nodes and edges. Node ids are globally unique across
5093
+ // strands (Rust assigns them via a single counter), so no
5094
+ // node id collision can happen. The renderer must emit
5095
+ // every node and every edge from both strands.
5096
+ const payload = {
5097
+ strands: [
5098
+ {
5099
+ nodes: [
5100
+ { id: 'n0', urn: 'media:pdf' },
5101
+ { id: 'n1', urn: 'media:txt;textable' },
5102
+ ],
5103
+ edges: [
5104
+ {
5105
+ alias: 'edge_0',
5106
+ cap_urn: 'cap:in=media:pdf;op=extract;out=media:txt;textable',
5107
+ is_loop: false,
5108
+ assignment: [
5109
+ { cap_arg_media_urn: 'media:pdf', source_node: 'n0' },
5110
+ ],
5111
+ target_node: 'n1',
5112
+ },
5113
+ ],
5114
+ input_anchor_nodes: ['n0'],
5115
+ output_anchor_nodes: ['n1'],
5116
+ },
5117
+ {
5118
+ nodes: [
5119
+ { id: 'n2', urn: 'media:json;record;textable' },
5120
+ { id: 'n3', urn: 'media:csv;list;record;textable' },
5121
+ ],
5122
+ edges: [
5123
+ {
5124
+ alias: 'edge_1',
5125
+ cap_urn: 'cap:in=media:json;record;textable;op=convert_format;out=media:csv;list;record;textable',
5126
+ is_loop: false,
5127
+ assignment: [
5128
+ { cap_arg_media_urn: 'media:json;record;textable', source_node: 'n2' },
5129
+ ],
5130
+ target_node: 'n3',
5131
+ },
5132
+ ],
5133
+ input_anchor_nodes: ['n2'],
5134
+ output_anchor_nodes: ['n3'],
5135
+ },
5136
+ ],
5137
+ };
5138
+ const built = rendererBuildResolvedMachineGraphData(payload);
5139
+ assertEqual(built.nodes.length, 4, 'all four nodes from both strands present');
5140
+ assertEqual(built.edges.length, 2, 'one edge per strand');
5141
+ // Each node carries a strandIndex matching which strand it came from.
5142
+ const idToStrand = {};
5143
+ for (const n of built.nodes) idToStrand[n.data.id] = n.data.strandIndex;
5144
+ assertEqual(idToStrand['n0'], 0, 'n0 belongs to strand 0');
5145
+ assertEqual(idToStrand['n1'], 0, 'n1 belongs to strand 0');
5146
+ assertEqual(idToStrand['n2'], 1, 'n2 belongs to strand 1');
5147
+ assertEqual(idToStrand['n3'], 1, 'n3 belongs to strand 1');
5148
+ }
5149
+
5150
+ function testRenderer_buildResolvedMachineGraphData_duplicateNodeIdAcrossStrandsFailsHard() {
5151
+ // Node ids must be globally unique across strands. The
5152
+ // Rust serializer guarantees this via a single global
5153
+ // counter. If the host ever feeds a payload that violates
5154
+ // it, the renderer must fail hard so the bug surfaces
5155
+ // instead of silently overwriting one node with another.
5156
+ const payload = {
5157
+ strands: [
5158
+ {
5159
+ nodes: [{ id: 'n0', urn: 'media:pdf' }],
5160
+ edges: [],
5161
+ input_anchor_nodes: ['n0'],
5162
+ output_anchor_nodes: ['n0'],
5163
+ },
5164
+ {
5165
+ nodes: [{ id: 'n0', urn: 'media:html' }],
5166
+ edges: [],
5167
+ input_anchor_nodes: ['n0'],
5168
+ output_anchor_nodes: ['n0'],
5169
+ },
5170
+ ],
5171
+ };
5172
+ let threw = false;
5173
+ let message = '';
5174
+ try {
5175
+ rendererBuildResolvedMachineGraphData(payload);
5176
+ } catch (e) {
5177
+ threw = true;
5178
+ message = e.message || '';
5179
+ }
5180
+ assert(threw, 'duplicate node id across strands must throw');
5181
+ assert(message.includes('duplicate node id') && message.includes('n0'),
5182
+ 'error must name the colliding node id');
5183
+ }
5184
+
5185
+ function testRenderer_validateResolvedMachinePayload_rejectsMissingFields() {
5186
+ // The validator must reject any payload missing a required
5187
+ // field on a strand, edge, node, or assignment binding.
5188
+ // We exercise the most-likely-to-be-missed field on each
5189
+ // sub-shape.
5190
+ const cases = [
5191
+ { strands: 'not-an-array' },
5192
+ { strands: [{ nodes: [], edges: [], input_anchor_nodes: [] /* missing output_anchor_nodes */ }] },
5193
+ { strands: [{ nodes: [{ id: 'n0' /* missing urn */ }], edges: [], input_anchor_nodes: [], output_anchor_nodes: [] }] },
5194
+ {
5195
+ strands: [{
5196
+ nodes: [{ id: 'n0', urn: 'media:x' }],
5197
+ edges: [{
5198
+ alias: 'edge_0',
5199
+ cap_urn: 'cap:in=...;out=...',
5200
+ is_loop: false,
5201
+ assignment: [{ cap_arg_media_urn: 'media:x' /* missing source_node */ }],
5202
+ target_node: 'n0',
5203
+ }],
5204
+ input_anchor_nodes: ['n0'],
5205
+ output_anchor_nodes: ['n0'],
5206
+ }],
5207
+ },
5208
+ ];
5209
+ for (const c of cases) {
5210
+ let threw = false;
5211
+ try { rendererValidateResolvedMachinePayload(c); } catch (e) { threw = true; }
5212
+ assert(threw, `validator must reject payload: ${JSON.stringify(c).slice(0, 80)}`);
5213
+ }
5214
+ }
5215
+
4841
5216
  // ============================================================================
4842
5217
  // Test runner
4843
5218
  // ============================================================================
@@ -5016,24 +5391,24 @@ async function runTests() {
5016
5391
  if (p2) await p2;
5017
5392
  runTest('JS: media_spec_construction', testJS_mediaSpecConstruction);
5018
5393
 
5019
- // plugin_repo: PluginRepoServer and PluginRepoClient tests
5020
- console.log('\n--- plugin_repo ---');
5021
- runTest('TEST320: plugin_info_construction', test320_pluginInfoConstruction);
5022
- runTest('TEST321: plugin_info_is_signed', test321_pluginInfoIsSigned);
5023
- runTest('TEST322: plugin_info_has_binary', test322_pluginInfoHasBinary);
5024
- runTest('TEST323: plugin_repo_server_validate_registry', test323_pluginRepoServerValidateRegistry);
5025
- runTest('TEST324: plugin_repo_server_transform_to_array', test324_pluginRepoServerTransformToArray);
5026
- runTest('TEST325: plugin_repo_server_get_plugins', test325_pluginRepoServerGetPlugins);
5027
- runTest('TEST326: plugin_repo_server_get_plugin_by_id', test326_pluginRepoServerGetPluginById);
5028
- runTest('TEST327: plugin_repo_server_search_plugins', test327_pluginRepoServerSearchPlugins);
5029
- runTest('TEST328: plugin_repo_server_get_by_category', test328_pluginRepoServerGetByCategory);
5030
- runTest('TEST329: plugin_repo_server_get_by_cap', test329_pluginRepoServerGetByCap);
5031
- runTest('TEST330: plugin_repo_client_update_cache', test330_pluginRepoClientUpdateCache);
5032
- runTest('TEST331: plugin_repo_client_get_suggestions', test331_pluginRepoClientGetSuggestions);
5033
- runTest('TEST332: plugin_repo_client_get_plugin', test332_pluginRepoClientGetPlugin);
5034
- runTest('TEST333: plugin_repo_client_get_all_caps', test333_pluginRepoClientGetAllCaps);
5035
- runTest('TEST334: plugin_repo_client_needs_sync', test334_pluginRepoClientNeedsSync);
5036
- runTest('TEST335: plugin_repo_server_client_integration', test335_pluginRepoServerClientIntegration);
5394
+ // cartridge_repo: CartridgeRepoServer and CartridgeRepoClient tests
5395
+ console.log('\n--- cartridge_repo ---');
5396
+ runTest('TEST320: cartridge_info_construction', test320_cartridgeInfoConstruction);
5397
+ runTest('TEST321: cartridge_info_is_signed', test321_cartridgeInfoIsSigned);
5398
+ runTest('TEST322: cartridge_info_has_package', test322_cartridgeInfoHasPackage);
5399
+ runTest('TEST323: cartridge_repo_server_validate_registry', test323_cartridgeRepoServerValidateRegistry);
5400
+ runTest('TEST324: cartridge_repo_server_transform_to_array', test324_cartridgeRepoServerTransformToArray);
5401
+ runTest('TEST325: cartridge_repo_server_get_cartridges', test325_cartridgeRepoServerGetCartridges);
5402
+ runTest('TEST326: cartridge_repo_server_get_cartridge_by_id', test326_cartridgeRepoServerGetCartridgeById);
5403
+ runTest('TEST327: cartridge_repo_server_search_cartridges', test327_cartridgeRepoServerSearchCartridges);
5404
+ runTest('TEST328: cartridge_repo_server_get_by_category', test328_cartridgeRepoServerGetByCategory);
5405
+ runTest('TEST329: cartridge_repo_server_get_by_cap', test329_cartridgeRepoServerGetByCap);
5406
+ runTest('TEST330: cartridge_repo_client_update_cache', test330_cartridgeRepoClientUpdateCache);
5407
+ runTest('TEST331: cartridge_repo_client_get_suggestions', test331_cartridgeRepoClientGetSuggestions);
5408
+ runTest('TEST332: cartridge_repo_client_get_cartridge', test332_cartridgeRepoClientGetCartridge);
5409
+ runTest('TEST333: cartridge_repo_client_get_all_caps', test333_cartridgeRepoClientGetAllCaps);
5410
+ runTest('TEST334: cartridge_repo_client_needs_sync', test334_cartridgeRepoClientNeedsSync);
5411
+ runTest('TEST335: cartridge_repo_server_client_integration', test335_cartridgeRepoServerClientIntegration);
5037
5412
 
5038
5413
  // media_urn.rs: TEST546-TEST558 (MediaUrn predicates)
5039
5414
  console.log('\n--- media_urn.rs (predicates) ---');
@@ -5229,12 +5604,24 @@ async function runTests() {
5229
5604
  runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
5230
5605
  runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
5231
5606
  runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
5232
- runTest('RENDERER: buildRun_backboneDroppedWhenSuccessful', testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful);
5233
-
5234
- console.log('\n--- cap-graph-renderer machine builder ---');
5235
- runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
5236
- runTest('RENDERER: buildMachine_preservesTokenIds', testRenderer_buildMachineGraphData_preservesTokenIds);
5237
- runTest('RENDERER: buildMachine_rejectsEdgeMissingSource', testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource);
5607
+ runTest('RENDERER: buildRun_unclosedForeachNoMerge', testRenderer_buildRunGraphData_unclosedForeachSuccessNoMerge);
5608
+ runTest('RENDERER: buildRun_closedForeachMerges', testRenderer_buildRunGraphData_closedForeachSuccessMergesAtCollectTarget);
5609
+
5610
+ console.log('\n--- cap-graph-renderer editor-graph builder ---');
5611
+ runTest('RENDERER: validateEditorGraph_unknownKind', testRenderer_validateEditorGraphPayload_rejectsUnknownKind);
5612
+ runTest('RENDERER: buildEditorGraph_collapsesCapsIntoEdges', testRenderer_buildEditorGraphData_collapsesCapsIntoLabeledEdges);
5613
+ runTest('RENDERER: buildEditorGraph_loopEdgeGetsClass', testRenderer_buildEditorGraphData_loopMarkedEdgeGetsLoopClass);
5614
+ runTest('RENDERER: buildEditorGraph_cardinalityFromIsSeq', testRenderer_buildEditorGraphData_cardinalityFromDataSlotSequenceFlags);
5615
+ runTest('RENDERER: buildEditorGraph_incompleteCapDropped', testRenderer_buildEditorGraphData_capWithoutCompleteArgsIsDropped);
5616
+ runTest('RENDERER: buildEditorGraph_rejectsEdgeMissingSrc', testRenderer_buildEditorGraphData_rejectsEdgeWithMissingSource);
5617
+
5618
+ console.log('\n--- cap-graph-renderer resolved-machine builder ---');
5619
+ runTest('RENDERER: buildResolvedMachine_singleStrandLinear', testRenderer_buildResolvedMachineGraphData_singleStrandLinearChain);
5620
+ runTest('RENDERER: buildResolvedMachine_loopGetsLoopClass', testRenderer_buildResolvedMachineGraphData_loopEdgeGetsLoopClass);
5621
+ runTest('RENDERER: buildResolvedMachine_fanInOneEdgePerSrc', testRenderer_buildResolvedMachineGraphData_fanInProducesEdgePerAssignment);
5622
+ runTest('RENDERER: buildResolvedMachine_multiStrandDisjoint', testRenderer_buildResolvedMachineGraphData_multiStrandKeepsStrandsDisjoint);
5623
+ runTest('RENDERER: buildResolvedMachine_dupNodeIdFails', testRenderer_buildResolvedMachineGraphData_duplicateNodeIdAcrossStrandsFailsHard);
5624
+ runTest('RENDERER: validateResolvedMachine_rejectsMissingFields', testRenderer_validateResolvedMachinePayload_rejectsMissingFields);
5238
5625
 
5239
5626
  // Summary
5240
5627
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);