@transitrix/diagrams 1.0.1 → 1.2.0
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/dist/activities/__tests__/validate.test.js +32 -1
- package/dist/activities/cpm.js +11 -2
- package/dist/activities/gantt.js +6 -3
- package/dist/activities/types.d.ts +1 -0
- package/dist/activities/validate.js +24 -8
- package/dist/activity-card/__tests__/resolver.test.js +22 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/resolver.js +28 -20
- package/dist/activity-card/validate.js +22 -11
- package/dist/capability-map/index.d.ts +4 -0
- package/dist/capability-map/index.js +2 -0
- package/dist/capability-map/layout-tree.d.ts +42 -0
- package/dist/capability-map/layout-tree.js +65 -0
- package/dist/capability-map/render-capability-tree.d.ts +15 -0
- package/dist/capability-map/render-capability-tree.js +108 -0
- package/dist/capability-map/types.d.ts +2 -0
- package/dist/compliance/__tests__/impact.test.js +198 -0
- package/dist/compliance/html.js +1 -7
- package/dist/compliance/impact.d.ts +24 -1
- package/dist/compliance/impact.js +97 -21
- package/dist/factor/__tests__/validate.test.js +3 -3
- package/dist/factor/types.d.ts +1 -1
- package/dist/factor/validate.js +2 -2
- package/dist/fgca/__tests__/chain-table.test.js +2 -2
- package/dist/fgca/__tests__/layout.test.js +11 -11
- package/dist/fgca/__tests__/preview-layout.test.js +7 -7
- package/dist/fgca/__tests__/resolver.test.js +2 -2
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/chain-table.d.ts +1 -1
- package/dist/fgca/chain-table.js +4 -4
- package/dist/fgca/index.d.ts +2 -2
- package/dist/fgca/index.js +1 -1
- package/dist/fgca/layout.d.ts +2 -2
- package/dist/fgca/layout.js +9 -9
- package/dist/fgca/nodeTypes.d.ts +1 -1
- package/dist/fgca/nodeTypes.js +2 -2
- package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
- package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
- package/dist/fgca/parse-canonical.js +24 -13
- package/dist/fgca/preview-layout.d.ts +1 -1
- package/dist/fgca/preview-layout.js +4 -4
- package/dist/fgca/resolver.js +3 -1
- package/dist/fgca/types.d.ts +13 -11
- package/dist/fgca/types.js +2 -2
- package/dist/fgca/validate.d.ts +2 -2
- package/dist/fgca/validate.js +34 -23
- package/dist/process-blueprint/index.d.ts +1 -1
- package/dist/process-blueprint/layout.js +64 -37
- package/dist/process-blueprint/types.d.ts +14 -0
- package/dist/scenarios/index.d.ts +1 -1
- package/dist/scenarios/types.d.ts +4 -4
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +28 -3
- package/dist/theme/tokens.d.ts +47 -1
- package/dist/theme/tokens.js +31 -1
- package/dist/webview/__tests__/entry-notations.test.js +2 -0
- package/dist/webview/__tests__/render-process.test.d.ts +1 -0
- package/dist/webview/__tests__/render-process.test.js +463 -0
- package/dist/webview/__tests__/render-util.test.d.ts +1 -0
- package/dist/webview/__tests__/render-util.test.js +54 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +9 -5
- package/dist/webview/render-activities.d.ts +21 -1
- package/dist/webview/render-activities.js +91 -56
- package/dist/webview/render-activity-card.d.ts +11 -1
- package/dist/webview/render-activity-card.js +11 -2
- package/dist/webview/render-blocks.d.ts +24 -1
- package/dist/webview/render-blocks.js +58 -24
- package/dist/webview/render-fgca.d.ts +27 -0
- package/dist/webview/render-fgca.js +30 -16
- package/dist/webview/render-goals.d.ts +26 -1
- package/dist/webview/render-goals.js +30 -20
- package/dist/webview/render-process-blueprint.d.ts +11 -1
- package/dist/webview/render-process-blueprint.js +118 -53
- package/dist/webview/render-process.d.ts +95 -0
- package/dist/webview/render-process.js +392 -0
- package/dist/webview/render-scenarios.js +2 -2
- package/dist/webview/render-util.d.ts +19 -6
- package/dist/webview/render-util.js +23 -15
- package/package.json +15 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/cpm.ts +14 -2
- package/src/activities/gantt.ts +6 -5
- package/src/activities/types.ts +1 -0
- package/src/activities/validate.ts +24 -8
- package/src/activity-card/__tests__/resolver.test.ts +24 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/resolver.ts +29 -20
- package/src/activity-card/validate.ts +20 -11
- package/src/capability-map/index.ts +11 -0
- package/src/capability-map/layout-tree.ts +114 -0
- package/src/capability-map/render-capability-tree.ts +131 -0
- package/src/capability-map/types.ts +2 -0
- package/src/compliance/__tests__/impact.test.ts +219 -0
- package/src/compliance/html.ts +1 -8
- package/src/compliance/impact.ts +141 -23
- package/src/factor/__tests__/validate.test.ts +3 -3
- package/src/factor/types.ts +1 -1
- package/src/factor/validate.ts +2 -2
- package/src/fgca/__tests__/chain-table.test.ts +2 -2
- package/src/fgca/__tests__/layout.test.ts +13 -13
- package/src/fgca/__tests__/preview-layout.test.ts +7 -7
- package/src/fgca/__tests__/resolver.test.ts +2 -2
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/chain-table.ts +5 -5
- package/src/fgca/index.ts +3 -3
- package/src/fgca/layout.ts +11 -11
- package/src/fgca/nodeTypes.ts +3 -3
- package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
- package/src/fgca/parse-canonical.ts +26 -15
- package/src/fgca/preview-layout.ts +5 -5
- package/src/fgca/resolver.ts +3 -1
- package/src/fgca/types.ts +15 -13
- package/src/fgca/validate.ts +32 -21
- package/src/process-blueprint/index.ts +1 -0
- package/src/process-blueprint/layout.ts +71 -42
- package/src/process-blueprint/types.ts +15 -0
- package/src/scenarios/index.ts +2 -2
- package/src/scenarios/types.ts +4 -4
- package/src/scenarios/validate.ts +3 -3
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +29 -2
- package/src/theme/tokens.ts +33 -1
- package/src/webview/__tests__/entry-notations.test.ts +2 -0
- package/src/webview/__tests__/render-process.test.ts +538 -0
- package/src/webview/__tests__/render-util.test.ts +66 -0
- package/src/webview/entry.ts +11 -5
- package/src/webview/render-activities.ts +107 -67
- package/src/webview/render-activity-card.ts +11 -2
- package/src/webview/render-blocks.ts +78 -28
- package/src/webview/render-fgca.ts +53 -29
- package/src/webview/render-goals.ts +45 -23
- package/src/webview/render-process-blueprint.ts +156 -82
- package/src/webview/render-process.ts +507 -0
- package/src/webview/render-scenarios.ts +3 -3
- package/src/webview/render-util.ts +27 -14
|
@@ -838,4 +838,223 @@ describe('mergeStageTaskDetails', () => {
|
|
|
838
838
|
// S2 has no tasks in stageOwner, filter returns [] → no tasks embedded
|
|
839
839
|
expect(merged.details[1].tasks).toBeUndefined();
|
|
840
840
|
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// ── COMPIMP-010 — subject type label invariant (#345) ────────────────────────
|
|
844
|
+
|
|
845
|
+
describe('COMPIMP-010 — subject type label invariant (#345)', () => {
|
|
846
|
+
it('process-only view: columns carry subjectType=process and plain ID labels (regression)', () => {
|
|
847
|
+
const canon = emptyCanon();
|
|
848
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
849
|
+
canon.requirements.push({ id: 'REQ-1', name: 'R1' });
|
|
850
|
+
canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROCESS-CS-1', status: 'compliant' });
|
|
851
|
+
const matrix = buildImpactMatrix(canon, {
|
|
852
|
+
id: 'V', name: 'Process-only',
|
|
853
|
+
subjects: { processes: ['PROCESS-CS-1'] },
|
|
854
|
+
obligations: {},
|
|
855
|
+
});
|
|
856
|
+
expect(matrix.columns).toHaveLength(1);
|
|
857
|
+
expect(matrix.columns[0].subjectType).toBe('process');
|
|
858
|
+
// Process-only: label is just the ID — no [PRODUCT] prefix (regression)
|
|
859
|
+
expect(matrix.columns[0].label).toBe('PROCESS-CS-1');
|
|
860
|
+
expect(matrix.columns[0].label).not.toContain('[PRODUCT]');
|
|
861
|
+
expect(matrix.cells[0][0].status).toBe('compliant');
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('product-only view: columns carry subjectType=product and plain ID labels', () => {
|
|
865
|
+
const canon = emptyCanon();
|
|
866
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
867
|
+
canon.requirements.push({ id: 'REQ-1', name: 'R1' });
|
|
868
|
+
canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PRODUCT-A-1', status: 'compliant' });
|
|
869
|
+
const matrix = buildImpactMatrix(canon, {
|
|
870
|
+
id: 'V', name: 'Product-only',
|
|
871
|
+
subjects: { products: ['PRODUCT-A-1'] },
|
|
872
|
+
obligations: {},
|
|
873
|
+
});
|
|
874
|
+
expect(matrix.columns[0].subjectType).toBe('product');
|
|
875
|
+
expect(matrix.columns[0].label).toBe('PRODUCT-A-1');
|
|
876
|
+
expect(matrix.columns[0].label).not.toContain('[PROCESS]');
|
|
877
|
+
expect(matrix.findings).toHaveLength(0);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('combined view: product columns carry [PRODUCT] badge; process columns carry [PROCESS] badge', () => {
|
|
881
|
+
const canon = emptyCanon();
|
|
882
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
883
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
884
|
+
canon.requirements.push({ id: 'REQ-1', name: 'R1' });
|
|
885
|
+
const matrix = buildImpactMatrix(canon, {
|
|
886
|
+
id: 'V', name: 'Combined',
|
|
887
|
+
subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
|
|
888
|
+
obligations: {},
|
|
889
|
+
});
|
|
890
|
+
expect(matrix.columns).toHaveLength(2);
|
|
891
|
+
const prodCol = matrix.columns.find(c => c.subjectId === 'PRODUCT-A-1')!;
|
|
892
|
+
const procCol = matrix.columns.find(c => c.subjectId === 'PROCESS-CS-1')!;
|
|
893
|
+
expect(prodCol.subjectType).toBe('product');
|
|
894
|
+
expect(prodCol.label).toBe('[PRODUCT] PRODUCT-A-1');
|
|
895
|
+
expect(procCol.subjectType).toBe('process');
|
|
896
|
+
expect(procCol.label).toBe('[PROCESS] PROCESS-CS-1');
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('combined view: markdown renders [PRODUCT] / [PROCESS] column headers', () => {
|
|
900
|
+
const canon = emptyCanon();
|
|
901
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
902
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
903
|
+
canon.requirements.push({ id: 'REQ-1', name: 'R1' });
|
|
904
|
+
const matrix = buildImpactMatrix(canon, {
|
|
905
|
+
id: 'V', name: 'Combined',
|
|
906
|
+
subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
|
|
907
|
+
obligations: {},
|
|
908
|
+
});
|
|
909
|
+
const md = renderImpactMarkdown(matrix);
|
|
910
|
+
expect(md).toContain('[PRODUCT] PRODUCT-A-1');
|
|
911
|
+
expect(md).toContain('[PROCESS] PROCESS-CS-1');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('capability column always has subjectType=capability', () => {
|
|
915
|
+
const canon = emptyCanon();
|
|
916
|
+
canon.subjects.push({ id: 'CAPABILITY-CRM-1', name: 'CRM' });
|
|
917
|
+
const matrix = buildImpactMatrix(canon, {
|
|
918
|
+
id: 'V', name: 'Cap',
|
|
919
|
+
subjects: { capabilities: ['CAPABILITY-CRM-1'] },
|
|
920
|
+
obligations: {},
|
|
921
|
+
});
|
|
922
|
+
expect(matrix.columns[0].subjectType).toBe('capability');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('COMPIMP-009 warning: product-* grouping with process-only subjects', () => {
|
|
926
|
+
const canon = emptyCanon();
|
|
927
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
|
|
928
|
+
const matrix = buildImpactMatrix(canon, {
|
|
929
|
+
id: 'V', name: 'V',
|
|
930
|
+
subjects: { processes: ['PROCESS-CS-1'] },
|
|
931
|
+
obligations: {},
|
|
932
|
+
grouping: { columns: 'product' },
|
|
933
|
+
});
|
|
934
|
+
const c009 = matrix.findings.find(f => f.code === 'COMPIMP-009');
|
|
935
|
+
expect(c009).toBeDefined();
|
|
936
|
+
expect(c009!.severity).toBe('warning');
|
|
937
|
+
expect(c009!.message).toContain('process');
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('COMPIMP-010 error: product-stage grouping with process subjects', () => {
|
|
941
|
+
const canon = emptyCanon();
|
|
942
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
|
|
943
|
+
const matrix = buildImpactMatrix(
|
|
944
|
+
canon,
|
|
945
|
+
{
|
|
946
|
+
id: 'V', name: 'V',
|
|
947
|
+
subjects: { processes: ['PROCESS-CS-1'] },
|
|
948
|
+
obligations: {},
|
|
949
|
+
grouping: { columns: 'product-stage' },
|
|
950
|
+
},
|
|
951
|
+
[{ objectId: 'PROCESS-CS-1', details: [{ id: 'STAGE-A', name: 'Stage A' }] }],
|
|
952
|
+
);
|
|
953
|
+
const c010 = matrix.findings.find(f => f.code === 'COMPIMP-010');
|
|
954
|
+
expect(c010).toBeDefined();
|
|
955
|
+
expect(c010!.severity).toBe('error');
|
|
956
|
+
expect(c010!.message).toContain('process-stage');
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('no COMPIMP-010 when process-stage grouping is used for process subjects', () => {
|
|
960
|
+
const canon = emptyCanon();
|
|
961
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
|
|
962
|
+
const matrix = buildImpactMatrix(
|
|
963
|
+
canon,
|
|
964
|
+
{
|
|
965
|
+
id: 'V', name: 'V',
|
|
966
|
+
subjects: { processes: ['PROCESS-CS-1'] },
|
|
967
|
+
obligations: {},
|
|
968
|
+
grouping: { columns: 'process-stage' },
|
|
969
|
+
},
|
|
970
|
+
[{ objectId: 'PROCESS-CS-1', details: [{ id: 'STAGE-A', name: 'Stage A' }] }],
|
|
971
|
+
);
|
|
972
|
+
expect(matrix.findings.some(f => f.code === 'COMPIMP-010')).toBe(false);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('parseImpactViewConfig accepts process-centric column values', () => {
|
|
976
|
+
for (const col of ['process', 'process-stage', 'process-stage-task'] as const) {
|
|
977
|
+
const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: col } });
|
|
978
|
+
expect(r.ok).toBe(true);
|
|
979
|
+
if (r.ok) expect(r.config.grouping?.columns).toBe(col);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('matrix.findings is empty for a conformant product-only view', () => {
|
|
984
|
+
const canon = emptyCanon();
|
|
985
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
986
|
+
const matrix = buildImpactMatrix(canon, {
|
|
987
|
+
id: 'V', name: 'V',
|
|
988
|
+
subjects: { products: ['PRODUCT-A-1'] },
|
|
989
|
+
obligations: {},
|
|
990
|
+
});
|
|
991
|
+
expect(matrix.findings).toEqual([]);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('COMPIMP-011 error: report_type=product with subjects.processes strips process columns', () => {
|
|
995
|
+
const canon = emptyCanon();
|
|
996
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
997
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
998
|
+
const matrix = buildImpactMatrix(canon, {
|
|
999
|
+
id: 'V', name: 'V',
|
|
1000
|
+
report_type: 'product',
|
|
1001
|
+
subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
|
|
1002
|
+
obligations: {},
|
|
1003
|
+
});
|
|
1004
|
+
// COMPIMP-011 fires
|
|
1005
|
+
const c011 = matrix.findings.find(f => f.code === 'COMPIMP-011');
|
|
1006
|
+
expect(c011).toBeDefined();
|
|
1007
|
+
expect(c011!.severity).toBe('error');
|
|
1008
|
+
// Spurious PROCESS column is stripped
|
|
1009
|
+
expect(matrix.columns).toHaveLength(1);
|
|
1010
|
+
expect(matrix.columns[0].subjectType).toBe('product');
|
|
1011
|
+
expect(matrix.columns[0].subjectId).toBe('PRODUCT-A-1');
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it('COMPIMP-011 error: report_type=process with subjects.products strips product columns', () => {
|
|
1015
|
+
const canon = emptyCanon();
|
|
1016
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
1017
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
1018
|
+
const matrix = buildImpactMatrix(canon, {
|
|
1019
|
+
id: 'V', name: 'V',
|
|
1020
|
+
report_type: 'process',
|
|
1021
|
+
subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
|
|
1022
|
+
obligations: {},
|
|
1023
|
+
});
|
|
1024
|
+
const c011 = matrix.findings.find(f => f.code === 'COMPIMP-011');
|
|
1025
|
+
expect(c011).toBeDefined();
|
|
1026
|
+
expect(c011!.severity).toBe('error');
|
|
1027
|
+
// Spurious PRODUCT column is stripped
|
|
1028
|
+
expect(matrix.columns).toHaveLength(1);
|
|
1029
|
+
expect(matrix.columns[0].subjectType).toBe('process');
|
|
1030
|
+
expect(matrix.columns[0].subjectId).toBe('PROCESS-CS-1');
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it('report_type=combined allows both product and process columns without COMPIMP-011', () => {
|
|
1034
|
+
const canon = emptyCanon();
|
|
1035
|
+
canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
|
|
1036
|
+
canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
|
|
1037
|
+
const matrix = buildImpactMatrix(canon, {
|
|
1038
|
+
id: 'V', name: 'V',
|
|
1039
|
+
report_type: 'combined',
|
|
1040
|
+
subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
|
|
1041
|
+
obligations: {},
|
|
1042
|
+
});
|
|
1043
|
+
expect(matrix.findings.some(f => f.code === 'COMPIMP-011')).toBe(false);
|
|
1044
|
+
expect(matrix.columns).toHaveLength(2);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('parseImpactViewConfig round-trips report_type', () => {
|
|
1048
|
+
for (const rt of ['product', 'process', 'combined'] as const) {
|
|
1049
|
+
const r = parseImpactViewConfig({ id: 'V', name: 'V', report_type: rt, subjects: {}, obligations: {} });
|
|
1050
|
+
expect(r.ok).toBe(true);
|
|
1051
|
+
if (r.ok) expect(r.config.report_type).toBe(rt);
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('parseImpactViewConfig ignores unknown report_type values', () => {
|
|
1056
|
+
const r = parseImpactViewConfig({ id: 'V', name: 'V', report_type: 'unknown-type', subjects: {}, obligations: {} });
|
|
1057
|
+
expect(r.ok).toBe(true);
|
|
1058
|
+
if (r.ok) expect(r.config.report_type).toBeUndefined();
|
|
1059
|
+
});
|
|
841
1060
|
});
|
package/src/compliance/html.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { ComplianceCanon } from './classify.js';
|
|
|
16
16
|
import type { ReportScope } from './markdown.js';
|
|
17
17
|
import type { AssertionStatus } from '../assertion/types.js';
|
|
18
18
|
import type { ImpactMatrix } from './impact.js';
|
|
19
|
+
import { escHtml } from '../webview/render-util.js';
|
|
19
20
|
|
|
20
21
|
const STATUS_LABELS: Record<AssertionStatus, string> = {
|
|
21
22
|
compliant: 'Compliant',
|
|
@@ -33,14 +34,6 @@ export interface HtmlOptions {
|
|
|
33
34
|
title?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
function escHtml(s: string): string {
|
|
37
|
-
return s
|
|
38
|
-
.replace(/&/g, '&')
|
|
39
|
-
.replace(/</g, '<')
|
|
40
|
-
.replace(/>/g, '>')
|
|
41
|
-
.replace(/"/g, '"');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
function badge(status: AssertionStatus): string {
|
|
45
38
|
return `<span class="cmp-badge cmp-${status}">${escHtml(STATUS_LABELS[status])}</span>`;
|
|
46
39
|
}
|
package/src/compliance/impact.ts
CHANGED
|
@@ -70,6 +70,16 @@ export interface ImpactColumn {
|
|
|
70
70
|
label: string;
|
|
71
71
|
/** Display name for the subject (product/process name, not the id). */
|
|
72
72
|
subjectName?: string;
|
|
73
|
+
/** Which subjects pool this column derives from (product / process / capability).
|
|
74
|
+
* Used to enforce the subject type label invariant (§5.2, COMPIMP-010). */
|
|
75
|
+
subjectType: 'product' | 'process' | 'capability';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A structural validation finding emitted by the matrix builder. */
|
|
79
|
+
export interface ImpactFinding {
|
|
80
|
+
code: string;
|
|
81
|
+
severity: 'error' | 'warning';
|
|
82
|
+
message: string;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/**
|
|
@@ -82,9 +92,13 @@ export interface ImpactColumn {
|
|
|
82
92
|
* - `'product-stage-task'` — one column per (subject, stage, task) triple;
|
|
83
93
|
* pass `objectDetails` with stages that carry `tasks[]` (built via
|
|
84
94
|
* `extractObjectDetails` + `extractProcessFlowTasks` + `mergeStageTaskDetails`).
|
|
95
|
+
* - `'process'` / `'process-stage'` / `'process-stage-task'` — process-centric
|
|
96
|
+
* equivalents used when `view.subjects.processes` is the sole entry point
|
|
97
|
+
* (§7.1 process compliance variant). Avoids COMPIMP-010 for process subjects.
|
|
85
98
|
*/
|
|
86
99
|
export interface ImpactGrouping {
|
|
87
|
-
columns: 'product' | 'product-stage' | 'product-stage-task'
|
|
100
|
+
columns: 'product' | 'product-stage' | 'product-stage-task'
|
|
101
|
+
| 'process' | 'process-stage' | 'process-stage-task';
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/**
|
|
@@ -103,6 +117,15 @@ export interface ImpactViewConfig {
|
|
|
103
117
|
id: string;
|
|
104
118
|
name: string;
|
|
105
119
|
description?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Declares the subject scope of the view (§4, COMPIMP-011 enforcement).
|
|
122
|
+
* `product` — covers products only; `subjects.processes` MUST be absent.
|
|
123
|
+
* `process` — covers processes directly; `subjects.products` MUST be absent.
|
|
124
|
+
* `combined` — both subject types allowed; column headers carry explicit badges.
|
|
125
|
+
* Omitting the field preserves backward-compatible behaviour (auto-detected
|
|
126
|
+
* from `subjects.*` presence).
|
|
127
|
+
*/
|
|
128
|
+
report_type?: 'product' | 'process' | 'combined';
|
|
106
129
|
/**
|
|
107
130
|
* ISO 8601 date of the last report snapshot (YYYY-MM-DD).
|
|
108
131
|
* Populated automatically by the CLI on each run and stored back to the
|
|
@@ -221,17 +244,26 @@ export function parseImpactViewConfig(raw: unknown): ParseImpactViewConfigResult
|
|
|
221
244
|
? (v.grouping as Record<string, unknown>)
|
|
222
245
|
: null;
|
|
223
246
|
const rawColumns = typeof rawGrouping?.columns === 'string' ? rawGrouping.columns : undefined;
|
|
224
|
-
const VALID_COLUMNS = new Set<string>([
|
|
247
|
+
const VALID_COLUMNS = new Set<string>([
|
|
248
|
+
'product', 'product-stage', 'product-stage-task',
|
|
249
|
+
'process', 'process-stage', 'process-stage-task',
|
|
250
|
+
]);
|
|
225
251
|
const normalizedColumns: ImpactGrouping['columns'] | undefined =
|
|
226
252
|
rawColumns === 'object-details' ? 'product-stage' // backward-compat alias
|
|
227
253
|
: rawColumns === 'object' ? 'product' // backward-compat alias
|
|
228
254
|
: VALID_COLUMNS.has(rawColumns ?? '') ? (rawColumns as ImpactGrouping['columns'])
|
|
229
255
|
: undefined;
|
|
230
256
|
|
|
257
|
+
const VALID_REPORT_TYPES = new Set(['product', 'process', 'combined']);
|
|
258
|
+
const rawReportType = typeof v.report_type === 'string' ? v.report_type : undefined;
|
|
259
|
+
|
|
231
260
|
const config: ImpactViewConfig = {
|
|
232
261
|
id: v.id as string,
|
|
233
262
|
name: v.name as string,
|
|
234
263
|
description: typeof v.description === 'string' ? v.description : undefined,
|
|
264
|
+
report_type: rawReportType && VALID_REPORT_TYPES.has(rawReportType)
|
|
265
|
+
? (rawReportType as ImpactViewConfig['report_type'])
|
|
266
|
+
: undefined,
|
|
235
267
|
snapshot_at: typeof v.snapshot_at === 'string' ? v.snapshot_at : undefined,
|
|
236
268
|
subjects: {
|
|
237
269
|
products: Array.isArray(subjects.products)
|
|
@@ -337,6 +369,8 @@ export interface ImpactMatrix {
|
|
|
337
369
|
* Empty array when no requirement in the column has a `derived_from`.
|
|
338
370
|
*/
|
|
339
371
|
obligationsLane: string[][];
|
|
372
|
+
/** Structural validation findings emitted during matrix build (COMPIMP-009, COMPIMP-010, etc.). */
|
|
373
|
+
findings: ImpactFinding[];
|
|
340
374
|
}
|
|
341
375
|
|
|
342
376
|
const DEFAULT_NO_OBLIGATION_LABEL = 'No mapped obligation (current model)';
|
|
@@ -432,18 +466,48 @@ function aggregateStatus(assertions: IndexAssertion[]): AssertionStatus {
|
|
|
432
466
|
|
|
433
467
|
// ── Column builder helpers (CV-3a) ──────────────────────────────────────────
|
|
434
468
|
|
|
435
|
-
function buildProductColumns(
|
|
469
|
+
function buildProductColumns(
|
|
470
|
+
config: ImpactViewConfig,
|
|
471
|
+
namedItems: { id: string; name: string }[],
|
|
472
|
+
isCombined: boolean,
|
|
473
|
+
): ImpactColumn[] {
|
|
436
474
|
const nameMap = new Map(namedItems.map(p => [p.id, p.name]));
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
475
|
+
const cols: ImpactColumn[] = [];
|
|
476
|
+
for (const id of (config.subjects.products ?? [])) {
|
|
477
|
+
cols.push({
|
|
478
|
+
subjectId: id,
|
|
479
|
+
label: isCombined ? `[PRODUCT] ${id}` : id,
|
|
480
|
+
subjectName: nameMap.get(id),
|
|
481
|
+
subjectType: 'product',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
for (const id of (config.subjects.processes ?? [])) {
|
|
485
|
+
cols.push({
|
|
486
|
+
subjectId: id,
|
|
487
|
+
label: isCombined ? `[PROCESS] ${id}` : id,
|
|
488
|
+
subjectName: nameMap.get(id),
|
|
489
|
+
subjectType: 'process',
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
for (const id of (config.subjects.capabilities ?? [])) {
|
|
493
|
+
cols.push({
|
|
494
|
+
subjectId: id,
|
|
495
|
+
label: id,
|
|
496
|
+
subjectName: nameMap.get(id),
|
|
497
|
+
subjectType: 'capability',
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return cols;
|
|
443
501
|
}
|
|
444
502
|
|
|
445
|
-
function buildObjectDetailColumns(
|
|
503
|
+
function buildObjectDetailColumns(
|
|
504
|
+
config: ImpactViewConfig,
|
|
505
|
+
objectDetails: ObjectDetailInput[],
|
|
506
|
+
isCombined: boolean,
|
|
507
|
+
): ImpactColumn[] {
|
|
446
508
|
const detailMap = new Map<string, ObjectDetailDef[]>(objectDetails.map(d => [d.objectId, d.details]));
|
|
509
|
+
const productSet = new Set(config.subjects.products ?? []);
|
|
510
|
+
const processSet = new Set(config.subjects.processes ?? []);
|
|
447
511
|
const subjects = [
|
|
448
512
|
...(config.subjects.products ?? []),
|
|
449
513
|
...(config.subjects.processes ?? []),
|
|
@@ -451,12 +515,15 @@ function buildObjectDetailColumns(config: ImpactViewConfig, objectDetails: Objec
|
|
|
451
515
|
];
|
|
452
516
|
const cols: ImpactColumn[] = [];
|
|
453
517
|
for (const subjectId of subjects) {
|
|
518
|
+
const subjectType: 'product' | 'process' | 'capability' =
|
|
519
|
+
productSet.has(subjectId) ? 'product' : processSet.has(subjectId) ? 'process' : 'capability';
|
|
520
|
+
const badge = isCombined && subjectType !== 'capability' ? `[${subjectType.toUpperCase()}] ` : '';
|
|
454
521
|
const details = detailMap.get(subjectId);
|
|
455
522
|
if (!details || details.length === 0) {
|
|
456
|
-
cols.push({ subjectId, label: subjectId });
|
|
523
|
+
cols.push({ subjectId, label: `${badge}${subjectId}`, subjectType });
|
|
457
524
|
} else {
|
|
458
525
|
for (const detail of details) {
|
|
459
|
-
cols.push({ subjectId, stageId: detail.id, label: `${subjectId}:${detail.id}
|
|
526
|
+
cols.push({ subjectId, stageId: detail.id, label: `${badge}${subjectId}:${detail.id}`, subjectType });
|
|
460
527
|
}
|
|
461
528
|
}
|
|
462
529
|
}
|
|
@@ -469,8 +536,14 @@ function buildObjectDetailColumns(config: ImpactViewConfig, objectDetails: Objec
|
|
|
469
536
|
* per-task columns `{ subjectId, stageId, taskId, label: subjectId:stageId:taskId }`.
|
|
470
537
|
* Stages with no tasks fall back to a single stage-grain column.
|
|
471
538
|
*/
|
|
472
|
-
function buildTaskColumns(
|
|
539
|
+
function buildTaskColumns(
|
|
540
|
+
config: ImpactViewConfig,
|
|
541
|
+
objectDetails: ObjectDetailInput[],
|
|
542
|
+
isCombined: boolean,
|
|
543
|
+
): ImpactColumn[] {
|
|
473
544
|
const detailMap = new Map<string, ObjectDetailDef[]>(objectDetails.map(d => [d.objectId, d.details]));
|
|
545
|
+
const productSet = new Set(config.subjects.products ?? []);
|
|
546
|
+
const processSet = new Set(config.subjects.processes ?? []);
|
|
474
547
|
const subjects = [
|
|
475
548
|
...(config.subjects.products ?? []),
|
|
476
549
|
...(config.subjects.processes ?? []),
|
|
@@ -478,21 +551,24 @@ function buildTaskColumns(config: ImpactViewConfig, objectDetails: ObjectDetailI
|
|
|
478
551
|
];
|
|
479
552
|
const cols: ImpactColumn[] = [];
|
|
480
553
|
for (const subjectId of subjects) {
|
|
554
|
+
const subjectType: 'product' | 'process' | 'capability' =
|
|
555
|
+
productSet.has(subjectId) ? 'product' : processSet.has(subjectId) ? 'process' : 'capability';
|
|
556
|
+
const badge = isCombined && subjectType !== 'capability' ? `[${subjectType.toUpperCase()}] ` : '';
|
|
481
557
|
const stages = detailMap.get(subjectId);
|
|
482
558
|
if (!stages || stages.length === 0) {
|
|
483
|
-
cols.push({ subjectId, label: subjectId });
|
|
559
|
+
cols.push({ subjectId, label: `${badge}${subjectId}`, subjectType });
|
|
484
560
|
} else {
|
|
485
561
|
for (const stage of stages) {
|
|
486
562
|
if (!stage.tasks || stage.tasks.length === 0) {
|
|
487
|
-
|
|
488
|
-
cols.push({ subjectId, stageId: stage.id, label: `${subjectId}:${stage.id}` });
|
|
563
|
+
cols.push({ subjectId, stageId: stage.id, label: `${badge}${subjectId}:${stage.id}`, subjectType });
|
|
489
564
|
} else {
|
|
490
565
|
for (const task of stage.tasks) {
|
|
491
566
|
cols.push({
|
|
492
567
|
subjectId,
|
|
493
568
|
stageId: stage.id,
|
|
494
569
|
taskId: task.id,
|
|
495
|
-
label: `${subjectId}:${stage.id}:${task.id}`,
|
|
570
|
+
label: `${badge}${subjectId}:${stage.id}:${task.id}`,
|
|
571
|
+
subjectType,
|
|
496
572
|
});
|
|
497
573
|
}
|
|
498
574
|
}
|
|
@@ -559,12 +635,53 @@ export function buildImpactMatrix(
|
|
|
559
635
|
|
|
560
636
|
const grain = config.grouping?.columns ?? 'product';
|
|
561
637
|
const hasDetails = objectDetails !== undefined && objectDetails.length > 0;
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
638
|
+
const hasProducts = (config.subjects.products?.length ?? 0) > 0;
|
|
639
|
+
const hasProcesses = (config.subjects.processes?.length ?? 0) > 0;
|
|
640
|
+
// Combined = both products and processes are explicit entry points (§7.1).
|
|
641
|
+
const isCombined = hasProducts && hasProcesses;
|
|
642
|
+
|
|
643
|
+
let columns: ImpactColumn[] =
|
|
644
|
+
(grain === 'product-stage-task' || grain === 'process-stage-task') && hasDetails
|
|
645
|
+
? buildTaskColumns(config, objectDetails!, isCombined)
|
|
646
|
+
: (grain === 'product-stage' || grain === 'process-stage' || (grain as string) === 'object-details') && hasDetails
|
|
647
|
+
? buildObjectDetailColumns(config, objectDetails!, isCombined)
|
|
648
|
+
: buildProductColumns(config, [...(canon.products ?? []), ...(canon.subjects ?? [])], isCombined);
|
|
649
|
+
|
|
650
|
+
// ── Structural validation (COMPIMP-009, COMPIMP-010, COMPIMP-011) ──────
|
|
651
|
+
const findings: ImpactFinding[] = [];
|
|
652
|
+
if (!hasProducts && hasProcesses &&
|
|
653
|
+
(grain === 'product' || grain === 'product-stage' || grain === 'product-stage-task')) {
|
|
654
|
+
findings.push({
|
|
655
|
+
code: 'COMPIMP-009',
|
|
656
|
+
severity: 'warning',
|
|
657
|
+
message: `view.grouping.columns is '${grain}' but view.subjects.products is absent and subjects.processes is the only entry point. Consider using '${grain.replace('product', 'process')}' for a process-centric view.`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (hasProcesses && (grain === 'product-stage' || grain === 'product-stage-task')) {
|
|
661
|
+
findings.push({
|
|
662
|
+
code: 'COMPIMP-010',
|
|
663
|
+
severity: 'error',
|
|
664
|
+
message: `view.grouping.columns is '${grain}' but view.subjects.processes has entries — product-stage semantics applied to process columns violate the subject type label invariant (§5.2). Use '${grain.replace('product', 'process')}' for process columns.`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// COMPIMP-011: report_type contract — strip cross-type columns and emit an
|
|
668
|
+
// error when the declared scope contradicts the subjects actually present.
|
|
669
|
+
if (config.report_type === 'product' && hasProcesses) {
|
|
670
|
+
findings.push({
|
|
671
|
+
code: 'COMPIMP-011',
|
|
672
|
+
severity: 'error',
|
|
673
|
+
message: `view.report_type is 'product' but view.subjects.processes has entries — process columns are excluded. Remove subjects.processes or set report_type to 'combined'.`,
|
|
674
|
+
});
|
|
675
|
+
columns = columns.filter(c => c.subjectType !== 'process');
|
|
676
|
+
}
|
|
677
|
+
if (config.report_type === 'process' && hasProducts) {
|
|
678
|
+
findings.push({
|
|
679
|
+
code: 'COMPIMP-011',
|
|
680
|
+
severity: 'error',
|
|
681
|
+
message: `view.report_type is 'process' but view.subjects.products has entries — product columns are excluded. Remove subjects.products or set report_type to 'combined'.`,
|
|
682
|
+
});
|
|
683
|
+
columns = columns.filter(c => c.subjectType !== 'product');
|
|
684
|
+
}
|
|
568
685
|
|
|
569
686
|
const allowedStatuses = new Set<AssertionStatus>(
|
|
570
687
|
config.status_display?.show ?? ['compliant', 'partial', 'non_compliant', 'under_review', 'pending_owner', 'n_a'],
|
|
@@ -653,6 +770,7 @@ export function buildImpactMatrix(
|
|
|
653
770
|
config.empty_cells?.no_obligation_applies_label ?? DEFAULT_NO_OBLIGATION_APPLIES_LABEL,
|
|
654
771
|
},
|
|
655
772
|
obligationsLane,
|
|
773
|
+
findings,
|
|
656
774
|
};
|
|
657
775
|
}
|
|
658
776
|
|
|
@@ -4,7 +4,7 @@ import type { CanonCatalog } from '../../typed-id.js';
|
|
|
4
4
|
|
|
5
5
|
function validExternal(): Record<string, unknown> {
|
|
6
6
|
return {
|
|
7
|
-
notation: '
|
|
7
|
+
notation: 'driver',
|
|
8
8
|
id: 'FACTOR-EU-REG-1',
|
|
9
9
|
name: 'EU regulatory window',
|
|
10
10
|
type: 'external',
|
|
@@ -22,7 +22,7 @@ function validExternal(): Record<string, unknown> {
|
|
|
22
22
|
|
|
23
23
|
function validInternal(): Record<string, unknown> {
|
|
24
24
|
return {
|
|
25
|
-
notation: '
|
|
25
|
+
notation: 'driver',
|
|
26
26
|
id: 'FACTOR-COMP-1',
|
|
27
27
|
name: 'Support response time',
|
|
28
28
|
type: 'internal',
|
|
@@ -71,7 +71,7 @@ describe('validateFactor — FACTOR-001 (shape / id grammar)', () => {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
it('flags a wrong notation tag and a non-canon zone', () => {
|
|
74
|
-
expect(codes({ ...validExternal(), notation: '
|
|
74
|
+
expect(codes({ ...validExternal(), notation: 'factor' })).toContain('FACTOR-001');
|
|
75
75
|
expect(codes({ ...validExternal(), zone: 'sandbox' })).toContain('FACTOR-001');
|
|
76
76
|
});
|
|
77
77
|
|
package/src/factor/types.ts
CHANGED
package/src/factor/validate.ts
CHANGED
|
@@ -39,8 +39,8 @@ export function validateFactor(input: unknown, options: FactorValidateOptions =
|
|
|
39
39
|
if (!isCanonicalIdOfType(f.id, 'FACTOR')) {
|
|
40
40
|
errors.push({ code: 'FACTOR-001', message: `id "${String(f.id)}" must match FACTOR-[<middle>-]<INTEGER>.`, path: 'id' });
|
|
41
41
|
}
|
|
42
|
-
if (f.notation !== '
|
|
43
|
-
errors.push({ code: 'FACTOR-001', message: 'notation must be the fixed value "
|
|
42
|
+
if (f.notation !== 'driver') {
|
|
43
|
+
errors.push({ code: 'FACTOR-001', message: 'notation must be the fixed value "driver".', path: 'notation' });
|
|
44
44
|
}
|
|
45
45
|
if (f.zone !== undefined && f.zone !== 'canon') {
|
|
46
46
|
errors.push({ code: 'FACTOR-001', message: 'zone must be "canon" for a FACTOR.', path: 'zone' });
|
|
@@ -36,7 +36,7 @@ describe('buildChainTable — worked example (FGCA)', () => {
|
|
|
36
36
|
|
|
37
37
|
it('produces the spec grid with F1 spanning 3 and G2/C2 spanning 2', () => {
|
|
38
38
|
const t = buildChainTable(doc);
|
|
39
|
-
expect(t.columns).toEqual<ChainColumn[]>(['
|
|
39
|
+
expect(t.columns).toEqual<ChainColumn[]>(['driver', 'goal', 'change', 'activity']);
|
|
40
40
|
expect(asGrid(t)).toEqual([
|
|
41
41
|
['F1×3', 'G1', 'C1', 'A1'],
|
|
42
42
|
['·', 'G2×2', 'C2×2', 'A2'],
|
|
@@ -67,7 +67,7 @@ describe('buildChainTable — FGA (no Change column)', () => {
|
|
|
67
67
|
|
|
68
68
|
it('emits three columns and merges Factor/Goal across the two activities', () => {
|
|
69
69
|
const t = buildChainTable(doc, { hideChanges: true });
|
|
70
|
-
expect(t.columns).toEqual<ChainColumn[]>(['
|
|
70
|
+
expect(t.columns).toEqual<ChainColumn[]>(['driver', 'goal', 'activity']);
|
|
71
71
|
expect(asGrid(t)).toEqual([
|
|
72
72
|
['F1×2', 'G1×2', 'A1'],
|
|
73
73
|
['·', '·', 'A2'],
|
|
@@ -9,12 +9,12 @@ beforeAll(() => {
|
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
import { buildFGCALayout } from "../layout";
|
|
12
|
-
import type {
|
|
12
|
+
import type { DriverItem, GoalItem, BdnChangeWithActivities, ActivityItem } from "../types";
|
|
13
13
|
import { ALL_FGCA_COLUMNS } from "../types";
|
|
14
14
|
|
|
15
15
|
const ALL_COLS = new Set(ALL_FGCA_COLUMNS);
|
|
16
16
|
|
|
17
|
-
const factors:
|
|
17
|
+
const factors: DriverItem[] = [
|
|
18
18
|
{ id: 1, name: "Digital growth" },
|
|
19
19
|
{ id: 2, name: "Talent shortage" },
|
|
20
20
|
];
|
|
@@ -50,16 +50,16 @@ describe("buildFGCALayout — all columns visible", () => {
|
|
|
50
50
|
|
|
51
51
|
it("assigns correct node types", () => {
|
|
52
52
|
const types = nodes.map((n) => n.type);
|
|
53
|
-
expect(types.filter((t) => t === "
|
|
53
|
+
expect(types.filter((t) => t === "fgcaDriver")).toHaveLength(factors.length);
|
|
54
54
|
expect(types.filter((t) => t === "fgcaGoal")).toHaveLength(goals.length);
|
|
55
55
|
expect(types.filter((t) => t === "fgcaChange")).toHaveLength(changes.length);
|
|
56
56
|
expect(types.filter((t) => t === "fgcaActivity")).toHaveLength(activities.length);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it("creates Factor→Goal edges", () => {
|
|
60
|
-
const fgEdges = edges.filter((e) => e.source.startsWith("
|
|
60
|
+
const fgEdges = edges.filter((e) => e.source.startsWith("driver_") && e.target.startsWith("goal_"));
|
|
61
61
|
expect(fgEdges).toHaveLength(2);
|
|
62
|
-
expect(fgEdges.find((e) => e.source === "
|
|
62
|
+
expect(fgEdges.find((e) => e.source === "driver_1" && e.target === "goal_10")).toBeTruthy();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
it("creates Goal→Change edges", () => {
|
|
@@ -85,7 +85,7 @@ describe("buildFGCALayout — all columns visible", () => {
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
it("encodes F-XXXX label format via id in node data", () => {
|
|
88
|
-
const factorNode = nodes.find((n) => n.id === "
|
|
88
|
+
const factorNode = nodes.find((n) => n.id === "driver_1");
|
|
89
89
|
expect(String(factorNode?.data.id).padStart(4, "0")).toBe("0001");
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -95,16 +95,16 @@ describe("buildFGCALayout — all columns visible", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
describe("buildFGCALayout —
|
|
99
|
-
const cols = new Set(ALL_FGCA_COLUMNS.filter((c) => c !== "
|
|
98
|
+
describe("buildFGCALayout — Driver column hidden", () => {
|
|
99
|
+
const cols = new Set(ALL_FGCA_COLUMNS.filter((c) => c !== "driver"));
|
|
100
100
|
const { nodes, edges } = buildFGCALayout({ factors, goals, changes, activities, visibleColumns: cols });
|
|
101
101
|
|
|
102
|
-
it("produces no
|
|
103
|
-
expect(nodes.filter((n) => n.type === "
|
|
102
|
+
it("produces no driver nodes", () => {
|
|
103
|
+
expect(nodes.filter((n) => n.type === "fgcaDriver")).toHaveLength(0);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("produces no
|
|
107
|
-
expect(edges.filter((e) => e.source.startsWith("
|
|
106
|
+
it("produces no Driver→Goal edges", () => {
|
|
107
|
+
expect(edges.filter((e) => e.source.startsWith("driver_"))).toHaveLength(0);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it("still produces Goal and downstream nodes", () => {
|
|
@@ -150,7 +150,7 @@ describe("buildFGCALayout — columns are positioned left to right", () => {
|
|
|
150
150
|
const { nodes } = buildFGCALayout({ factors, goals, changes, activities, visibleColumns: ALL_COLS });
|
|
151
151
|
|
|
152
152
|
it("factor column x < goal column x", () => {
|
|
153
|
-
const f = nodes.find((n) => n.type === "
|
|
153
|
+
const f = nodes.find((n) => n.type === "fgcaDriver")!;
|
|
154
154
|
const g = nodes.find((n) => n.type === "fgcaGoal")!;
|
|
155
155
|
expect(f.position.x).toBeLessThan(g.position.x);
|
|
156
156
|
});
|