@sun-asterisk/sungen 3.2.16-beta.3 → 3.2.16-beta.5
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 +20 -2
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +78 -25
- 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 +36 -1
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
- package/dist/exporters/matrix/map-loader.js +2 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -1
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
- package/dist/exporters/matrix/render-csv.js +12 -9
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts +14 -0
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +107 -48
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +21 -3
- 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 +23 -6
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
- package/package.json +3 -3
- package/src/cli/commands/delivery.ts +19 -6
- package/src/exporters/matrix/build.ts +77 -22
- package/src/exporters/matrix/export.ts +21 -6
- package/src/exporters/matrix/gates.ts +36 -1
- package/src/exporters/matrix/map-loader.ts +2 -0
- package/src/exporters/matrix/render-csv.ts +13 -10
- package/src/exporters/matrix/render-xlsx.ts +104 -49
- package/src/exporters/matrix/types.ts +22 -4
- package/src/exporters/matrix/wording.ts +20 -3
- package/src/orchestrator/templates/ai-src/commands/delivery.md +23 -6
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
|
@@ -14,13 +14,14 @@ 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,
|
|
21
21
|
DeliveryItem,
|
|
22
22
|
DeliveryMap,
|
|
23
23
|
ItemResult,
|
|
24
|
+
VariantState,
|
|
24
25
|
MatrixDisposition,
|
|
25
26
|
MatrixLayer,
|
|
26
27
|
MatrixModel,
|
|
@@ -72,12 +73,12 @@ function resolveDataPairs(
|
|
|
72
73
|
if (row) {
|
|
73
74
|
for (const [k, v] of Object.entries(row)) {
|
|
74
75
|
if (k.startsWith('__') || k === 'case' || k === 'name' || k === 'label') continue;
|
|
75
|
-
pairs.push(`${k}: ${String(v)}`);
|
|
76
|
+
pairs.push(`${k}: ${displayValue(String(v))}`);
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
for (const v of vars) {
|
|
79
80
|
const val = row && v in row ? undefined : testData?.[v]; // row columns already listed
|
|
80
|
-
if (val !== undefined) pairs.push(`${v}: ${val}`);
|
|
81
|
+
if (val !== undefined) pairs.push(`${v}: ${displayValue(val)}`);
|
|
81
82
|
}
|
|
82
83
|
return pairs;
|
|
83
84
|
}
|
|
@@ -141,7 +142,7 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
|
|
|
141
142
|
...(authRole ? [authRole === 'no-auth' ? 'The user is signed out.' : `The user is signed in as ${authRole}.`] : []),
|
|
142
143
|
...inputs.feature.backgroundGivenSteps.map(renderPrecondition),
|
|
143
144
|
...m.feature.rawGivenSteps.map(renderPrecondition),
|
|
144
|
-
...(manual?.preconditions ?? []).map((t) =>
|
|
145
|
+
...(manual?.preconditions ?? []).map((t) => renderSetupInstruction(t)),
|
|
145
146
|
]));
|
|
146
147
|
|
|
147
148
|
const base = {
|
|
@@ -247,11 +248,22 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
|
|
|
247
248
|
function substituteDisplayVars(text: string, vars: Record<string, string>): string {
|
|
248
249
|
return text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string) => {
|
|
249
250
|
if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
|
|
250
|
-
|
|
251
|
-
return v === '' ? '(empty)' : v;
|
|
251
|
+
return displayValue(vars[key]);
|
|
252
252
|
});
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Make invisible test data VISIBLE instead of losing it (review GAP-04):
|
|
257
|
+
* '' → (empty) · whitespace-only → (N spaces) · padded → quoted verbatim.
|
|
258
|
+
* The canonical value is never changed — only its presentation.
|
|
259
|
+
*/
|
|
260
|
+
export function displayValue(v: string): string {
|
|
261
|
+
if (v === '') return '(empty)';
|
|
262
|
+
if (/^\s+$/.test(v)) return `(${v.length} space${v.length > 1 ? 's' : ''})`;
|
|
263
|
+
if (v !== v.trim()) return `"${v}"`;
|
|
264
|
+
return v;
|
|
265
|
+
}
|
|
266
|
+
|
|
255
267
|
function sentenceOf(text: string): string {
|
|
256
268
|
const s = text.trim();
|
|
257
269
|
if (!s) return s;
|
|
@@ -315,28 +327,57 @@ function rowAsStrings(row: Record<string, unknown>): Record<string, string> {
|
|
|
315
327
|
// Item assembly + roll-up
|
|
316
328
|
// ---------------------------------------------------------------------------
|
|
317
329
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The exact word rendered in a variant's Result cell. This is the SINGLE
|
|
332
|
+
* vocabulary both the renderers and the parent's COUNTIF roll-up use — the
|
|
333
|
+
* legacy exporter's statusToTestResult disagreed on two statuses (`skipped`
|
|
334
|
+
* showed N/A but counted as not-run; `interrupted` showed Pending but counted
|
|
335
|
+
* as blocked), which silently skewed the derived parent result.
|
|
336
|
+
*/
|
|
337
|
+
export function variantState(v: CoverageVariant): VariantState {
|
|
338
|
+
const r = v.result;
|
|
339
|
+
if (!r) return 'Pending';
|
|
340
|
+
switch (r.status) {
|
|
341
|
+
case 'passed': return 'Passed';
|
|
342
|
+
case 'failed':
|
|
343
|
+
case 'timedOut': return 'Failed';
|
|
344
|
+
case 'interrupted': return 'Blocked';
|
|
345
|
+
case 'skipped': return 'N/A';
|
|
346
|
+
default: return 'Pending';
|
|
347
|
+
}
|
|
324
348
|
}
|
|
325
349
|
|
|
326
|
-
|
|
350
|
+
const STATE_TO_COUNT: Record<VariantState, keyof DeliveryItem['resultCounts']> = {
|
|
351
|
+
Passed: 'passed', Failed: 'failed', Blocked: 'blocked', Pending: 'notRun', 'N/A': 'na',
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Derived parent result — precedence failed → blocked → all-N/A → not_run →
|
|
356
|
+
* partial → passed. `N/A` variants leave the denominator (an intentionally
|
|
357
|
+
* skipped case must not make the parent look incomplete).
|
|
358
|
+
*/
|
|
327
359
|
export function rollUp(variants: CoverageVariant[]): { result: ItemResult; counts: DeliveryItem['resultCounts'] } {
|
|
328
|
-
const counts = { passed: 0, failed: 0, blocked: 0, notRun: 0 };
|
|
329
|
-
for (const v of variants) counts[
|
|
330
|
-
const
|
|
360
|
+
const counts = { passed: 0, failed: 0, blocked: 0, notRun: 0, na: 0 };
|
|
361
|
+
for (const v of variants) counts[STATE_TO_COUNT[variantState(v)]]++;
|
|
362
|
+
const effective = variants.length - counts.na;
|
|
331
363
|
let result: ItemResult;
|
|
332
364
|
if (counts.failed > 0) result = 'failed';
|
|
333
365
|
else if (counts.blocked > 0) result = 'blocked';
|
|
334
|
-
else if (
|
|
335
|
-
else if (counts.
|
|
336
|
-
else result = 'passed';
|
|
366
|
+
else if (effective === 0) result = 'na';
|
|
367
|
+
else if (counts.passed === 0) result = 'not_run';
|
|
368
|
+
else if (counts.passed === effective) result = 'passed';
|
|
369
|
+
else result = 'partial';
|
|
337
370
|
return { result, counts };
|
|
338
371
|
}
|
|
339
372
|
|
|
373
|
+
/** Longest shared leading run of steps — hoisted onto the parent row. */
|
|
374
|
+
function commonPrefix(lists: string[][]): number {
|
|
375
|
+
if (lists.length === 0) return 0;
|
|
376
|
+
let n = 0;
|
|
377
|
+
while (lists.every((l) => l.length > n) && new Set(lists.map((l) => l[n])).size === 1) n++;
|
|
378
|
+
return n;
|
|
379
|
+
}
|
|
380
|
+
|
|
340
381
|
/**
|
|
341
382
|
* Expand one map variant ref to concrete variant refs:
|
|
342
383
|
* a bare VP-id whose scenario has a dataset means ALL its rows.
|
|
@@ -367,8 +408,13 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
|
|
|
367
408
|
const groupVariants = g.variants.flatMap((ref) => expandMapRef(ref, variantsByVp));
|
|
368
409
|
const { result, counts } = rollUp(groupVariants);
|
|
369
410
|
const first = groupVariants[0];
|
|
370
|
-
const triggerShapes = new Set(groupVariants.map((v) => v.triggerShape.join(' ; ')));
|
|
371
411
|
const modes = new Set(groupVariants.map((v) => v.mode));
|
|
412
|
+
const commonPre = (first?.precondition ?? []).filter((line) =>
|
|
413
|
+
groupVariants.every((v) => v.precondition.includes(line)));
|
|
414
|
+
// Steps every variant starts with belong on the parent once; each variant
|
|
415
|
+
// renders only its remaining steps (review: the shared prefix was repeated
|
|
416
|
+
// on every child while the parent cell sat empty).
|
|
417
|
+
const prefixLen = commonPrefix(groupVariants.map((v) => v.trigger));
|
|
372
418
|
// Item priority = highest variant priority (per-variant priorities stay visible).
|
|
373
419
|
const priorityRank: Record<string, number> = { High: 0, Normal: 1, Low: 2 };
|
|
374
420
|
const priority = groupVariants
|
|
@@ -386,8 +432,17 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
|
|
|
386
432
|
mode: modes.size > 1 ? 'mixed' : (first?.mode ?? 'auto'),
|
|
387
433
|
layers: Array.from(new Set(groupVariants.flatMap((v) => v.layers))),
|
|
388
434
|
traces: Array.from(new Set(groupVariants.flatMap((v) => v.traces))),
|
|
389
|
-
|
|
390
|
-
|
|
435
|
+
dimensions: g.dimensions,
|
|
436
|
+
// Only the preconditions COMMON to every variant belong on the parent —
|
|
437
|
+
// copying the first variant's setup mis-states the start state of the
|
|
438
|
+
// others (review GAP-03). Variant-specific lines render as the sub-row's
|
|
439
|
+
// precondition delta.
|
|
440
|
+
precondition: commonPre,
|
|
441
|
+
preconditionDeltas: Object.fromEntries(groupVariants.map((v) =>
|
|
442
|
+
[v.ref, v.precondition.filter((line) => !commonPre.includes(line))])),
|
|
443
|
+
triggerDeltas: Object.fromEntries(groupVariants.map((v) =>
|
|
444
|
+
[v.ref, v.trigger.slice(prefixLen)])),
|
|
445
|
+
trigger: (first?.trigger ?? []).slice(0, prefixLen),
|
|
391
446
|
variants: groupVariants,
|
|
392
447
|
result,
|
|
393
448
|
resultCounts: counts,
|
|
@@ -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) {
|
|
@@ -300,7 +320,22 @@ const WORDING_SMELLS: Array<[RegExp, string]> = [
|
|
|
300
320
|
|
|
301
321
|
function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
|
|
302
322
|
for (const g of ctx.inputs.map.groups) {
|
|
303
|
-
|
|
323
|
+
const variantCount = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp)).length;
|
|
324
|
+
// Without a digest the parent row can only list variant refs — a reviewer
|
|
325
|
+
// then has to expand the item to learn which dimensions it covers.
|
|
326
|
+
if (variantCount > 3 && !g.dimensions) {
|
|
327
|
+
findings.push({
|
|
328
|
+
gate: 'W', severity: 'warning', ref: g.id,
|
|
329
|
+
message: `group ${g.id} has ${variantCount} variants and no \`dimensions:\` digest — add a short one (e.g. "required ×3 · format ×9") so the collapsed view stays informative`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (g.dimensions && g.dimensions.length > 120) {
|
|
333
|
+
findings.push({
|
|
334
|
+
gate: 'W', severity: 'warning', ref: g.id,
|
|
335
|
+
message: `group ${g.id} \`dimensions:\` is ${g.dimensions.length} chars — keep the digest short (≤120)`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
for (const [field, text] of ([['intent', g.intent], ['oracle', g.oracle], ...(g.dimensions ? [['dimensions', g.dimensions] as const] : [])] as const)) {
|
|
304
339
|
for (const [re, what] of WORDING_SMELLS) {
|
|
305
340
|
if (re.test(text)) {
|
|
306
341
|
findings.push({
|
|
@@ -78,6 +78,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
|
|
|
78
78
|
target: String(grp.target ?? ''),
|
|
79
79
|
intent: String(grp.intent ?? ''),
|
|
80
80
|
oracle: String(grp.oracle ?? ''),
|
|
81
|
+
...(grp.dimensions !== undefined ? { dimensions: String(grp.dimensions) } : {}),
|
|
81
82
|
category: grp.category as MapCategory,
|
|
82
83
|
review,
|
|
83
84
|
variants: Array.isArray(variants) ? variants.map(String) : [],
|
|
@@ -148,6 +149,7 @@ export function writeDeliveryMap(file: string, map: DeliveryMap): void {
|
|
|
148
149
|
target: g.target,
|
|
149
150
|
intent: g.intent,
|
|
150
151
|
oracle: g.oracle,
|
|
152
|
+
...(g.dimensions !== undefined ? { dimensions: g.dimensions } : {}),
|
|
151
153
|
category: g.category,
|
|
152
154
|
review: g.review,
|
|
153
155
|
variants: g.variants,
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* and result), so downstream pipelines can re-derive both views from one file.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { itemModeLabel, itemResultLabel } from './render-xlsx';
|
|
8
|
+
import { variantState } from './build';
|
|
9
|
+
import { coverageSummary, itemModeLabel, itemResultLabel } from './render-xlsx';
|
|
10
10
|
import { MatrixModel } from './types';
|
|
11
11
|
|
|
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 {
|
|
@@ -35,8 +35,6 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
35
35
|
lines.push(HEADERS.map(esc).join(','));
|
|
36
36
|
|
|
37
37
|
for (const item of model.items) {
|
|
38
|
-
const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
|
|
39
|
-
const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger.join('\n') : '(differs per variant — see variant rows)';
|
|
40
38
|
lines.push([
|
|
41
39
|
'item',
|
|
42
40
|
item.id,
|
|
@@ -45,14 +43,16 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
45
43
|
item.category,
|
|
46
44
|
item.priority,
|
|
47
45
|
item.precondition.join('\n'),
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
coverageSummary(item),
|
|
47
|
+
// Shared leading steps only; each variant row carries its remaining steps.
|
|
48
|
+
item.trigger.join('\n'),
|
|
50
49
|
item.oracle,
|
|
51
50
|
itemModeLabel(item),
|
|
52
51
|
item.traces.join(' '),
|
|
53
52
|
itemResultLabel(item),
|
|
54
53
|
'',
|
|
55
54
|
item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
|
|
55
|
+
'',
|
|
56
56
|
].map(esc).join(','));
|
|
57
57
|
|
|
58
58
|
for (const v of item.variants) {
|
|
@@ -65,15 +65,18 @@ export function renderMatrixCsv(model: MatrixModel): string {
|
|
|
65
65
|
'',
|
|
66
66
|
v.condition,
|
|
67
67
|
'', '',
|
|
68
|
-
|
|
68
|
+
// Precondition DELTA — variant-specific setup only (review GAP-03).
|
|
69
|
+
(item.preconditionDeltas[v.ref] ?? []).join('\n'),
|
|
69
70
|
v.data.join('\n'),
|
|
70
|
-
|
|
71
|
+
// Steps after the parent's shared prefix only.
|
|
72
|
+
(item.triggerDeltas[v.ref] ?? []).join('\n'),
|
|
71
73
|
expected,
|
|
72
74
|
v.mode === 'manual' ? 'Manual' : 'Auto',
|
|
73
75
|
v.traces.join(' '),
|
|
74
|
-
|
|
76
|
+
variantState(v),
|
|
75
77
|
isoDate(v.result?.startTime),
|
|
76
78
|
v.result?.error ? String(v.result.error).slice(0, 200) : '',
|
|
79
|
+
'',
|
|
77
80
|
].map(esc).join(','));
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -27,8 +27,8 @@ import {
|
|
|
27
27
|
BLACK,
|
|
28
28
|
LAVENDER,
|
|
29
29
|
} from '../xlsx-report-builder';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
30
|
+
import { variantState } from './build';
|
|
31
|
+
import { DeliveryItem, MatrixModel, MapCategory } from './types';
|
|
32
32
|
|
|
33
33
|
const CATEGORY_ORDER: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
|
|
34
34
|
const SOFT_BLUE = { argb: 'FFDCE6F1' };
|
|
@@ -63,22 +63,21 @@ export function itemModeLabel(item: DeliveryItem): string {
|
|
|
63
63
|
return item.mode === 'manual' ? 'Manual' : 'Auto';
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Parent roll-up label. It must NEVER be exactly one of the variant state words
|
|
68
|
+
* (`Passed`/`Failed`/`Blocked`/`Pending`/`N/A`) — the summary band counts the
|
|
69
|
+
* Result column with exact-match COUNTIF over parent AND child rows, so a parent
|
|
70
|
+
* reading plain "Pending" was counted as an extra pending variant (109 instead
|
|
71
|
+
* of 77 on st-login).
|
|
72
|
+
*/
|
|
66
73
|
export function itemResultLabel(item: DeliveryItem): string {
|
|
67
|
-
const { passed, failed, blocked,
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
not_run: 'Pending',
|
|
75
|
-
};
|
|
76
|
-
return map[item.result];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function variantResultLabel(v: CoverageVariant): string {
|
|
80
|
-
if (!v.result) return 'Pending';
|
|
81
|
-
return statusToTestResult(v.result.status);
|
|
74
|
+
const { passed, failed, blocked, na } = item.resultCounts;
|
|
75
|
+
const eff = item.variants.length - na;
|
|
76
|
+
const suffix = na > 0 ? ` · ${na} N/A` : '';
|
|
77
|
+
if (item.result === 'na') return 'All N/A';
|
|
78
|
+
if (item.result === 'failed') return `${passed}/${eff} Passed · ${failed} Failed${suffix}`;
|
|
79
|
+
if (item.result === 'blocked') return `${passed}/${eff} Passed · ${blocked} Blocked${suffix}`;
|
|
80
|
+
return `${passed}/${eff} Passed${suffix}`;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
/** ISO date (2026-08-04) — unambiguous across locales (review §9). */
|
|
@@ -89,10 +88,24 @@ function isoDate(startTime: string | undefined): string {
|
|
|
89
88
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
function numbered(lines: string[]): string {
|
|
91
|
+
function numbered(lines: string[], start = 1): string {
|
|
93
92
|
if (lines.length === 0) return '';
|
|
94
|
-
if (lines.length === 1) return lines[0];
|
|
95
|
-
return lines.map((l, i) => `${
|
|
93
|
+
if (lines.length === 1 && start === 1) return lines[0];
|
|
94
|
+
return lines.map((l, i) => `${start + i}. ${l}`).join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compact coverage summary for the parent row. A declared `dimensions:` digest
|
|
99
|
+
* wins ("15 variants — required ×3 · format ×9 …"); otherwise a single variant
|
|
100
|
+
* shows its condition and a group falls back to its variant REFS. Concatenating
|
|
101
|
+
* every variant's full title turned this cell into an 800-character paragraph.
|
|
102
|
+
*/
|
|
103
|
+
export function coverageSummary(item: DeliveryItem): string {
|
|
104
|
+
const n = item.variants.length;
|
|
105
|
+
const head = `${n} variant${n === 1 ? '' : 's'}:`;
|
|
106
|
+
if (item.dimensions) return `${head} ${item.dimensions}`;
|
|
107
|
+
if (n === 1) return `${head} ${item.variants[0].condition}`;
|
|
108
|
+
return `${head} ${item.variants.map((v) => v.ref).join(' · ')}`;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
/** Expected cell = observable outcomes; the HOW moves under a separate heading. */
|
|
@@ -120,7 +133,7 @@ function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
|
|
|
120
133
|
const MATRIX_HEADERS = [
|
|
121
134
|
'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
|
|
122
135
|
'Coverage / Test Data', 'Action / Trigger', 'Expected Result', 'Mode', 'Trace',
|
|
123
|
-
'Result', 'Executed Date', 'Executor', 'Note
|
|
136
|
+
'Result', 'Executed Date', 'Executor', 'Note / Evidence', 'Defect ID',
|
|
124
137
|
];
|
|
125
138
|
const RESULT_COL = 13; // column M
|
|
126
139
|
|
|
@@ -144,25 +157,40 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
144
157
|
{ width: 22 }, // M Result
|
|
145
158
|
{ width: 12 }, // N Executed
|
|
146
159
|
{ width: 13 }, // O Executor
|
|
147
|
-
{ width:
|
|
160
|
+
{ width: 22 }, // P Note / Evidence
|
|
161
|
+
{ width: 12 }, // Q Defect ID
|
|
148
162
|
];
|
|
149
163
|
|
|
150
164
|
renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} TEST CASE & COVERAGE MATRIX`, sungenVersion, model.formNo);
|
|
151
165
|
draftBanner(ws, model);
|
|
152
166
|
|
|
153
|
-
// Summary band (row 6) — items vs variants stay separate metrics by design (I8)
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
// Summary band (row 6) — items vs variants stay separate metrics by design (I8).
|
|
168
|
+
// The execution counters are LIVE formulas over the variant Result cells, so
|
|
169
|
+
// hand-entered results update the totals (review GAP-06). Variant cells hold the
|
|
170
|
+
// exact state words and parent cells NEVER do (itemResultLabel + the roll-up
|
|
171
|
+
// formula always compose a "n/m Passed …" label), so exact-match COUNTIF over
|
|
172
|
+
// the whole column counts variants only.
|
|
173
|
+
const sum = (k: 'passed' | 'failed' | 'blocked' | 'notRun' | 'na'): number =>
|
|
174
|
+
model.items.reduce((a, i) => a + i.resultCounts[k], 0);
|
|
175
|
+
const passed = sum('passed');
|
|
176
|
+
const failed = sum('failed');
|
|
177
|
+
const blocked = sum('blocked');
|
|
178
|
+
const notRun = sum('notRun');
|
|
179
|
+
const na = sum('na');
|
|
180
|
+
const RANGE = 'M9:M10000';
|
|
158
181
|
const band = ws.getRow(6);
|
|
159
|
-
[
|
|
160
|
-
`Delivery items: ${model.manifest.itemCount}
|
|
161
|
-
`Coverage variants: ${model.manifest.variantCount}
|
|
162
|
-
`Passed: ${
|
|
163
|
-
|
|
182
|
+
const bandCells: Array<[string, ExcelJS.CellValue]> = [
|
|
183
|
+
['items', `Delivery items: ${model.manifest.itemCount}`],
|
|
184
|
+
['variants', `Coverage variants: ${model.manifest.variantCount}`],
|
|
185
|
+
['passed', { formula: `"Passed: "&COUNTIF(${RANGE},"Passed")`, result: `Passed: ${passed}` } as ExcelJS.CellValue],
|
|
186
|
+
['failed', { formula: `"Failed: "&COUNTIF(${RANGE},"Failed")`, result: `Failed: ${failed}` } as ExcelJS.CellValue],
|
|
187
|
+
['blocked', { formula: `"Blocked: "&COUNTIF(${RANGE},"Blocked")`, result: `Blocked: ${blocked}` } as ExcelJS.CellValue],
|
|
188
|
+
['pending', { formula: `"Pending: "&COUNTIF(${RANGE},"Pending")`, result: `Pending: ${notRun}` } as ExcelJS.CellValue],
|
|
189
|
+
['na', { formula: `"N/A: "&COUNTIF(${RANGE},"N/A")`, result: `N/A: ${na}` } as ExcelJS.CellValue],
|
|
190
|
+
];
|
|
191
|
+
bandCells.forEach(([, v], i) => {
|
|
164
192
|
const c = band.getCell(2 + i);
|
|
165
|
-
dataCell(c,
|
|
193
|
+
dataCell(c, v, { bold: true, fill: SOFT_BLUE, center: true });
|
|
166
194
|
});
|
|
167
195
|
|
|
168
196
|
const HEADER_ROW = 8;
|
|
@@ -178,6 +206,10 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
178
206
|
// Every item gets sub-rows — the sub-row is where the source scenario id, the
|
|
179
207
|
// resolved data, and the manual result/evidence entry live (review B-05).
|
|
180
208
|
const firstChild = rowIdx;
|
|
209
|
+
// Delta-only sub-rows (review GAP-01): content identical across the whole
|
|
210
|
+
// item lives ONCE on the parent — a child repeats only what distinguishes it.
|
|
211
|
+
// The child's Expected Result is always kept: it is the precise source
|
|
212
|
+
// oracle (the semantic anchor), not a repetition of the parent's summary.
|
|
181
213
|
for (const v of item.variants) {
|
|
182
214
|
const vr = ws.getRow(rowIdx++);
|
|
183
215
|
vr.outlineLevel = 1;
|
|
@@ -185,16 +217,20 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
185
217
|
v.ref,
|
|
186
218
|
'',
|
|
187
219
|
v.condition,
|
|
188
|
-
'', '',
|
|
220
|
+
'', '',
|
|
221
|
+
numbered(item.preconditionDeltas[v.ref] ?? []),
|
|
189
222
|
numbered(v.data),
|
|
190
|
-
numbered
|
|
223
|
+
// Only the steps after the parent's shared prefix, numbered so the
|
|
224
|
+
// parent's steps + these read as one continuous procedure.
|
|
225
|
+
numbered(item.triggerDeltas[v.ref] ?? [], item.trigger.length + 1),
|
|
191
226
|
expectedWithVerification(v.oracle, v.verification),
|
|
192
227
|
v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
|
|
193
228
|
v.traces.join(', '),
|
|
194
|
-
|
|
229
|
+
variantState(v),
|
|
195
230
|
isoDate(v.result?.startTime),
|
|
196
231
|
'',
|
|
197
232
|
v.result?.error ? String(v.result.error).slice(0, 200) : '',
|
|
233
|
+
'',
|
|
198
234
|
];
|
|
199
235
|
vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
|
|
200
236
|
// Manual-execution entry: constrain the Result cell to the known states.
|
|
@@ -207,19 +243,21 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
207
243
|
// --- Parent row: only values that are genuinely COMMON to the whole item
|
|
208
244
|
// (review M-04); everything variant-specific stays on the sub-rows.
|
|
209
245
|
const pr = ws.getRow(parentRowIdx);
|
|
210
|
-
const
|
|
211
|
-
const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger : null;
|
|
212
|
-
const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
|
|
246
|
+
const coverage = coverageSummary(item);
|
|
213
247
|
|
|
214
|
-
// Live roll-up
|
|
215
|
-
//
|
|
248
|
+
// Live roll-up derived from the child Result cells — precedence
|
|
249
|
+
// failed → blocked → pending → partial → passed. N/A variants leave the
|
|
250
|
+
// denominator (review GAP-06): eff = n − COUNTIF(N/A); all-N/A → "N/A".
|
|
216
251
|
const rng = `M${firstChild}:M${lastChild}`;
|
|
217
252
|
const n = item.variants.length;
|
|
253
|
+
const EFF = `(${n}-COUNTIF(${rng},"N/A"))`;
|
|
254
|
+
const NA = `IF(COUNTIF(${rng},"N/A")>0," · "&COUNTIF(${rng},"N/A")&" N/A","")`;
|
|
255
|
+
// Mirrors itemResultLabel exactly — and never yields a bare state word.
|
|
218
256
|
const rollUpFormula =
|
|
219
|
-
`IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"
|
|
220
|
-
`IF(COUNTIF(${rng},"Blocked")>0,"Blocked",` +
|
|
221
|
-
`IF(
|
|
222
|
-
`
|
|
257
|
+
`IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Failed")&" Failed"&${NA},` +
|
|
258
|
+
`IF(COUNTIF(${rng},"Blocked")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Blocked")&" Blocked"&${NA},` +
|
|
259
|
+
`IF(${EFF}=0,"All N/A",` +
|
|
260
|
+
`COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed"&${NA})))`;
|
|
223
261
|
|
|
224
262
|
const values: ExcelJS.CellValue[] = [
|
|
225
263
|
item.id,
|
|
@@ -229,7 +267,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
229
267
|
item.priority,
|
|
230
268
|
numbered(item.precondition),
|
|
231
269
|
coverage,
|
|
232
|
-
|
|
270
|
+
// The shared leading steps; each variant's remaining steps sit on its sub-row.
|
|
271
|
+
numbered(item.trigger),
|
|
233
272
|
item.oracle,
|
|
234
273
|
itemModeLabel(item),
|
|
235
274
|
item.traces.join(', '),
|
|
@@ -237,6 +276,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
237
276
|
'',
|
|
238
277
|
'',
|
|
239
278
|
item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
|
|
279
|
+
'',
|
|
240
280
|
];
|
|
241
281
|
values.forEach((v, i) => dataCell(pr.getCell(2 + i), v, {
|
|
242
282
|
bold: true,
|
|
@@ -246,9 +286,18 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
|
|
|
246
286
|
}));
|
|
247
287
|
}
|
|
248
288
|
|
|
249
|
-
ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column:
|
|
289
|
+
ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 17 } };
|
|
250
290
|
// Freeze the header band AND the ID + Target columns (review §9).
|
|
251
291
|
ws.views = [{ state: 'frozen', xSplit: 3, ySplit: HEADER_ROW }];
|
|
292
|
+
// Print: landscape, fit width only (never squash all rows into one page),
|
|
293
|
+
// repeat the masthead + header rows on every page (review GAP-08).
|
|
294
|
+
ws.pageSetup = {
|
|
295
|
+
orientation: 'landscape',
|
|
296
|
+
fitToPage: true,
|
|
297
|
+
fitToWidth: 1,
|
|
298
|
+
fitToHeight: 0,
|
|
299
|
+
printTitlesRow: '1:8',
|
|
300
|
+
};
|
|
252
301
|
}
|
|
253
302
|
|
|
254
303
|
// ---------------------------------------------------------------------------
|
|
@@ -270,8 +319,14 @@ function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersio
|
|
|
270
319
|
if (model.requirements.length > 0) {
|
|
271
320
|
dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
|
|
272
321
|
rowIdx += 1;
|
|
273
|
-
const rh = ws.getRow(rowIdx
|
|
274
|
-
|
|
322
|
+
const rh = ws.getRow(rowIdx);
|
|
323
|
+
ws.mergeCells(rowIdx, 4, rowIdx, 6);
|
|
324
|
+
headerCell(rh.getCell(2), 'Requirement');
|
|
325
|
+
headerCell(rh.getCell(3), 'Status');
|
|
326
|
+
headerCell(rh.getCell(4), 'Delivery items');
|
|
327
|
+
headerCell(rh.getCell(7), 'Variants');
|
|
328
|
+
headerCell(rh.getCell(8), 'Note');
|
|
329
|
+
rowIdx += 1;
|
|
275
330
|
for (const r of model.requirements) {
|
|
276
331
|
const row = ws.getRow(rowIdx++);
|
|
277
332
|
dataCell(row.getCell(2), r.id, { bold: true });
|