capdag 0.102.232 → 0.104.240
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 +626 -0
- package/package.json +1 -1
package/capdag.test.js
CHANGED
|
@@ -3743,6 +3743,595 @@ function assertThrowsWithCode(fn, expectedCode) {
|
|
|
3743
3743
|
}
|
|
3744
3744
|
}
|
|
3745
3745
|
|
|
3746
|
+
// ============================================================================
|
|
3747
|
+
// cap-graph-renderer helpers — pure functions that do not require a DOM.
|
|
3748
|
+
// The renderer class itself needs cytoscape + DOM and is exercised by hand
|
|
3749
|
+
// in the browser; these tests cover the pure data transforms underneath it.
|
|
3750
|
+
// ============================================================================
|
|
3751
|
+
|
|
3752
|
+
const {
|
|
3753
|
+
cardinalityLabel: rendererCardinalityLabel,
|
|
3754
|
+
cardinalityFromCap: rendererCardinalityFromCap,
|
|
3755
|
+
canonicalMediaUrn: rendererCanonicalMediaUrn,
|
|
3756
|
+
mediaNodeLabel: rendererMediaNodeLabel,
|
|
3757
|
+
buildStrandGraphData: rendererBuildStrandGraphData,
|
|
3758
|
+
buildRunGraphData: rendererBuildRunGraphData,
|
|
3759
|
+
buildMachineGraphData: rendererBuildMachineGraphData,
|
|
3760
|
+
classifyStrandCapSteps: rendererClassifyStrandCapSteps,
|
|
3761
|
+
validateStrandPayload: rendererValidateStrandPayload,
|
|
3762
|
+
validateRunPayload: rendererValidateRunPayload,
|
|
3763
|
+
validateMachinePayload: rendererValidateMachinePayload,
|
|
3764
|
+
validateStrandStep: rendererValidateStrandStep,
|
|
3765
|
+
validateBodyOutcome: rendererValidateBodyOutcome,
|
|
3766
|
+
} = require('./cap-graph-renderer.js');
|
|
3767
|
+
|
|
3768
|
+
// The renderer module reads its dependencies off `window` or `global` at
|
|
3769
|
+
// call time (it is browser-first). Node has no window, so we install the
|
|
3770
|
+
// needed capdag-js classes on `global` before the tests run. Every
|
|
3771
|
+
// renderer path exercised by the tests resolves through these.
|
|
3772
|
+
if (typeof global.TaggedUrn === 'undefined') {
|
|
3773
|
+
global.TaggedUrn = require('tagged-urn').TaggedUrn;
|
|
3774
|
+
}
|
|
3775
|
+
if (typeof global.MediaUrn === 'undefined') global.MediaUrn = MediaUrn;
|
|
3776
|
+
if (typeof global.CapUrn === 'undefined') global.CapUrn = CapUrn;
|
|
3777
|
+
if (typeof global.Cap === 'undefined') global.Cap = Cap;
|
|
3778
|
+
if (typeof global.CapGraph === 'undefined') global.CapGraph = CapGraph;
|
|
3779
|
+
// Reference the top-of-file destructured createCap via the module export.
|
|
3780
|
+
if (typeof global.createCap === 'undefined') {
|
|
3781
|
+
global.createCap = require('./capdag.js').createCap;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
function testRenderer_cardinalityLabel_allFourCases() {
|
|
3785
|
+
assertEqual(rendererCardinalityLabel(false, false), '1\u21921', 'scalar-to-scalar');
|
|
3786
|
+
assertEqual(rendererCardinalityLabel(true, false), 'n\u21921', 'sequence-to-scalar');
|
|
3787
|
+
assertEqual(rendererCardinalityLabel(false, true), '1\u2192n', 'scalar-to-sequence');
|
|
3788
|
+
assertEqual(rendererCardinalityLabel(true, true), 'n\u2192n', 'sequence-to-sequence');
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
function testRenderer_cardinalityLabel_usesUnicodeArrow() {
|
|
3792
|
+
// The label must use the real rightwards arrow (U+2192), not ASCII "->".
|
|
3793
|
+
// Downstream styling and tests depend on this glyph.
|
|
3794
|
+
const label = rendererCardinalityLabel(false, true);
|
|
3795
|
+
assert(label.includes('\u2192'), 'label should contain U+2192 rightwards arrow');
|
|
3796
|
+
assert(!label.includes('->'), 'label must not contain the ASCII replacement "->"');
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
function testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg() {
|
|
3800
|
+
// The main input arg is the one whose sources include a stdin source.
|
|
3801
|
+
// A naive implementation that reads args[0] would see `cli-only` (not a
|
|
3802
|
+
// sequence) and report 1→1 even though the stdin arg is a sequence.
|
|
3803
|
+
const cap = {
|
|
3804
|
+
urn: 'cap:in="media:textable;list";op=transcribe;out="media:textable"',
|
|
3805
|
+
args: [
|
|
3806
|
+
{
|
|
3807
|
+
display_name: 'cli-only',
|
|
3808
|
+
is_sequence: false,
|
|
3809
|
+
sources: [{ cli_flag: '--mode' }],
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
display_name: 'main-input',
|
|
3813
|
+
is_sequence: true,
|
|
3814
|
+
sources: [{ stdin: {} }],
|
|
3815
|
+
},
|
|
3816
|
+
],
|
|
3817
|
+
output: { is_sequence: false },
|
|
3818
|
+
};
|
|
3819
|
+
assertEqual(rendererCardinalityFromCap(cap), 'n\u21921',
|
|
3820
|
+
'must pick the arg that has a stdin source, not args[0]');
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
function testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing() {
|
|
3824
|
+
// No args and no output: both sides collapse to 1 (scalar default).
|
|
3825
|
+
// If a bug makes the function return "n" for missing data, this fails.
|
|
3826
|
+
const cap = { urn: 'cap:in="media:";op=noop;out="media:"' };
|
|
3827
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
|
|
3828
|
+
'missing args/output must default to scalar on both sides');
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
function testRenderer_cardinalityFromCap_outputOnlySequence() {
|
|
3832
|
+
// One scalar stdin arg, output is a sequence: expects 1→n.
|
|
3833
|
+
const cap = {
|
|
3834
|
+
urn: 'cap:in="media:textable";op=generate;out="media:textable;list"',
|
|
3835
|
+
args: [{ sources: [{ stdin: {} }], is_sequence: false }],
|
|
3836
|
+
output: { is_sequence: true },
|
|
3837
|
+
};
|
|
3838
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u2192n',
|
|
3839
|
+
'scalar stdin with sequence output must yield 1→n');
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
function testRenderer_cardinalityFromCap_rejectsStringIsSequence() {
|
|
3843
|
+
// The function must use strict `=== true` to avoid treating truthy strings
|
|
3844
|
+
// as booleans. "true" is a string, not a boolean — it must NOT be treated
|
|
3845
|
+
// as a sequence, because downstream renderers expect boolean semantics.
|
|
3846
|
+
const cap = {
|
|
3847
|
+
urn: 'cap:in="media:";op=x;out="media:"',
|
|
3848
|
+
args: [{ sources: [{ stdin: {} }], is_sequence: 'true' }],
|
|
3849
|
+
output: { is_sequence: 'true' },
|
|
3850
|
+
};
|
|
3851
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
|
|
3852
|
+
'string "true" must not be treated as boolean true');
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
function testRenderer_cardinalityFromCap_throwsOnNonObject() {
|
|
3856
|
+
// Fail-hard on invalid input; no fallback to a default cardinality.
|
|
3857
|
+
let threw = false;
|
|
3858
|
+
try {
|
|
3859
|
+
rendererCardinalityFromCap(null);
|
|
3860
|
+
} catch (e) {
|
|
3861
|
+
threw = true;
|
|
3862
|
+
}
|
|
3863
|
+
assert(threw, 'cardinalityFromCap(null) must throw');
|
|
3864
|
+
|
|
3865
|
+
threw = false;
|
|
3866
|
+
try {
|
|
3867
|
+
rendererCardinalityFromCap('not-an-object');
|
|
3868
|
+
} catch (e) {
|
|
3869
|
+
threw = true;
|
|
3870
|
+
}
|
|
3871
|
+
assert(threw, 'cardinalityFromCap(string) must throw');
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
function testRenderer_canonicalMediaUrn_normalizesTagOrder() {
|
|
3875
|
+
// Two media URNs with identical tags in different input orders must
|
|
3876
|
+
// produce the same canonical string. If canonicalization is bypassed,
|
|
3877
|
+
// the two strings remain different and this test exposes it.
|
|
3878
|
+
const a = rendererCanonicalMediaUrn('media:video;h264;list');
|
|
3879
|
+
const b = rendererCanonicalMediaUrn('media:list;h264;video');
|
|
3880
|
+
assertEqual(a, b, 'tag-order differences must not survive canonicalization');
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
function testRenderer_canonicalMediaUrn_preservesValueTags() {
|
|
3884
|
+
const c = rendererCanonicalMediaUrn('media:model;quant=q4');
|
|
3885
|
+
assert(c.includes('quant=q4'), 'value tag must be preserved');
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
function testRenderer_canonicalMediaUrn_rejectsCapUrn() {
|
|
3889
|
+
// MediaUrn.fromString enforces the media: prefix. Feeding a cap URN to
|
|
3890
|
+
// canonicalMediaUrn must fail hard.
|
|
3891
|
+
let threw = false;
|
|
3892
|
+
try {
|
|
3893
|
+
rendererCanonicalMediaUrn('cap:op=x;in="media:";out="media:"');
|
|
3894
|
+
} catch (e) {
|
|
3895
|
+
threw = true;
|
|
3896
|
+
}
|
|
3897
|
+
assert(threw, 'canonicalMediaUrn must reject non-media URNs');
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
function testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker() {
|
|
3901
|
+
// A media URN with one value tag and one marker tag renders two lines:
|
|
3902
|
+
// value tag as "key: value", marker tag as bare key. Order is canonical
|
|
3903
|
+
// (alphabetical, matching TaggedUrn's sorted key iteration).
|
|
3904
|
+
const label = rendererMediaNodeLabel('media:video;quant=q4');
|
|
3905
|
+
const lines = label.split('\n');
|
|
3906
|
+
assertEqual(lines.length, 2, 'two tags must produce two lines');
|
|
3907
|
+
assert(lines.includes('quant: q4'), 'value tag rendered as key: value');
|
|
3908
|
+
assert(lines.includes('video'), 'marker tag rendered as bare key');
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
function testRenderer_mediaNodeLabel_stableAcrossTagOrder() {
|
|
3912
|
+
// Labels must be tag-order-independent so that the same media URN
|
|
3913
|
+
// produces the same multi-line label regardless of how the source
|
|
3914
|
+
// happened to spell it.
|
|
3915
|
+
const a = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:list;textable'));
|
|
3916
|
+
const b = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:textable;list'));
|
|
3917
|
+
assertEqual(a, b, 'label must be stable across tag orderings');
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
// ---------------- strand builder ----------------
|
|
3921
|
+
|
|
3922
|
+
function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
|
|
3923
|
+
return {
|
|
3924
|
+
step_type: {
|
|
3925
|
+
Cap: {
|
|
3926
|
+
cap_urn: capUrn,
|
|
3927
|
+
title,
|
|
3928
|
+
specificity: 0,
|
|
3929
|
+
input_is_sequence: inSeq,
|
|
3930
|
+
output_is_sequence: outSeq,
|
|
3931
|
+
},
|
|
3932
|
+
},
|
|
3933
|
+
from_spec: fromSpec,
|
|
3934
|
+
to_spec: toSpec,
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
function makeForEachStep(mediaSpec) {
|
|
3939
|
+
return {
|
|
3940
|
+
step_type: { ForEach: { media_spec: mediaSpec } },
|
|
3941
|
+
from_spec: mediaSpec,
|
|
3942
|
+
to_spec: mediaSpec,
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
function makeCollectStep(mediaSpec) {
|
|
3947
|
+
return {
|
|
3948
|
+
step_type: { Collect: { media_spec: mediaSpec } },
|
|
3949
|
+
from_spec: mediaSpec,
|
|
3950
|
+
to_spec: mediaSpec,
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
function testRenderer_validateStrandStep_rejectsUnknownVariant() {
|
|
3955
|
+
// A step with an unknown variant must fail hard at validation; no
|
|
3956
|
+
// silent coercion.
|
|
3957
|
+
let threw = false;
|
|
3958
|
+
try {
|
|
3959
|
+
rendererValidateStrandStep({
|
|
3960
|
+
step_type: { WrongVariant: {} },
|
|
3961
|
+
from_spec: 'media:a',
|
|
3962
|
+
to_spec: 'media:a',
|
|
3963
|
+
}, 'test');
|
|
3964
|
+
} catch (e) {
|
|
3965
|
+
threw = true;
|
|
3966
|
+
assert(e.message.includes('WrongVariant'), 'error must name the bad variant');
|
|
3967
|
+
}
|
|
3968
|
+
assert(threw, 'unknown variant must throw');
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function testRenderer_validateStrandStep_requiresBooleanIsSequence() {
|
|
3972
|
+
// A Cap variant must have boolean is_sequence fields; number or string
|
|
3973
|
+
// must reject.
|
|
3974
|
+
let threw = false;
|
|
3975
|
+
try {
|
|
3976
|
+
rendererValidateStrandStep({
|
|
3977
|
+
step_type: { Cap: {
|
|
3978
|
+
cap_urn: 'cap:in="media:a";op=x;out="media:b"',
|
|
3979
|
+
title: 't',
|
|
3980
|
+
input_is_sequence: 1, // number, not boolean
|
|
3981
|
+
output_is_sequence: false,
|
|
3982
|
+
}},
|
|
3983
|
+
from_spec: 'media:a',
|
|
3984
|
+
to_spec: 'media:b',
|
|
3985
|
+
}, 'test');
|
|
3986
|
+
} catch (e) {
|
|
3987
|
+
threw = true;
|
|
3988
|
+
assert(e.message.includes('input_is_sequence'), 'error must name the bad field');
|
|
3989
|
+
}
|
|
3990
|
+
assert(threw, 'non-boolean is_sequence must throw');
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
function testRenderer_classifyStrandCapSteps_capFlags() {
|
|
3994
|
+
// Strand: ForEach → cap1 → cap2 → cap3 → Collect. cap1 should have
|
|
3995
|
+
// prevForEach=true; cap3 should have nextCollect=true; cap2 should
|
|
3996
|
+
// have neither.
|
|
3997
|
+
const steps = [
|
|
3998
|
+
makeForEachStep('media:pdf;list'),
|
|
3999
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
|
|
4000
|
+
makeCapStep('cap:in="media:png";op=b;out="media:jpg"', 'b', 'media:png', 'media:jpg', false, false),
|
|
4001
|
+
makeCapStep('cap:in="media:jpg";op=c;out="media:txt"', 'c', 'media:jpg', 'media:txt', false, false),
|
|
4002
|
+
makeCollectStep('media:txt'),
|
|
4003
|
+
];
|
|
4004
|
+
const { capStepIndices, capFlags } = rendererClassifyStrandCapSteps(steps);
|
|
4005
|
+
assertEqual(capStepIndices.length, 3, 'three cap steps');
|
|
4006
|
+
assert(capFlags.get(1).prevForEach, 'cap1 has prevForEach');
|
|
4007
|
+
assert(!capFlags.get(1).nextCollect, 'cap1 has no nextCollect');
|
|
4008
|
+
assert(!capFlags.get(2).prevForEach, 'cap2 has no prevForEach');
|
|
4009
|
+
assert(!capFlags.get(2).nextCollect, 'cap2 has no nextCollect');
|
|
4010
|
+
assert(!capFlags.get(3).prevForEach, 'cap3 has no prevForEach');
|
|
4011
|
+
assert(capFlags.get(3).nextCollect, 'cap3 has nextCollect');
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
function testRenderer_classifyStrandCapSteps_nestedForks() {
|
|
4015
|
+
// Nested strand: ForEach → cap1 → ForEach → cap2 → Collect → cap3 → Collect.
|
|
4016
|
+
// cap1 has prevForEach (outer), cap2 has prevForEach (inner) and
|
|
4017
|
+
// nextCollect (inner), cap3 has nextCollect (outer).
|
|
4018
|
+
const steps = [
|
|
4019
|
+
makeForEachStep('media:a;list'),
|
|
4020
|
+
makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
|
|
4021
|
+
makeForEachStep('media:b;list'),
|
|
4022
|
+
makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
|
|
4023
|
+
makeCollectStep('media:c'),
|
|
4024
|
+
makeCapStep('cap:in="media:c";op=c;out="media:d"', 'c', 'media:c', 'media:d', false, false),
|
|
4025
|
+
makeCollectStep('media:d'),
|
|
4026
|
+
];
|
|
4027
|
+
const { capFlags } = rendererClassifyStrandCapSteps(steps);
|
|
4028
|
+
assert(capFlags.get(1).prevForEach && !capFlags.get(1).nextCollect, 'cap1 outer entry');
|
|
4029
|
+
assert(capFlags.get(3).prevForEach && capFlags.get(3).nextCollect, 'cap2 inner both');
|
|
4030
|
+
assert(!capFlags.get(5).prevForEach && capFlags.get(5).nextCollect, 'cap3 outer exit');
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
function testRenderer_buildStrandGraphData_labelsForkBoundaries() {
|
|
4034
|
+
// End-to-end: a foreach strand in which source_spec (media:pdf;list)
|
|
4035
|
+
// differs from the first cap's from_spec (media:pdf) produces an
|
|
4036
|
+
// explicit fix-up edge source→firstCap.from labeled "for each". The
|
|
4037
|
+
// cap edge carries only the cap title. The topology is
|
|
4038
|
+
// media:pdf;list --[for each]--> media:pdf --[extract text]--> media:txt
|
|
4039
|
+
// Only two edges — ForEach is NOT a separate node, Collect is absent
|
|
4040
|
+
// because the strand's target_spec already matches the last cap's
|
|
4041
|
+
// to_spec (no fan-in fix-up needed).
|
|
4042
|
+
const payload = {
|
|
4043
|
+
source_spec: 'media:pdf;list',
|
|
4044
|
+
target_spec: 'media:txt',
|
|
4045
|
+
steps: [
|
|
4046
|
+
makeForEachStep('media:pdf;list'),
|
|
4047
|
+
makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract text', 'media:pdf', 'media:txt', false, false),
|
|
4048
|
+
],
|
|
4049
|
+
};
|
|
4050
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4051
|
+
assertEqual(built.edges.length, 2, 'one fix-up edge + one cap edge');
|
|
4052
|
+
const forEachEdge = built.edges[0];
|
|
4053
|
+
assertEqual(forEachEdge.label, 'for each', 'first edge is the for-each fan-out');
|
|
4054
|
+
assertEqual(forEachEdge.source, 'media:pdf;list', 'for-each sources from source_spec');
|
|
4055
|
+
assertEqual(forEachEdge.target, 'media:pdf', 'for-each targets first cap.from_spec');
|
|
4056
|
+
const capEdge = built.edges[1];
|
|
4057
|
+
assertEqual(capEdge.label, 'extract text', 'cap edge carries only the cap title');
|
|
4058
|
+
assertEqual(capEdge.source, 'media:pdf', 'cap sources from its from_spec');
|
|
4059
|
+
assertEqual(capEdge.target, 'media:txt', 'cap targets its to_spec');
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
function testRenderer_buildStrandGraphData_collectFixupEdge() {
|
|
4063
|
+
// When the strand's target_spec (media:pdf;list) differs from the
|
|
4064
|
+
// last cap's to_spec (media:pdf), the builder emits an explicit
|
|
4065
|
+
// fix-up edge lastCap.to → target labeled "collect".
|
|
4066
|
+
const payload = {
|
|
4067
|
+
source_spec: 'media:a',
|
|
4068
|
+
target_spec: 'media:pdf;list',
|
|
4069
|
+
steps: [
|
|
4070
|
+
makeCapStep('cap:in="media:a";op=x;out="media:pdf"', 'x', 'media:a', 'media:pdf', false, false),
|
|
4071
|
+
makeCollectStep('media:pdf'),
|
|
4072
|
+
],
|
|
4073
|
+
};
|
|
4074
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4075
|
+
assertEqual(built.edges.length, 2, 'one cap edge + one collect fix-up');
|
|
4076
|
+
const capEdge = built.edges[0];
|
|
4077
|
+
assertEqual(capEdge.label, 'x', 'cap edge plain title');
|
|
4078
|
+
const collectEdge = built.edges[1];
|
|
4079
|
+
assertEqual(collectEdge.label, 'collect', 'collect fix-up labeled "collect"');
|
|
4080
|
+
assertEqual(collectEdge.source, 'media:pdf', 'collect sources from last cap.to_spec');
|
|
4081
|
+
assertEqual(collectEdge.target, 'media:pdf;list', 'collect targets target_spec');
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
function testRenderer_buildStrandGraphData_plainCapNoMarker() {
|
|
4085
|
+
// A plain 1→1 cap step (no ForEach/Collect adjacency, no sequence)
|
|
4086
|
+
// must not emit any cardinality marker in the label. This catches a
|
|
4087
|
+
// regression where "1→1" was accidentally appended to every edge.
|
|
4088
|
+
const payload = {
|
|
4089
|
+
source_spec: 'media:a',
|
|
4090
|
+
target_spec: 'media:b',
|
|
4091
|
+
steps: [
|
|
4092
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4093
|
+
],
|
|
4094
|
+
};
|
|
4095
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4096
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4097
|
+
assertEqual(built.edges[0].label, 'x', 'plain cap has no cardinality suffix');
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
function testRenderer_buildStrandGraphData_sequenceShowsCardinality() {
|
|
4101
|
+
// A cap with input_is_sequence=true MUST emit "(n→1)" on the label.
|
|
4102
|
+
const payload = {
|
|
4103
|
+
source_spec: 'media:a;list',
|
|
4104
|
+
target_spec: 'media:b',
|
|
4105
|
+
steps: [
|
|
4106
|
+
makeCapStep('cap:in="media:a;list";op=x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
|
|
4107
|
+
],
|
|
4108
|
+
};
|
|
4109
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4110
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4111
|
+
assert(built.edges[0].label.includes('(n\u21921)'),
|
|
4112
|
+
`edge label must include (n\u21921) marker; got: ${built.edges[0].label}`);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
function testRenderer_validateStrandPayload_missingSourceSpec() {
|
|
4116
|
+
let threw = false;
|
|
4117
|
+
try {
|
|
4118
|
+
rendererValidateStrandPayload({ target_spec: 'media:b', steps: [] });
|
|
4119
|
+
} catch (e) {
|
|
4120
|
+
threw = true;
|
|
4121
|
+
assert(e.message.includes('source_spec'), 'error must name source_spec');
|
|
4122
|
+
}
|
|
4123
|
+
assert(threw, 'missing source_spec must throw');
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
// ---------------- run builder ----------------
|
|
4127
|
+
|
|
4128
|
+
function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
|
|
4129
|
+
let threw = false;
|
|
4130
|
+
try {
|
|
4131
|
+
rendererValidateBodyOutcome({ body_index: -1, success: true, cap_urns: [] }, 'test');
|
|
4132
|
+
} catch (e) {
|
|
4133
|
+
threw = true;
|
|
4134
|
+
}
|
|
4135
|
+
assert(threw, 'negative body_index must throw');
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
4139
|
+
// 6 successes, 4 failures. visible_success_count=3, visible_failure_count=2,
|
|
4140
|
+
// total_body_count=10. The builder must:
|
|
4141
|
+
// - render exactly 3 success replicas (one node per cap step per body)
|
|
4142
|
+
// - render exactly 2 failure replicas
|
|
4143
|
+
// - emit a success show-more node with hidden count 3
|
|
4144
|
+
// - emit a failure show-more node with hidden count 2
|
|
4145
|
+
const strand = {
|
|
4146
|
+
source_spec: 'media:pdf;list',
|
|
4147
|
+
target_spec: 'media:txt',
|
|
4148
|
+
steps: [
|
|
4149
|
+
makeForEachStep('media:pdf;list'),
|
|
4150
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
|
|
4151
|
+
makeCapStep('cap:in="media:png";op=b;out="media:txt"', 'b', 'media:png', 'media:txt', false, false),
|
|
4152
|
+
makeCollectStep('media:txt'),
|
|
4153
|
+
],
|
|
4154
|
+
};
|
|
4155
|
+
const outcomes = [];
|
|
4156
|
+
for (let i = 0; i < 6; i++) {
|
|
4157
|
+
outcomes.push({ body_index: i, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 });
|
|
4158
|
+
}
|
|
4159
|
+
for (let i = 6; i < 10; i++) {
|
|
4160
|
+
outcomes.push({
|
|
4161
|
+
body_index: i,
|
|
4162
|
+
success: false,
|
|
4163
|
+
cap_urns: [],
|
|
4164
|
+
saved_paths: [],
|
|
4165
|
+
total_bytes: 0,
|
|
4166
|
+
duration_ms: 0,
|
|
4167
|
+
failed_cap: 'cap:in="media:png";op=b;out="media:txt"',
|
|
4168
|
+
error: 'oom',
|
|
4169
|
+
});
|
|
4170
|
+
}
|
|
4171
|
+
const payload = {
|
|
4172
|
+
resolved_strand: strand,
|
|
4173
|
+
body_outcomes: outcomes,
|
|
4174
|
+
visible_success_count: 3,
|
|
4175
|
+
visible_failure_count: 2,
|
|
4176
|
+
total_body_count: 10,
|
|
4177
|
+
};
|
|
4178
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4179
|
+
|
|
4180
|
+
// Count replica nodes by classes.
|
|
4181
|
+
let successNodes = 0;
|
|
4182
|
+
let failureNodes = 0;
|
|
4183
|
+
for (const n of built.replicaNodes) {
|
|
4184
|
+
if (n.classes === 'body-success') successNodes++;
|
|
4185
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4186
|
+
}
|
|
4187
|
+
assertEqual(successNodes, 3 * 2, 'three successful bodies × two cap steps each = 6 success nodes');
|
|
4188
|
+
// Failed bodies truncate at failed_cap (cap b = second cap), so trace
|
|
4189
|
+
// length includes both cap a and cap b → 2 nodes per failed body.
|
|
4190
|
+
assertEqual(failureNodes, 2 * 2, 'two failed bodies × two nodes each (trace truncated at failed_cap)');
|
|
4191
|
+
|
|
4192
|
+
// Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
|
|
4193
|
+
const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
|
|
4194
|
+
const failureShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'failure');
|
|
4195
|
+
assert(successShowMore !== undefined, 'success show-more present');
|
|
4196
|
+
assert(failureShowMore !== undefined, 'failure show-more present');
|
|
4197
|
+
assertEqual(successShowMore.data.hiddenCount, 3, 'success hidden count = 3');
|
|
4198
|
+
assertEqual(failureShowMore.data.hiddenCount, 2, 'failure hidden count = 2');
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
|
|
4202
|
+
// A failure without failed_cap (e.g. infrastructure failure) must still
|
|
4203
|
+
// render the full body trace — the builder must not crash or produce
|
|
4204
|
+
// zero replicas.
|
|
4205
|
+
const strand = {
|
|
4206
|
+
source_spec: 'media:pdf;list',
|
|
4207
|
+
target_spec: 'media:txt',
|
|
4208
|
+
steps: [
|
|
4209
|
+
makeForEachStep('media:pdf;list'),
|
|
4210
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:txt"', 'a', 'media:pdf', 'media:txt', false, false),
|
|
4211
|
+
makeCollectStep('media:txt'),
|
|
4212
|
+
],
|
|
4213
|
+
};
|
|
4214
|
+
const payload = {
|
|
4215
|
+
resolved_strand: strand,
|
|
4216
|
+
body_outcomes: [
|
|
4217
|
+
{ body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, error: 'unknown' },
|
|
4218
|
+
],
|
|
4219
|
+
visible_success_count: 0,
|
|
4220
|
+
visible_failure_count: 5,
|
|
4221
|
+
total_body_count: 1,
|
|
4222
|
+
};
|
|
4223
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4224
|
+
let failureNodes = 0;
|
|
4225
|
+
for (const n of built.replicaNodes) {
|
|
4226
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4227
|
+
}
|
|
4228
|
+
assertEqual(failureNodes, 1, 'full trace (one cap) renders as one failure node');
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
4232
|
+
// The renderer matches failed_cap against step cap URNs via
|
|
4233
|
+
// CapUrn.isEquivalent, NOT string equality. Feed a payload where
|
|
4234
|
+
// failed_cap and the step's cap_urn differ only in tag order — they
|
|
4235
|
+
// should still match, proving URNs are not treated as strings.
|
|
4236
|
+
const strand = {
|
|
4237
|
+
source_spec: 'media:a',
|
|
4238
|
+
target_spec: 'media:c',
|
|
4239
|
+
steps: [
|
|
4240
|
+
makeForEachStep('media:a;list'),
|
|
4241
|
+
// Canonical form places tags alphabetically: op after in/out.
|
|
4242
|
+
makeCapStep(
|
|
4243
|
+
'cap:in="media:a";op=x;out="media:b"',
|
|
4244
|
+
'x', 'media:a', 'media:b', false, false
|
|
4245
|
+
),
|
|
4246
|
+
makeCapStep(
|
|
4247
|
+
'cap:in="media:b";op=y;out="media:c"',
|
|
4248
|
+
'y', 'media:b', 'media:c', false, false
|
|
4249
|
+
),
|
|
4250
|
+
makeCollectStep('media:c'),
|
|
4251
|
+
],
|
|
4252
|
+
};
|
|
4253
|
+
// The failed_cap URN is semantically the same as step 1 (cap y). If
|
|
4254
|
+
// CapUrn.fromString canonicalizes (it should), any equivalent form
|
|
4255
|
+
// will match. Feed a fully-specified form that is equivalent.
|
|
4256
|
+
const payload = {
|
|
4257
|
+
resolved_strand: strand,
|
|
4258
|
+
body_outcomes: [
|
|
4259
|
+
{
|
|
4260
|
+
body_index: 0,
|
|
4261
|
+
success: false,
|
|
4262
|
+
cap_urns: [],
|
|
4263
|
+
saved_paths: [],
|
|
4264
|
+
total_bytes: 0,
|
|
4265
|
+
duration_ms: 0,
|
|
4266
|
+
failed_cap: 'cap:in="media:b";out="media:c";op=y', // different tag order
|
|
4267
|
+
error: 'fail',
|
|
4268
|
+
},
|
|
4269
|
+
],
|
|
4270
|
+
visible_success_count: 0,
|
|
4271
|
+
visible_failure_count: 1,
|
|
4272
|
+
total_body_count: 1,
|
|
4273
|
+
};
|
|
4274
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4275
|
+
let failureNodes = 0;
|
|
4276
|
+
for (const n of built.replicaNodes) {
|
|
4277
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4278
|
+
}
|
|
4279
|
+
// cap x + cap y = 2 nodes for a body trace that terminates at cap y.
|
|
4280
|
+
assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
// ---------------- machine builder ----------------
|
|
4284
|
+
|
|
4285
|
+
function testRenderer_validateMachinePayload_rejectsUnknownKind() {
|
|
4286
|
+
let threw = false;
|
|
4287
|
+
try {
|
|
4288
|
+
rendererValidateMachinePayload({
|
|
4289
|
+
elements: [{ kind: 'widget', graph_id: 'w1' }],
|
|
4290
|
+
});
|
|
4291
|
+
} catch (e) {
|
|
4292
|
+
threw = true;
|
|
4293
|
+
assert(e.message.includes('widget') || e.message.includes('kind'),
|
|
4294
|
+
'error must name the bad kind');
|
|
4295
|
+
}
|
|
4296
|
+
assert(threw, 'unknown element kind must throw');
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
function testRenderer_buildMachineGraphData_preservesTokenIds() {
|
|
4300
|
+
// Token IDs are the bridge for editor cross-highlighting. Every
|
|
4301
|
+
// element MUST carry its tokenId through into the cytoscape data.
|
|
4302
|
+
const data = {
|
|
4303
|
+
elements: [
|
|
4304
|
+
{ kind: 'node', graph_id: 'n1', label: 'a', token_id: 't-node-a' },
|
|
4305
|
+
{ kind: 'cap', graph_id: 'c1', label: 'fn', token_id: 't-cap-fn' },
|
|
4306
|
+
{ kind: 'edge', graph_id: 'e1', source_graph_id: 'n1', target_graph_id: 'c1', label: '', token_id: 't-edge-1' },
|
|
4307
|
+
],
|
|
4308
|
+
};
|
|
4309
|
+
const built = rendererBuildMachineGraphData(data);
|
|
4310
|
+
const nodeTokens = built.nodes.map(n => n.data.tokenId).sort();
|
|
4311
|
+
assertEqual(JSON.stringify(nodeTokens), JSON.stringify(['t-cap-fn', 't-node-a']),
|
|
4312
|
+
'node tokenIds must round-trip');
|
|
4313
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4314
|
+
assertEqual(built.edges[0].data.tokenId, 't-edge-1', 'edge tokenId must round-trip');
|
|
4315
|
+
// Kinds are carried as element data for editor-side filtering.
|
|
4316
|
+
const kinds = built.nodes.map(n => n.data.kind).sort();
|
|
4317
|
+
assertEqual(JSON.stringify(kinds), JSON.stringify(['cap', 'node']),
|
|
4318
|
+
'element kinds must be preserved');
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
|
|
4322
|
+
let threw = false;
|
|
4323
|
+
try {
|
|
4324
|
+
rendererBuildMachineGraphData({
|
|
4325
|
+
elements: [
|
|
4326
|
+
{ kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
|
|
4327
|
+
],
|
|
4328
|
+
});
|
|
4329
|
+
} catch (e) {
|
|
4330
|
+
threw = true;
|
|
4331
|
+
}
|
|
4332
|
+
assert(threw, 'edge without source_graph_id must throw');
|
|
4333
|
+
}
|
|
4334
|
+
|
|
3746
4335
|
// ============================================================================
|
|
3747
4336
|
// Test runner
|
|
3748
4337
|
// ============================================================================
|
|
@@ -4093,6 +4682,43 @@ async function runTests() {
|
|
|
4093
4682
|
runTest('REGISTRY: capRegistryClient_construction', testMachine_capRegistryClient_construction);
|
|
4094
4683
|
runTest('REGISTRY: capRegistryEntry_defaults', testMachine_capRegistryEntry_defaults);
|
|
4095
4684
|
|
|
4685
|
+
// cap-graph-renderer pure helpers (no DOM dependency)
|
|
4686
|
+
console.log('\n--- cap-graph-renderer helpers ---');
|
|
4687
|
+
runTest('RENDERER: cardinalityLabel_allFourCases', testRenderer_cardinalityLabel_allFourCases);
|
|
4688
|
+
runTest('RENDERER: cardinalityLabel_usesUnicodeArrow', testRenderer_cardinalityLabel_usesUnicodeArrow);
|
|
4689
|
+
runTest('RENDERER: cardinalityFromCap_findsStdinArg', testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg);
|
|
4690
|
+
runTest('RENDERER: cardinalityFromCap_scalarDefaults', testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing);
|
|
4691
|
+
runTest('RENDERER: cardinalityFromCap_outputOnlySequence', testRenderer_cardinalityFromCap_outputOnlySequence);
|
|
4692
|
+
runTest('RENDERER: cardinalityFromCap_rejectsStringBool', testRenderer_cardinalityFromCap_rejectsStringIsSequence);
|
|
4693
|
+
runTest('RENDERER: cardinalityFromCap_throwsOnNonObject', testRenderer_cardinalityFromCap_throwsOnNonObject);
|
|
4694
|
+
runTest('RENDERER: canonicalMediaUrn_normalizesTagOrder', testRenderer_canonicalMediaUrn_normalizesTagOrder);
|
|
4695
|
+
runTest('RENDERER: canonicalMediaUrn_preservesValueTags', testRenderer_canonicalMediaUrn_preservesValueTags);
|
|
4696
|
+
runTest('RENDERER: canonicalMediaUrn_rejectsCapUrn', testRenderer_canonicalMediaUrn_rejectsCapUrn);
|
|
4697
|
+
runTest('RENDERER: mediaNodeLabel_valueAndMarker', testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker);
|
|
4698
|
+
runTest('RENDERER: mediaNodeLabel_stableAcrossTagOrder', testRenderer_mediaNodeLabel_stableAcrossTagOrder);
|
|
4699
|
+
|
|
4700
|
+
console.log('\n--- cap-graph-renderer strand builder ---');
|
|
4701
|
+
runTest('RENDERER: validateStrandStep_unknownVariant', testRenderer_validateStrandStep_rejectsUnknownVariant);
|
|
4702
|
+
runTest('RENDERER: validateStrandStep_booleanIsSequence', testRenderer_validateStrandStep_requiresBooleanIsSequence);
|
|
4703
|
+
runTest('RENDERER: classifyStrandCapSteps_simple', testRenderer_classifyStrandCapSteps_capFlags);
|
|
4704
|
+
runTest('RENDERER: classifyStrandCapSteps_nested', testRenderer_classifyStrandCapSteps_nestedForks);
|
|
4705
|
+
runTest('RENDERER: buildStrand_labelsForkBoundaries', testRenderer_buildStrandGraphData_labelsForkBoundaries);
|
|
4706
|
+
runTest('RENDERER: buildStrand_collectFixupEdge', testRenderer_buildStrandGraphData_collectFixupEdge);
|
|
4707
|
+
runTest('RENDERER: buildStrand_plainCapNoMarker', testRenderer_buildStrandGraphData_plainCapNoMarker);
|
|
4708
|
+
runTest('RENDERER: buildStrand_sequenceShowsCardinality', testRenderer_buildStrandGraphData_sequenceShowsCardinality);
|
|
4709
|
+
runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
|
|
4710
|
+
|
|
4711
|
+
console.log('\n--- cap-graph-renderer run builder ---');
|
|
4712
|
+
runTest('RENDERER: validateBodyOutcome_negativeIndex', testRenderer_validateBodyOutcome_rejectsNegativeIndex);
|
|
4713
|
+
runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
|
|
4714
|
+
runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
|
|
4715
|
+
runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
|
|
4716
|
+
|
|
4717
|
+
console.log('\n--- cap-graph-renderer machine builder ---');
|
|
4718
|
+
runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
|
|
4719
|
+
runTest('RENDERER: buildMachine_preservesTokenIds', testRenderer_buildMachineGraphData_preservesTokenIds);
|
|
4720
|
+
runTest('RENDERER: buildMachine_rejectsEdgeMissingSource', testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource);
|
|
4721
|
+
|
|
4096
4722
|
// Summary
|
|
4097
4723
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|
|
4098
4724
|
if (failCount > 0) {
|
package/package.json
CHANGED