@sun-asterisk/sungen 3.2.16-beta.3 → 3.2.16-beta.4
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/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +16 -5
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +6 -0
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +24 -6
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +5 -4
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +15 -6
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/gates.d.ts.map +1 -1
- package/dist/exporters/matrix/gates.js +20 -0
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
- package/dist/exporters/matrix/render-csv.js +10 -5
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +63 -24
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +3 -1
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/matrix/wording.d.ts +6 -0
- package/dist/exporters/matrix/wording.d.ts.map +1 -1
- package/dist/exporters/matrix/wording.js +22 -3
- package/dist/exporters/matrix/wording.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +11 -6
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +25 -8
- package/package.json +3 -3
- package/src/cli/commands/delivery.ts +19 -6
- package/src/exporters/matrix/build.ts +23 -7
- package/src/exporters/matrix/export.ts +21 -6
- package/src/exporters/matrix/gates.ts +20 -0
- package/src/exporters/matrix/render-csv.ts +10 -5
- package/src/exporters/matrix/render-xlsx.ts +63 -24
- package/src/exporters/matrix/types.ts +3 -1
- package/src/exporters/matrix/wording.ts +20 -3
- package/src/orchestrator/templates/ai-src/commands/delivery.md +11 -6
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +25 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sun-asterisk/sungen",
|
|
3
|
-
"version": "3.2.16-beta.
|
|
3
|
+
"version": "3.2.16-beta.4",
|
|
4
4
|
"description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@babel/types": "^7.28.5",
|
|
40
40
|
"@cucumber/gherkin": "^37.0.0",
|
|
41
41
|
"@cucumber/messages": "^31.0.0",
|
|
42
|
-
"@sungen/driver-data-factory": "3.2.16-beta.
|
|
43
|
-
"@sungen/driver-ui": "3.2.16-beta.
|
|
42
|
+
"@sungen/driver-data-factory": "3.2.16-beta.4",
|
|
43
|
+
"@sungen/driver-ui": "3.2.16-beta.4",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.2",
|
|
46
46
|
"dotenv": "^17.2.3",
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
loadMatrixModel,
|
|
32
32
|
approveMatrix,
|
|
33
33
|
writeMatrixDeliverables,
|
|
34
|
+
MatrixFormat,
|
|
34
35
|
hasErrors,
|
|
35
36
|
hasReview,
|
|
36
37
|
} from '../../exporters/matrix/export';
|
|
@@ -651,7 +652,7 @@ interface MatrixRunSummary {
|
|
|
651
652
|
async function exportMatrixTarget(
|
|
652
653
|
cwd: string,
|
|
653
654
|
target: DeliveryTarget,
|
|
654
|
-
opts: { check?: boolean; preview?: boolean },
|
|
655
|
+
opts: { check?: boolean; preview?: boolean; format?: MatrixFormat },
|
|
655
656
|
): Promise<MatrixRunSummary | null> {
|
|
656
657
|
const paths = matrixPathsFor(cwd, target);
|
|
657
658
|
const { model, mapMissing, mapErrors } = loadMatrixModel(paths);
|
|
@@ -689,24 +690,25 @@ async function exportMatrixTarget(
|
|
|
689
690
|
return null;
|
|
690
691
|
}
|
|
691
692
|
|
|
692
|
-
const { csvPath, xlsxPath } = await writeMatrixDeliverables(paths, model);
|
|
693
|
+
const { csvPath, xlsxPath } = await writeMatrixDeliverables(paths, model, opts.format ?? 'xlsx');
|
|
693
694
|
const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
|
|
694
695
|
const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
|
|
695
|
-
|
|
696
|
+
const written = [xlsxPath, csvPath].filter((p): p is string => !!p).map((p) => path.relative(cwd, p));
|
|
697
|
+
for (const rel of written) log(` ${COLOR.green}→ ${rel}${COLOR.reset}`);
|
|
696
698
|
return {
|
|
697
699
|
label: paths.label,
|
|
698
700
|
items: model.manifest.itemCount,
|
|
699
701
|
variants: model.manifest.variantCount,
|
|
700
702
|
passed,
|
|
701
703
|
failed,
|
|
702
|
-
file:
|
|
704
|
+
file: written.join(' + '),
|
|
703
705
|
};
|
|
704
706
|
}
|
|
705
707
|
|
|
706
708
|
function printMatrixSummaryTable(summaries: MatrixRunSummary[]): void {
|
|
707
709
|
log(`\n${COLOR.bold}Delivery matrix export complete${COLOR.reset}\n`);
|
|
708
710
|
const colWidth = Math.max(20, ...summaries.map((s) => s.label.length)) + 1;
|
|
709
|
-
log(` ${'Feature'.padEnd(colWidth)}Items Variants Passed Failed File`);
|
|
711
|
+
log(` ${'Feature'.padEnd(colWidth)}Items Variants Passed Failed File(s)`);
|
|
710
712
|
log(' ' + '-'.repeat(colWidth + 50));
|
|
711
713
|
for (const s of summaries) {
|
|
712
714
|
log(
|
|
@@ -796,9 +798,11 @@ export function registerDeliveryCommand(program: Command): void {
|
|
|
796
798
|
.option('--check', 'Matrix gates only — validate the delivery map, write nothing')
|
|
797
799
|
.option('--approve [ids]', 'Approve proposed groups (all, or a comma-separated id list) + stamp fingerprints')
|
|
798
800
|
.option('--preview', 'Render the matrix despite review-required findings (DRAFT watermark)')
|
|
801
|
+
.option('--format <format>', 'Matrix output format: xlsx (default) | csv | both', 'xlsx')
|
|
799
802
|
.action(async (names: string[], options: {
|
|
800
803
|
skipPreflight?: boolean; continueOnMissing?: boolean; env?: string;
|
|
801
804
|
legacy?: boolean; full?: boolean; check?: boolean; approve?: boolean | string; preview?: boolean;
|
|
805
|
+
format?: string;
|
|
802
806
|
}) => {
|
|
803
807
|
try {
|
|
804
808
|
// Same effect as running with SUNGEN_ENV=<env> — result-file resolution,
|
|
@@ -806,6 +810,13 @@ export function registerDeliveryCommand(program: Command): void {
|
|
|
806
810
|
if (options.env) process.env.SUNGEN_ENV = options.env;
|
|
807
811
|
const cwd = process.cwd();
|
|
808
812
|
|
|
813
|
+
// Matrix output format — one artifact by default (xlsx); csv/both on request.
|
|
814
|
+
const format = (options.format ?? 'xlsx').toLowerCase();
|
|
815
|
+
if (!['xlsx', 'csv', 'both'].includes(format)) {
|
|
816
|
+
console.error(`${COLOR.red}Invalid --format "${options.format}"${COLOR.reset} — use xlsx | csv | both`);
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
|
|
809
820
|
// 1. Scope detection — each positional name expands into one target
|
|
810
821
|
// per `.feature` file inside that screen/flow. Passing a specific
|
|
811
822
|
// feature basename (e.g. `home-modal`) narrows to that one file.
|
|
@@ -886,7 +897,9 @@ export function registerDeliveryCommand(program: Command): void {
|
|
|
886
897
|
const matrixSummaries: MatrixRunSummary[] = [];
|
|
887
898
|
let failedTargets = 0;
|
|
888
899
|
for (const target of toExport) {
|
|
889
|
-
const s = await exportMatrixTarget(cwd, target, {
|
|
900
|
+
const s = await exportMatrixTarget(cwd, target, {
|
|
901
|
+
check: options.check, preview: options.preview, format: format as MatrixFormat,
|
|
902
|
+
});
|
|
890
903
|
if (s) matrixSummaries.push(s);
|
|
891
904
|
else failedTargets++;
|
|
892
905
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
splitVpAndName,
|
|
15
15
|
} from '../feature-parser';
|
|
16
16
|
import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
|
|
17
|
-
import { classifyManualComments, renderAction, renderExpected, renderPrecondition } from './wording';
|
|
17
|
+
import { classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction } from './wording';
|
|
18
18
|
import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
|
|
19
19
|
import {
|
|
20
20
|
CoverageVariant,
|
|
@@ -72,12 +72,12 @@ function resolveDataPairs(
|
|
|
72
72
|
if (row) {
|
|
73
73
|
for (const [k, v] of Object.entries(row)) {
|
|
74
74
|
if (k.startsWith('__') || k === 'case' || k === 'name' || k === 'label') continue;
|
|
75
|
-
pairs.push(`${k}: ${String(v)}`);
|
|
75
|
+
pairs.push(`${k}: ${displayValue(String(v))}`);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
for (const v of vars) {
|
|
79
79
|
const val = row && v in row ? undefined : testData?.[v]; // row columns already listed
|
|
80
|
-
if (val !== undefined) pairs.push(`${v}: ${val}`);
|
|
80
|
+
if (val !== undefined) pairs.push(`${v}: ${displayValue(val)}`);
|
|
81
81
|
}
|
|
82
82
|
return pairs;
|
|
83
83
|
}
|
|
@@ -141,7 +141,7 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
|
|
|
141
141
|
...(authRole ? [authRole === 'no-auth' ? 'The user is signed out.' : `The user is signed in as ${authRole}.`] : []),
|
|
142
142
|
...inputs.feature.backgroundGivenSteps.map(renderPrecondition),
|
|
143
143
|
...m.feature.rawGivenSteps.map(renderPrecondition),
|
|
144
|
-
...(manual?.preconditions ?? []).map((t) =>
|
|
144
|
+
...(manual?.preconditions ?? []).map((t) => renderSetupInstruction(t)),
|
|
145
145
|
]));
|
|
146
146
|
|
|
147
147
|
const base = {
|
|
@@ -247,11 +247,22 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
|
|
|
247
247
|
function substituteDisplayVars(text: string, vars: Record<string, string>): string {
|
|
248
248
|
return text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string) => {
|
|
249
249
|
if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
|
|
250
|
-
|
|
251
|
-
return v === '' ? '(empty)' : v;
|
|
250
|
+
return displayValue(vars[key]);
|
|
252
251
|
});
|
|
253
252
|
}
|
|
254
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Make invisible test data VISIBLE instead of losing it (review GAP-04):
|
|
256
|
+
* '' → (empty) · whitespace-only → (N spaces) · padded → quoted verbatim.
|
|
257
|
+
* The canonical value is never changed — only its presentation.
|
|
258
|
+
*/
|
|
259
|
+
export function displayValue(v: string): string {
|
|
260
|
+
if (v === '') return '(empty)';
|
|
261
|
+
if (/^\s+$/.test(v)) return `(${v.length} space${v.length > 1 ? 's' : ''})`;
|
|
262
|
+
if (v !== v.trim()) return `"${v}"`;
|
|
263
|
+
return v;
|
|
264
|
+
}
|
|
265
|
+
|
|
255
266
|
function sentenceOf(text: string): string {
|
|
256
267
|
const s = text.trim();
|
|
257
268
|
if (!s) return s;
|
|
@@ -386,7 +397,12 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
|
|
|
386
397
|
mode: modes.size > 1 ? 'mixed' : (first?.mode ?? 'auto'),
|
|
387
398
|
layers: Array.from(new Set(groupVariants.flatMap((v) => v.layers))),
|
|
388
399
|
traces: Array.from(new Set(groupVariants.flatMap((v) => v.traces))),
|
|
389
|
-
|
|
400
|
+
// Only the preconditions COMMON to every variant belong on the parent —
|
|
401
|
+
// copying the first variant's setup mis-states the start state of the
|
|
402
|
+
// others (review GAP-03). Variant-specific lines render as the sub-row's
|
|
403
|
+
// precondition delta.
|
|
404
|
+
precondition: (first?.precondition ?? []).filter((line) =>
|
|
405
|
+
groupVariants.every((v) => v.precondition.includes(line))),
|
|
390
406
|
trigger: triggerShapes.size === 1 ? (first?.trigger ?? []) : ['(per variant)'],
|
|
391
407
|
variants: groupVariants,
|
|
392
408
|
result,
|
|
@@ -17,6 +17,7 @@ import { getPackageVersion } from '../package-info';
|
|
|
17
17
|
import { writeCsv } from '../csv-exporter';
|
|
18
18
|
import { writeXlsx } from '../xlsx-exporter';
|
|
19
19
|
import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
|
|
20
|
+
import { mapContentFingerprint } from './fingerprint';
|
|
20
21
|
import { buildMatrix, deriveVariants } from './build';
|
|
21
22
|
import { renderMatrixXlsx } from './render-xlsx';
|
|
22
23
|
import { renderMatrixCsv } from './render-csv';
|
|
@@ -121,14 +122,28 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
|
|
|
121
122
|
g.review = 'approved';
|
|
122
123
|
approved.push(g.id);
|
|
123
124
|
}
|
|
125
|
+
// Freeze the reviewed map semantics as well (GAP-09) — computed AFTER the
|
|
126
|
+
// review flips so re-running approve on an unchanged map is idempotent.
|
|
127
|
+
map.fingerprints.__map__ = mapContentFingerprint(map.groups, map.dispositions);
|
|
124
128
|
writeDeliveryMap(paths.mapFile, map);
|
|
125
129
|
return { findings: model.findings.filter((f) => f.severity !== 'error'), approved };
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
export type MatrixFormat = 'xlsx' | 'csv' | 'both';
|
|
133
|
+
|
|
134
|
+
/** Render + write the requested format(s). Caller has already enforced the gate policy. */
|
|
135
|
+
export async function writeMatrixDeliverables(
|
|
136
|
+
paths: MatrixTargetPaths,
|
|
137
|
+
model: MatrixModel,
|
|
138
|
+
format: MatrixFormat = 'xlsx',
|
|
139
|
+
): Promise<{ csvPath?: string; xlsxPath?: string }> {
|
|
140
|
+
const out: { csvPath?: string; xlsxPath?: string } = {};
|
|
141
|
+
if (format === 'csv' || format === 'both') {
|
|
142
|
+
out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
|
|
143
|
+
}
|
|
144
|
+
if (format === 'xlsx' || format === 'both') {
|
|
145
|
+
const wb = renderMatrixXlsx(model, getPackageVersion());
|
|
146
|
+
out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
134
149
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { splitVpAndName, extractTestcaseType } from '../feature-parser';
|
|
15
|
+
import { mapContentFingerprint } from './fingerprint';
|
|
15
16
|
import { CoverageVariant, MatrixFinding, MAX_VARIANTS_PER_ITEM } from './types';
|
|
16
17
|
import { expandMapRef, BuildInputs } from './build';
|
|
17
18
|
|
|
@@ -266,6 +267,25 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
|
|
|
266
267
|
const { map } = ctx.inputs;
|
|
267
268
|
const { variantsByVp } = ctx;
|
|
268
269
|
|
|
270
|
+
// The map's own semantics (targets/intents/oracles/grouping) are part of what
|
|
271
|
+
// was approved — an edit after approval must be re-reviewed, not silently
|
|
272
|
+
// published (review GAP-09).
|
|
273
|
+
const stampedMap = map.fingerprints.__map__;
|
|
274
|
+
const liveMap = mapContentFingerprint(map.groups, map.dispositions);
|
|
275
|
+
if (map.groups.some((g) => g.review === 'approved')) {
|
|
276
|
+
if (!stampedMap) {
|
|
277
|
+
findings.push({
|
|
278
|
+
gate: 'E', severity: 'review',
|
|
279
|
+
message: 'the delivery map has no stamped content fingerprint — run `sungen delivery --approve` to record the reviewed wording',
|
|
280
|
+
});
|
|
281
|
+
} else if (stampedMap !== liveMap) {
|
|
282
|
+
findings.push({
|
|
283
|
+
gate: 'E', severity: 'review',
|
|
284
|
+
message: 'the delivery map wording/grouping changed since approval — re-review, then `sungen delivery --approve`',
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
269
289
|
for (const g of map.groups) {
|
|
270
290
|
if (g.review !== 'approved') continue; // proposed groups are already under review
|
|
271
291
|
for (const ref of g.variants) {
|
|
@@ -12,7 +12,7 @@ import { MatrixModel } from './types';
|
|
|
12
12
|
const HEADERS = [
|
|
13
13
|
'Level', 'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority',
|
|
14
14
|
'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected Result',
|
|
15
|
-
'Mode', 'Trace', 'Result', 'Executed Date', 'Note',
|
|
15
|
+
'Mode', 'Trace', 'Result', 'Executed Date', 'Note / Evidence', 'Defect ID',
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
function esc(value: string): string {
|
|
@@ -36,7 +36,8 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
36
36
|
|
|
37
37
|
for (const item of model.items) {
|
|
38
38
|
const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
|
|
39
|
-
const
|
|
39
|
+
const triggerIsCommon = triggerTexts.size === 1;
|
|
40
|
+
const commonPre = new Set(item.precondition);
|
|
40
41
|
lines.push([
|
|
41
42
|
'item',
|
|
42
43
|
item.id,
|
|
@@ -46,13 +47,15 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
46
47
|
item.priority,
|
|
47
48
|
item.precondition.join('\n'),
|
|
48
49
|
`${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`,
|
|
49
|
-
|
|
50
|
+
// No placeholder text: when triggers differ the variant rows carry them (GAP-01).
|
|
51
|
+
triggerIsCommon ? item.variants[0].trigger.join('\n') : '',
|
|
50
52
|
item.oracle,
|
|
51
53
|
itemModeLabel(item),
|
|
52
54
|
item.traces.join(' '),
|
|
53
55
|
itemResultLabel(item),
|
|
54
56
|
'',
|
|
55
57
|
item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
|
|
58
|
+
'',
|
|
56
59
|
].map(esc).join(','));
|
|
57
60
|
|
|
58
61
|
for (const v of item.variants) {
|
|
@@ -65,15 +68,17 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
65
68
|
'',
|
|
66
69
|
v.condition,
|
|
67
70
|
'', '',
|
|
68
|
-
|
|
71
|
+
// Precondition DELTA — variant-specific setup only (review GAP-03).
|
|
72
|
+
v.precondition.filter((line) => !commonPre.has(line)).join('\n'),
|
|
69
73
|
v.data.join('\n'),
|
|
70
|
-
v.trigger.join('\n'),
|
|
74
|
+
triggerIsCommon ? '' : v.trigger.join('\n'),
|
|
71
75
|
expected,
|
|
72
76
|
v.mode === 'manual' ? 'Manual' : 'Auto',
|
|
73
77
|
v.traces.join(' '),
|
|
74
78
|
v.result ? statusToTestResult(v.result.status) : 'Pending',
|
|
75
79
|
isoDate(v.result?.startTime),
|
|
76
80
|
v.result?.error ? String(v.result.error).slice(0, 200) : '',
|
|
81
|
+
'',
|
|
77
82
|
].map(esc).join(','));
|
|
78
83
|
}
|
|
79
84
|
}
|
|
@@ -120,7 +120,7 @@ function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
|
|
|
120
120
|
const MATRIX_HEADERS = [
|
|
121
121
|
'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
|
|
122
122
|
'Coverage / Test Data', 'Action / Trigger', 'Expected Result', 'Mode', 'Trace',
|
|
123
|
-
'Result', 'Executed Date', 'Executor', 'Note
|
|
123
|
+
'Result', 'Executed Date', 'Executor', 'Note / Evidence', 'Defect ID',
|
|
124
124
|
];
|
|
125
125
|
const RESULT_COL = 13; // column M
|
|
126
126
|
|
|
@@ -144,25 +144,35 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
144
144
|
{ width: 22 }, // M Result
|
|
145
145
|
{ width: 12 }, // N Executed
|
|
146
146
|
{ width: 13 }, // O Executor
|
|
147
|
-
{ width:
|
|
147
|
+
{ width: 22 }, // P Note / Evidence
|
|
148
|
+
{ width: 12 }, // Q Defect ID
|
|
148
149
|
];
|
|
149
150
|
|
|
150
151
|
renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} TEST CASE & COVERAGE MATRIX`, sungenVersion, model.formNo);
|
|
151
152
|
draftBanner(ws, model);
|
|
152
153
|
|
|
153
|
-
// Summary band (row 6) — items vs variants stay separate metrics by design (I8)
|
|
154
|
-
//
|
|
154
|
+
// Summary band (row 6) — items vs variants stay separate metrics by design (I8).
|
|
155
|
+
// The execution counters are LIVE formulas over the variant Result cells, so
|
|
156
|
+
// hand-entered results update the totals (review GAP-06). Variant cells hold the
|
|
157
|
+
// exact state words; parent cells hold composed labels — exact-match COUNTIF
|
|
158
|
+
// therefore counts variants only.
|
|
155
159
|
const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
|
|
156
160
|
const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
|
|
157
161
|
const notRun = model.items.reduce((a, i) => a + i.resultCounts.notRun + i.resultCounts.blocked, 0);
|
|
162
|
+
const RANGE = 'M9:M10000';
|
|
158
163
|
const band = ws.getRow(6);
|
|
159
|
-
[
|
|
160
|
-
`Delivery items: ${model.manifest.itemCount}
|
|
161
|
-
`Coverage variants: ${model.manifest.variantCount}
|
|
162
|
-
`Passed: ${
|
|
163
|
-
|
|
164
|
+
const bandCells: Array<[string, ExcelJS.CellValue]> = [
|
|
165
|
+
['items', `Delivery items: ${model.manifest.itemCount}`],
|
|
166
|
+
['variants', `Coverage variants: ${model.manifest.variantCount}`],
|
|
167
|
+
['passed', { formula: `"Passed: "&COUNTIF(${RANGE},"Passed")`, result: `Passed: ${passed}` } as ExcelJS.CellValue],
|
|
168
|
+
['failed', { formula: `"Failed: "&COUNTIF(${RANGE},"Failed")`, result: `Failed: ${failed}` } as ExcelJS.CellValue],
|
|
169
|
+
['blocked', { formula: `"Blocked: "&COUNTIF(${RANGE},"Blocked")`, result: 'Blocked: 0' } as ExcelJS.CellValue],
|
|
170
|
+
['pending', { formula: `"Pending: "&COUNTIF(${RANGE},"Pending")`, result: `Pending: ${notRun}` } as ExcelJS.CellValue],
|
|
171
|
+
['na', { formula: `"N/A: "&COUNTIF(${RANGE},"N/A")`, result: 'N/A: 0' } as ExcelJS.CellValue],
|
|
172
|
+
];
|
|
173
|
+
bandCells.forEach(([, v], i) => {
|
|
164
174
|
const c = band.getCell(2 + i);
|
|
165
|
-
dataCell(c,
|
|
175
|
+
dataCell(c, v, { bold: true, fill: SOFT_BLUE, center: true });
|
|
166
176
|
});
|
|
167
177
|
|
|
168
178
|
const HEADER_ROW = 8;
|
|
@@ -178,16 +188,25 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
178
188
|
// Every item gets sub-rows — the sub-row is where the source scenario id, the
|
|
179
189
|
// resolved data, and the manual result/evidence entry live (review B-05).
|
|
180
190
|
const firstChild = rowIdx;
|
|
191
|
+
// Delta-only sub-rows (review GAP-01): content identical across the whole
|
|
192
|
+
// item lives ONCE on the parent — a child repeats only what distinguishes it.
|
|
193
|
+
// The child's Expected Result is always kept: it is the precise source
|
|
194
|
+
// oracle (the semantic anchor), not a repetition of the parent's summary.
|
|
195
|
+
const commonPre = new Set(item.precondition);
|
|
196
|
+
const triggerTextsAll = new Set(item.variants.map((v) => v.trigger.join('\n')));
|
|
197
|
+
const triggerIsCommon = triggerTextsAll.size === 1;
|
|
181
198
|
for (const v of item.variants) {
|
|
182
199
|
const vr = ws.getRow(rowIdx++);
|
|
183
200
|
vr.outlineLevel = 1;
|
|
201
|
+
const preDelta = v.precondition.filter((line) => !commonPre.has(line));
|
|
184
202
|
const vValues: ExcelJS.CellValue[] = [
|
|
185
203
|
v.ref,
|
|
186
204
|
'',
|
|
187
205
|
v.condition,
|
|
188
|
-
'', '',
|
|
206
|
+
'', '',
|
|
207
|
+
numbered(preDelta),
|
|
189
208
|
numbered(v.data),
|
|
190
|
-
numbered(v.trigger),
|
|
209
|
+
triggerIsCommon ? '' : numbered(v.trigger),
|
|
191
210
|
expectedWithVerification(v.oracle, v.verification),
|
|
192
211
|
v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
|
|
193
212
|
v.traces.join(', '),
|
|
@@ -195,6 +214,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
195
214
|
isoDate(v.result?.startTime),
|
|
196
215
|
'',
|
|
197
216
|
v.result?.error ? String(v.result.error).slice(0, 200) : '',
|
|
217
|
+
'',
|
|
198
218
|
];
|
|
199
219
|
vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
|
|
200
220
|
// Manual-execution entry: constrain the Result cell to the known states.
|
|
@@ -207,19 +227,21 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
207
227
|
// --- Parent row: only values that are genuinely COMMON to the whole item
|
|
208
228
|
// (review M-04); everything variant-specific stays on the sub-rows.
|
|
209
229
|
const pr = ws.getRow(parentRowIdx);
|
|
210
|
-
const
|
|
211
|
-
const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger : null;
|
|
230
|
+
const commonTrigger = triggerIsCommon ? item.variants[0].trigger : null;
|
|
212
231
|
const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
|
|
213
232
|
|
|
214
|
-
// Live roll-up
|
|
215
|
-
//
|
|
233
|
+
// Live roll-up derived from the child Result cells — precedence
|
|
234
|
+
// failed → blocked → pending → partial → passed. N/A variants leave the
|
|
235
|
+
// denominator (review GAP-06): eff = n − COUNTIF(N/A); all-N/A → "N/A".
|
|
216
236
|
const rng = `M${firstChild}:M${lastChild}`;
|
|
217
237
|
const n = item.variants.length;
|
|
238
|
+
const EFF = `(${n}-COUNTIF(${rng},"N/A"))`;
|
|
218
239
|
const rollUpFormula =
|
|
219
|
-
`IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"
|
|
220
|
-
`IF(COUNTIF(${rng},"Blocked")>0,"Blocked",` +
|
|
221
|
-
`IF(
|
|
222
|
-
`IF(COUNTIF(${rng},"Passed")
|
|
240
|
+
`IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Failed")&" Failed",` +
|
|
241
|
+
`IF(COUNTIF(${rng},"Blocked")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Blocked")&" Blocked",` +
|
|
242
|
+
`IF(${EFF}=0,"N/A",` +
|
|
243
|
+
`IF(COUNTIF(${rng},"Passed")=${EFF},${EFF}&"/"&${EFF}&" Passed",` +
|
|
244
|
+
`IF(COUNTIF(${rng},"Passed")=0,"Pending",COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · pending")))))`;
|
|
223
245
|
|
|
224
246
|
const values: ExcelJS.CellValue[] = [
|
|
225
247
|
item.id,
|
|
@@ -229,7 +251,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
229
251
|
item.priority,
|
|
230
252
|
numbered(item.precondition),
|
|
231
253
|
coverage,
|
|
232
|
-
|
|
254
|
+
// No placeholder text: when triggers differ the sub-rows carry them (GAP-01).
|
|
255
|
+
commonTrigger ? numbered(commonTrigger) : '',
|
|
233
256
|
item.oracle,
|
|
234
257
|
itemModeLabel(item),
|
|
235
258
|
item.traces.join(', '),
|
|
@@ -237,6 +260,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
237
260
|
'',
|
|
238
261
|
'',
|
|
239
262
|
item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
|
|
263
|
+
'',
|
|
240
264
|
];
|
|
241
265
|
values.forEach((v, i) => dataCell(pr.getCell(2 + i), v, {
|
|
242
266
|
bold: true,
|
|
@@ -246,9 +270,18 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
246
270
|
}));
|
|
247
271
|
}
|
|
248
272
|
|
|
249
|
-
ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column:
|
|
273
|
+
ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 17 } };
|
|
250
274
|
// Freeze the header band AND the ID + Target columns (review §9).
|
|
251
275
|
ws.views = [{ state: 'frozen', xSplit: 3, ySplit: HEADER_ROW }];
|
|
276
|
+
// Print: landscape, fit width only (never squash all rows into one page),
|
|
277
|
+
// repeat the masthead + header rows on every page (review GAP-08).
|
|
278
|
+
ws.pageSetup = {
|
|
279
|
+
orientation: 'landscape',
|
|
280
|
+
fitToPage: true,
|
|
281
|
+
fitToWidth: 1,
|
|
282
|
+
fitToHeight: 0,
|
|
283
|
+
printTitlesRow: '1:8',
|
|
284
|
+
};
|
|
252
285
|
}
|
|
253
286
|
|
|
254
287
|
// ---------------------------------------------------------------------------
|
|
@@ -270,8 +303,14 @@ function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersio
|
|
|
270
303
|
if (model.requirements.length > 0) {
|
|
271
304
|
dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
|
|
272
305
|
rowIdx += 1;
|
|
273
|
-
const rh = ws.getRow(rowIdx
|
|
274
|
-
|
|
306
|
+
const rh = ws.getRow(rowIdx);
|
|
307
|
+
ws.mergeCells(rowIdx, 4, rowIdx, 6);
|
|
308
|
+
headerCell(rh.getCell(2), 'Requirement');
|
|
309
|
+
headerCell(rh.getCell(3), 'Status');
|
|
310
|
+
headerCell(rh.getCell(4), 'Delivery items');
|
|
311
|
+
headerCell(rh.getCell(7), 'Variants');
|
|
312
|
+
headerCell(rh.getCell(8), 'Note');
|
|
313
|
+
rowIdx += 1;
|
|
275
314
|
for (const r of model.requirements) {
|
|
276
315
|
const row = ws.getRow(rowIdx++);
|
|
277
316
|
dataCell(row.getCell(2), r.id, { bold: true });
|
|
@@ -57,7 +57,9 @@ export interface DeliveryMap {
|
|
|
57
57
|
/** Requirement-id → reviewed status override (partially_covered / not_applicable / …).
|
|
58
58
|
* Without an override a requirement is `covered` when a variant traces to it, else `gap`. */
|
|
59
59
|
requirements: Record<string, RequirementOverride>;
|
|
60
|
-
/** VP-id → scenario fingerprint stamped at approval time (drift detector input).
|
|
60
|
+
/** VP-id → scenario fingerprint stamped at approval time (drift detector input).
|
|
61
|
+
* The reserved key `__map__` holds the fingerprint of the map's own semantic
|
|
62
|
+
* content (groups + dispositions) so edited wording re-opens review (GAP-09). */
|
|
61
63
|
fingerprints: Record<string, string>;
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -19,10 +19,13 @@ function deRef(text: string): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function sentence(text: string): string {
|
|
22
|
-
|
|
22
|
+
// Trim the ENDS only — internal whitespace may be the test data itself
|
|
23
|
+
// (a padded email, a spaces-only value); collapsing it would silently
|
|
24
|
+
// change what the tester types (review GAP-04).
|
|
25
|
+
let s = text.trim();
|
|
23
26
|
if (!s) return s;
|
|
24
27
|
s = s.charAt(0).toUpperCase() + s.slice(1);
|
|
25
|
-
if (!/[.!?…]$/.test(s)) s += '.';
|
|
28
|
+
if (!/[.!?…"]$/.test(s)) s += '.';
|
|
26
29
|
return s;
|
|
27
30
|
}
|
|
28
31
|
|
|
@@ -42,7 +45,7 @@ export function renderAction(raw: string): string {
|
|
|
42
45
|
// click [X] <type>
|
|
43
46
|
[/^clicks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Click the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
|
|
44
47
|
// press <Key> at/in [X] field
|
|
45
|
-
[/^press(?:es)? (.+?) (?:at|in|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
|
|
48
|
+
[/^press(?:es)? (.+?) (?:at|in|on|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
|
|
46
49
|
[/^press(?:es)? (.+)$/i, (m) => `Press ${m[1]}`],
|
|
47
50
|
// select V in/from [X] dropdown
|
|
48
51
|
[/^selects? (.+?) (?:in|from) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Select ${m[1]} in the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
|
|
@@ -103,9 +106,23 @@ export function renderPrecondition(raw: string): string {
|
|
|
103
106
|
const s = raw.trim().replace(/^(User|The user)\s+/i, '');
|
|
104
107
|
const m = s.match(/^is on \[([^\]]+)\] page(.*)$/i);
|
|
105
108
|
if (m) return sentence(`The user is on the ${m[1]} page${m[2] ?? ''}`);
|
|
109
|
+
// "wait for [X] <type> (is) visible" is a STATE once established, not an action.
|
|
110
|
+
const w = s.match(/^waits? for \[([^\]]+)\]\s*(\w+)?(?: is)?(?: visible)?$/i);
|
|
111
|
+
if (w) return sentence(`The ${w[1]}${w[2] ? ` ${w[2]}` : ''} is visible`);
|
|
112
|
+
const auth = s.match(/^is (signed|logged) (in|out)(.*)$/i);
|
|
113
|
+
if (auth) return sentence(`The user is ${auth[1]} ${auth[2]}${auth[3] ?? ''}`);
|
|
106
114
|
return sentence(deRef(`The user ${s.charAt(0).toLowerCase()}${s.slice(1)}`));
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
/**
|
|
118
|
+
* A manual `Setup:` line is an INSTRUCTION the tester performs to establish the
|
|
119
|
+
* state — imperative reads correctly ("Seed the locked account."), while
|
|
120
|
+
* "The user seed …" is broken grammar (review GAP-08).
|
|
121
|
+
*/
|
|
122
|
+
export function renderSetupInstruction(raw: string): string {
|
|
123
|
+
return renderAction(raw);
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
// ---------------------------------------------------------------------------
|
|
110
127
|
// Manual `# Tester verifies:` comment classification (structured, label-free)
|
|
111
128
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: delivery
|
|
3
3
|
description: "Export the Test Case & Coverage Matrix (review/manual/customer deliverable) from Gherkin + Playwright results. --legacy exports the classic per-scenario CSV/XLSX."
|
|
4
|
-
argument-hint: "[name...] [--env <locale>] [--legacy] (omit names for all
|
|
4
|
+
argument-hint: "[name...] [--format csv] [--env <locale>] [--legacy] (omit names for all)"
|
|
5
5
|
order: 50
|
|
6
6
|
claude-tools: "Bash, Read, Write, AskUserQuestion"
|
|
7
7
|
copilot-tools: "[read, execute, edit, vscode/askQuestions]"
|
|
@@ -27,6 +27,8 @@ review → approve → official render.
|
|
|
27
27
|
|
|
28
28
|
Parse from `$ARGUMENTS`:
|
|
29
29
|
- **names** — zero or more screen/flow/api names. Empty → all targets.
|
|
30
|
+
- **`--format <xlsx|csv|both>`** — output format. **Default `xlsx` — one artifact only.** Pass
|
|
31
|
+
`--format csv` when the user wants the flat CSV (pipelines/diffing), `--format both` for both.
|
|
30
32
|
- **`--env <locale>`** — sets `SUNGEN_ENV=<locale>` for the run (accept `--locale` as alias).
|
|
31
33
|
- **`--legacy` / `--full`** — pass through to the CLI and skip the map flow entirely.
|
|
32
34
|
|
|
@@ -37,8 +39,8 @@ Parse from `$ARGUMENTS`:
|
|
|
37
39
|
```bash
|
|
38
40
|
[ -x ./bin/sungen.js ] && ./bin/sungen.js delivery <names> || npx sungen delivery <names>
|
|
39
41
|
```
|
|
40
|
-
(prepend `SUNGEN_ENV=<locale>` when `--env` was given; append `--
|
|
41
|
-
to step 5.)
|
|
42
|
+
(prepend `SUNGEN_ENV=<locale>` when `--env` was given; append `--format <fmt>` when the user asked
|
|
43
|
+
for a non-default format; append `--legacy` when requested — then skip to step 5.)
|
|
42
44
|
|
|
43
45
|
Three outcomes per target:
|
|
44
46
|
- **Rendered** → done, go to step 5.
|
|
@@ -151,6 +153,7 @@ counted in variants, never items**). Then `AskUserQuestion`:
|
|
|
151
153
|
- **Open the workbook** — inspect `qa/deliverables/<unit>-testcases.xlsx` (Testcases sheet:
|
|
152
154
|
collapse outline level 1 for the customer view; Coverage sheet: target × category grid + gaps).
|
|
153
155
|
- **Run tests to refresh results** — `/sungen:run-test <unit>`, then re-run delivery.
|
|
156
|
+
- **Also export CSV** — `sungen delivery <unit> --format csv` (flat `item`/`variant` rows for pipelines).
|
|
154
157
|
- **Export the legacy workbook too** — `sungen delivery <unit> --legacy`.
|
|
155
158
|
- **Done**
|
|
156
159
|
|
|
@@ -168,7 +171,8 @@ counted in variants, never items**). Then `AskUserQuestion`:
|
|
|
168
171
|
## CLI reference
|
|
169
172
|
|
|
170
173
|
```
|
|
171
|
-
sungen delivery [names...] # matrix (default; needs the map)
|
|
174
|
+
sungen delivery [names...] # matrix (default; needs the map) → XLSX only
|
|
175
|
+
--format <xlsx|csv|both> # output format; default xlsx (one artifact)
|
|
172
176
|
--check # gates only — validate the map, write nothing
|
|
173
177
|
--approve [DI-a,DI-b] # flip proposed→approved (+ stamp fingerprints); all groups when bare
|
|
174
178
|
--preview # render despite review findings (DRAFT watermark)
|
|
@@ -176,5 +180,6 @@ sungen delivery [names...] # matrix (default; needs the map)
|
|
|
176
180
|
--skip-preflight | --continue-on-missing | --env <env> # as before
|
|
177
181
|
```
|
|
178
182
|
|
|
179
|
-
Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Testcases + Coverage sheets)
|
|
180
|
-
(flat, `Level` column `item|variant`)
|
|
183
|
+
Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Testcases + Coverage sheets) by default;
|
|
184
|
+
`--format csv` writes `<unit>-testcases.csv` instead (flat, `Level` column `item|variant`),
|
|
185
|
+
`--format both` writes both. Legacy mode writes the classic files instead.
|