@sun-asterisk/sungen 3.2.16-beta.1 → 3.2.16-beta.2

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.
Files changed (46) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +1 -0
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/exporters/matrix/build.d.ts +8 -1
  5. package/dist/exporters/matrix/build.d.ts.map +1 -1
  6. package/dist/exporters/matrix/build.js +158 -22
  7. package/dist/exporters/matrix/build.js.map +1 -1
  8. package/dist/exporters/matrix/export.d.ts +2 -0
  9. package/dist/exporters/matrix/export.d.ts.map +1 -1
  10. package/dist/exporters/matrix/export.js +7 -0
  11. package/dist/exporters/matrix/export.js.map +1 -1
  12. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  13. package/dist/exporters/matrix/gates.js +37 -0
  14. package/dist/exporters/matrix/gates.js.map +1 -1
  15. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  16. package/dist/exporters/matrix/map-loader.js +18 -0
  17. package/dist/exporters/matrix/map-loader.js.map +1 -1
  18. package/dist/exporters/matrix/render-csv.d.ts +3 -2
  19. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  20. package/dist/exporters/matrix/render-csv.js +48 -28
  21. package/dist/exporters/matrix/render-csv.js.map +1 -1
  22. package/dist/exporters/matrix/render-xlsx.d.ts +16 -8
  23. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  24. package/dist/exporters/matrix/render-xlsx.js +116 -52
  25. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  26. package/dist/exporters/matrix/types.d.ts +23 -1
  27. package/dist/exporters/matrix/types.d.ts.map +1 -1
  28. package/dist/exporters/matrix/types.js.map +1 -1
  29. package/dist/exporters/matrix/wording.d.ts +45 -0
  30. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  31. package/dist/exporters/matrix/wording.js +150 -0
  32. package/dist/exporters/matrix/wording.js.map +1 -0
  33. package/dist/orchestrator/templates/ai-src/commands/delivery.md +27 -1
  34. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
  35. package/package.json +3 -3
  36. package/src/cli/commands/delivery.ts +1 -0
  37. package/src/exporters/matrix/build.ts +160 -22
  38. package/src/exporters/matrix/export.ts +10 -0
  39. package/src/exporters/matrix/gates.ts +41 -0
  40. package/src/exporters/matrix/map-loader.ts +20 -1
  41. package/src/exporters/matrix/render-csv.ts +49 -29
  42. package/src/exporters/matrix/render-xlsx.ts +122 -55
  43. package/src/exporters/matrix/types.ts +27 -1
  44. package/src/exporters/matrix/wording.ts +157 -0
  45. package/src/orchestrator/templates/ai-src/commands/delivery.md +27 -1
  46. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Flat CSV rendering of the matrix — same model as the workbook, one row per
3
- * delivery item (`Level: item`) followed by its variant rows (`Level: variant`),
4
- * so downstream pipelines can re-derive both views from one file.
3
+ * delivery item (`Level: item`) followed by its variant rows (`Level: variant`;
4
+ * every item has them the variant row carries the source scenario id, data,
5
+ * and result), so downstream pipelines can re-derive both views from one file.
5
6
  */
6
7
 
7
- import { statusToTestResult, formatExecutedDate } from '../playwright-report-parser';
8
+ import { statusToTestResult } from '../playwright-report-parser';
9
+ import { itemResultLabel } from './render-xlsx';
8
10
  import { MatrixModel } from './types';
9
11
 
10
12
  const HEADERS = [
11
13
  'Level', 'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority',
12
- 'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected (Oracle)',
14
+ 'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected Result',
13
15
  'Mode', 'Trace', 'Result', 'Executed Date', 'Note',
14
16
  ];
15
17
 
@@ -18,6 +20,13 @@ function esc(value: string): string {
18
20
  return value;
19
21
  }
20
22
 
23
+ function isoDate(startTime: string | undefined): string {
24
+ if (!startTime) return '';
25
+ const d = new Date(startTime);
26
+ if (isNaN(d.getTime())) return '';
27
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
28
+ }
29
+
21
30
  export function renderMatrixCsv(model: MatrixModel): string {
22
31
  const BOM = '\ufeff'; // Excel compatibility (Vietnamese/Japanese content)
23
32
  const lines: string[] = [];
@@ -26,8 +35,8 @@ export function renderMatrixCsv(model: MatrixModel): string {
26
35
  lines.push(HEADERS.map(esc).join(','));
27
36
 
28
37
  for (const item of model.items) {
29
- const single = item.variants.length === 1;
30
- const only = single ? item.variants[0] : undefined;
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)';
31
40
  lines.push([
32
41
  'item',
33
42
  item.id,
@@ -36,35 +45,46 @@ export function renderMatrixCsv(model: MatrixModel): string {
36
45
  item.category,
37
46
  item.priority,
38
47
  item.precondition.join('\n'),
39
- single ? only!.data.join('\n') : `${item.variants.length} variants`,
40
- item.trigger.join('\n'),
48
+ `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`,
49
+ commonTrigger,
41
50
  item.oracle,
42
51
  item.mode === 'manual' ? 'Manual' : 'Auto',
43
52
  item.traces.join(' '),
44
- item.result,
45
- single ? formatExecutedDate(only!.result?.startTime) : '',
53
+ itemResultLabel(item),
54
+ '',
46
55
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
47
56
  ].map(esc).join(','));
48
57
 
49
- if (!single) {
50
- for (const v of item.variants) {
51
- lines.push([
52
- 'variant',
53
- v.ref,
54
- '',
55
- v.condition,
56
- '', '',
57
- '',
58
- v.data.join('\n'),
59
- v.trigger.join('\n'),
60
- v.oracle.join('\n'),
61
- v.mode === 'manual' ? 'Manual' : 'Auto',
62
- v.traces.join(' '),
63
- v.result ? statusToTestResult(v.result.status) : 'Pending',
64
- formatExecutedDate(v.result?.startTime),
65
- v.result?.error ? String(v.result.error).slice(0, 200) : '',
66
- ].map(esc).join(','));
67
- }
58
+ for (const v of item.variants) {
59
+ const expected = v.verification.length > 0
60
+ ? `${v.oracle.join('\n')}\nVerification method: ${v.verification.join(' ')}`
61
+ : v.oracle.join('\n');
62
+ lines.push([
63
+ 'variant',
64
+ v.ref,
65
+ '',
66
+ v.condition,
67
+ '', '',
68
+ '',
69
+ v.data.join('\n'),
70
+ v.trigger.join('\n'),
71
+ expected,
72
+ v.mode === 'manual' ? 'Manual' : 'Auto',
73
+ v.traces.join(' '),
74
+ v.result ? statusToTestResult(v.result.status) : 'Pending',
75
+ isoDate(v.result?.startTime),
76
+ v.result?.error ? String(v.result.error).slice(0, 200) : '',
77
+ ].map(esc).join(','));
78
+ }
79
+ }
80
+
81
+ // Requirement coverage appendix — keeps the CSV self-contained for pipelines.
82
+ if (model.requirements.length > 0) {
83
+ lines.push('');
84
+ lines.push('# Requirement coverage');
85
+ lines.push(['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].join(','));
86
+ for (const r of model.requirements) {
87
+ lines.push([r.id, r.status, r.items.join(' '), String(r.variantCount), r.note].map(esc).join(','));
68
88
  }
69
89
  }
70
90
  return BOM + lines.join('\n') + '\n';
@@ -2,14 +2,21 @@
2
2
  * Render the Test Case & Coverage Matrix workbook.
3
3
  *
4
4
  * Sheets:
5
- * 1. Testcases — one parent row per delivery item, variant sub-rows grouped with
6
- * Excel outline (expanded for execution; the level-1 outline button collapses
7
- * to the customer/review view same sheet, one model, two views).
8
- * 2. Coverage target × category grid (items + variants, `—` marks the gaps),
9
- * dispositions, and the generation manifest.
5
+ * 1. Testcases — one parent row per delivery item; EVERY item (including
6
+ * single-variant ones) carries variant sub-rows on Excel outline level 1 —
7
+ * the sub-row is where source traceability (VP-id), test data, and the
8
+ * manual result/evidence entry live (review B-05/B-06). Collapse outline
9
+ * level 1 for the customer/review view; expand to execute.
10
+ * 2. Coverage — requirement coverage (id ↔ items ↔ status), the target ×
11
+ * category grid (explicit `—` gaps), dispositions, and the manifest.
10
12
  *
11
- * Every sheet keeps the company ISO masthead (renderReportHeaderBand). A draft
12
- * matrix (unapproved groups / review findings) carries a red DRAFT banner.
13
+ * Manual-execution support: variant Result cells carry a dropdown
14
+ * (Passed/Failed/Blocked/Pending/N/A) and the parent Result is a live Excel
15
+ * formula derived from its children — a parent can never contradict its
16
+ * variants, even after QA edits results by hand (review B-06).
17
+ *
18
+ * Every sheet keeps the company ISO masthead. A draft matrix (unapproved
19
+ * groups / review findings) carries a red DRAFT banner.
13
20
  */
14
21
 
15
22
  import * as ExcelJS from 'exceljs';
@@ -20,13 +27,14 @@ import {
20
27
  BLACK,
21
28
  LAVENDER,
22
29
  } from '../xlsx-report-builder';
23
- import { statusToTestResult, formatExecutedDate } from '../playwright-report-parser';
30
+ import { statusToTestResult } from '../playwright-report-parser';
24
31
  import { CoverageVariant, DeliveryItem, ItemResult, MatrixModel, MapCategory } from './types';
25
32
 
26
33
  const CATEGORY_ORDER: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
27
34
  const SOFT_BLUE = { argb: 'FFDCE6F1' };
28
35
  const DRAFT_RED = { argb: 'FFC00000' };
29
36
  const GAP_FILL = { argb: 'FFFCE4EC' };
37
+ const RESULT_STATES = 'Passed,Failed,Blocked,Pending,N/A';
30
38
 
31
39
  function headerCell(c: ExcelJS.Cell, label: string): void {
32
40
  c.value = label;
@@ -37,14 +45,16 @@ function headerCell(c: ExcelJS.Cell, label: string): void {
37
45
  }
38
46
 
39
47
  function dataCell(c: ExcelJS.Cell, value: ExcelJS.CellValue, opts?: { bold?: boolean; fill?: { argb: string }; center?: boolean; color?: { argb: string } }): void {
40
- c.value = value;
48
+ // An empty value stays a genuinely blank cell — never an empty shared string,
49
+ // which some renderers display as its string-table index (review M-03).
50
+ if (value !== '' && value !== undefined && value !== null) c.value = value;
41
51
  c.font = { bold: opts?.bold ?? false, size: 10, name: ARIAL_FONT, ...(opts?.color ? { color: opts.color } : {}) };
42
52
  c.alignment = { horizontal: opts?.center ? 'center' : 'left', vertical: 'top', wrapText: true };
43
53
  c.border = allBordersBlack;
44
54
  if (opts?.fill) c.fill = { type: 'pattern', pattern: 'solid', fgColor: opts.fill };
45
55
  }
46
56
 
47
- function itemResultLabel(item: DeliveryItem): string {
57
+ export function itemResultLabel(item: DeliveryItem): string {
48
58
  const { passed, failed, blocked, notRun } = item.resultCounts;
49
59
  const total = item.variants.length;
50
60
  const map: Record<ItemResult, string> = {
@@ -62,12 +72,27 @@ function variantResultLabel(v: CoverageVariant): string {
62
72
  return statusToTestResult(v.result.status);
63
73
  }
64
74
 
75
+ /** ISO date (2026-08-04) — unambiguous across locales (review §9). */
76
+ function isoDate(startTime: string | undefined): string {
77
+ if (!startTime) return '';
78
+ const d = new Date(startTime);
79
+ if (isNaN(d.getTime())) return '';
80
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
81
+ }
82
+
65
83
  function numbered(lines: string[]): string {
66
84
  if (lines.length === 0) return '';
67
85
  if (lines.length === 1) return lines[0];
68
86
  return lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
69
87
  }
70
88
 
89
+ /** Expected cell = observable outcomes; the HOW moves under a separate heading. */
90
+ function expectedWithVerification(oracle: string[], verification: string[]): string {
91
+ const expected = numbered(oracle);
92
+ if (verification.length === 0) return expected;
93
+ return `${expected}\nVerification method: ${verification.join(' ')}`;
94
+ }
95
+
71
96
  /** Red DRAFT banner on row 5 when the matrix is not approved for official delivery. */
72
97
  function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
73
98
  if (model.manifest.approvalState !== 'draft') return;
@@ -85,9 +110,10 @@ function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
85
110
 
86
111
  const MATRIX_HEADERS = [
87
112
  'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
88
- 'Coverage / Test Data', 'Action / Trigger', 'Expected (Oracle)', 'Mode', 'Trace',
113
+ 'Coverage / Test Data', 'Action / Trigger', 'Expected Result', 'Mode', 'Trace',
89
114
  'Result', 'Executed Date', 'Executor', 'Note\n(Evidence, DefectID)',
90
115
  ];
116
+ const RESULT_COL = 13; // column M
91
117
 
92
118
  function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
93
119
  const ws = wb.addWorksheet('Testcases');
@@ -95,7 +121,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
95
121
 
96
122
  ws.columns = [
97
123
  { width: 3 }, // A margin
98
- { width: 20 }, // B ID
124
+ { width: 22 }, // B ID
99
125
  { width: 18 }, // C Target
100
126
  { width: 42 }, // D Intent / Condition
101
127
  { width: 11 }, // E Category
@@ -106,8 +132,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
106
132
  { width: 42 }, // J Expected
107
133
  { width: 11 }, // K Mode
108
134
  { width: 13 }, // L Trace
109
- { width: 17 }, // M Result
110
- { width: 13 }, // N Executed
135
+ { width: 22 }, // M Result
136
+ { width: 12 }, // N Executed
111
137
  { width: 13 }, // O Executor
112
138
  { width: 24 }, // P Note
113
139
  ];
@@ -137,13 +163,55 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
137
163
 
138
164
  let rowIdx = HEADER_ROW + 1;
139
165
  for (const item of model.items) {
140
- // Parent row — the delivery item (the review unit).
141
- const pr = ws.getRow(rowIdx++);
142
- const single = item.variants.length === 1;
143
- const only = single ? item.variants[0] : undefined;
144
- const coverage = single
145
- ? numbered(only!.data)
146
- : `${item.variants.length} variants: ${item.variants.map((v) => v.condition).join(' · ')}`;
166
+ const parentRowIdx = rowIdx++;
167
+
168
+ // --- Variant sub-rows FIRST (to know their cell range for the parent formula).
169
+ // Every item gets sub-rows the sub-row is where the source scenario id, the
170
+ // resolved data, and the manual result/evidence entry live (review B-05).
171
+ const firstChild = rowIdx;
172
+ for (const v of item.variants) {
173
+ const vr = ws.getRow(rowIdx++);
174
+ vr.outlineLevel = 1;
175
+ const vValues: ExcelJS.CellValue[] = [
176
+ v.ref,
177
+ '',
178
+ v.condition,
179
+ '', '', '',
180
+ numbered(v.data),
181
+ numbered(v.trigger),
182
+ expectedWithVerification(v.oracle, v.verification),
183
+ v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
184
+ v.traces.join(', '),
185
+ variantResultLabel(v),
186
+ isoDate(v.result?.startTime),
187
+ '',
188
+ v.result?.error ? String(v.result.error).slice(0, 200) : '',
189
+ ];
190
+ vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
191
+ // Manual-execution entry: constrain the Result cell to the known states.
192
+ vr.getCell(2 + 11).dataValidation = {
193
+ type: 'list', allowBlank: true, formulae: [`"${RESULT_STATES}"`],
194
+ };
195
+ }
196
+ const lastChild = rowIdx - 1;
197
+
198
+ // --- Parent row: only values that are genuinely COMMON to the whole item
199
+ // (review M-04); everything variant-specific stays on the sub-rows.
200
+ const pr = ws.getRow(parentRowIdx);
201
+ const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
202
+ const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger : null;
203
+ const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
204
+
205
+ // Live roll-up: derived from the child Result cells so hand-entered results
206
+ // recompute the parent — precedence failed → blocked → pending → partial → passed.
207
+ const rng = `M${firstChild}:M${lastChild}`;
208
+ const n = item.variants.length;
209
+ const rollUpFormula =
210
+ `IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/${n} Passed · "&COUNTIF(${rng},"Failed")&" Failed",` +
211
+ `IF(COUNTIF(${rng},"Blocked")>0,"Blocked",` +
212
+ `IF(COUNTIF(${rng},"Passed")=${n},"${n}/${n} Passed",` +
213
+ `IF(COUNTIF(${rng},"Passed")=0,"Pending",COUNTIF(${rng},"Passed")&"/${n} Passed · pending"))))`;
214
+
147
215
  const values: ExcelJS.CellValue[] = [
148
216
  item.id,
149
217
  item.target,
@@ -152,12 +220,12 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
152
220
  item.priority,
153
221
  numbered(item.precondition),
154
222
  coverage,
155
- numbered(item.trigger),
223
+ commonTrigger ? numbered(commonTrigger) : '(differs per variant — see sub-rows)',
156
224
  item.oracle,
157
- item.mode === 'manual' ? `Manual${only?.manualReason ? ` (${only.manualReason})` : ''}` : 'Auto',
225
+ item.mode === 'manual' ? 'Manual' : 'Auto',
158
226
  item.traces.join(', '),
159
- itemResultLabel(item),
160
- single ? formatExecutedDate(only!.result?.startTime) : '',
227
+ { formula: rollUpFormula, result: itemResultLabel(item) } as ExcelJS.CellValue,
228
+ '',
161
229
  '',
162
230
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
163
231
  ];
@@ -165,53 +233,52 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
165
233
  bold: true,
166
234
  fill: SOFT_BLUE,
167
235
  center: i === 3 || i === 4 || i === 9,
168
- ...(String(values[14]).startsWith('REVIEW') && i === 14 ? { color: DRAFT_RED } : {}),
236
+ ...(i === 14 && item.review === 'proposed' ? { color: DRAFT_RED } : {}),
169
237
  }));
170
-
171
- // Variant sub-rows — the execution units (outline level 1; collapse for review).
172
- if (!single) {
173
- for (const v of item.variants) {
174
- const vr = ws.getRow(rowIdx++);
175
- vr.outlineLevel = 1;
176
- const vValues: ExcelJS.CellValue[] = [
177
- v.ref,
178
- '',
179
- v.condition,
180
- '', '', '',
181
- numbered(v.data),
182
- numbered(v.trigger),
183
- numbered(v.oracle),
184
- v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
185
- v.traces.join(', '),
186
- variantResultLabel(v),
187
- formatExecutedDate(v.result?.startTime),
188
- '',
189
- v.result?.error ? String(v.result.error).slice(0, 200) : '',
190
- ];
191
- vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
192
- }
193
- }
194
238
  }
195
239
 
196
240
  ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 16 } };
197
- ws.views = [{ state: 'frozen', ySplit: HEADER_ROW }];
241
+ // Freeze the header band AND the ID + Target columns (review §9).
242
+ ws.views = [{ state: 'frozen', xSplit: 3, ySplit: HEADER_ROW }];
198
243
  }
199
244
 
200
245
  // ---------------------------------------------------------------------------
201
- // Sheet 2 — Coverage (overview grid + dispositions + manifest)
246
+ // Sheet 2 — Coverage (requirements + overview grid + dispositions + manifest)
202
247
  // ---------------------------------------------------------------------------
203
248
 
204
249
  function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
205
250
  const ws = wb.addWorksheet('Coverage');
206
251
  ws.columns = [
207
- { width: 3 }, { width: 30 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 },
208
- { width: 16 }, { width: 40 },
252
+ { width: 3 }, { width: 22 }, { width: 18 }, { width: 16 }, { width: 16 }, { width: 16 },
253
+ { width: 16 }, { width: 44 },
209
254
  ];
210
255
  renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} COVERAGE OVERVIEW`, sungenVersion, model.formNo);
211
256
  draftBanner(ws, model);
212
257
 
213
- // --- Target × category grid ---
214
258
  let rowIdx = 7;
259
+
260
+ // --- Requirement coverage (review §6): every requirement id gets ONE explicit status.
261
+ if (model.requirements.length > 0) {
262
+ dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
263
+ rowIdx += 1;
264
+ const rh = ws.getRow(rowIdx++);
265
+ ['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].forEach((l, i) => headerCell(rh.getCell(2 + i), l));
266
+ for (const r of model.requirements) {
267
+ const row = ws.getRow(rowIdx++);
268
+ dataCell(row.getCell(2), r.id, { bold: true });
269
+ dataCell(row.getCell(3), r.status, {
270
+ center: true,
271
+ ...(r.status === 'gap' ? { fill: GAP_FILL, bold: true } : {}),
272
+ });
273
+ ws.mergeCells(rowIdx - 1, 4, rowIdx - 1, 6);
274
+ dataCell(row.getCell(4), r.items.join(', '));
275
+ dataCell(row.getCell(7), r.variantCount > 0 ? String(r.variantCount) : '', { center: true });
276
+ dataCell(row.getCell(8), r.note);
277
+ }
278
+ rowIdx += 1;
279
+ }
280
+
281
+ // --- Target × category grid ---
215
282
  dataCell(ws.getRow(rowIdx).getCell(2), 'Coverage matrix — delivery items (coverage variants) per target × category', { bold: true });
216
283
  rowIdx += 1;
217
284
  const gridHeader = ws.getRow(rowIdx++);
@@ -38,6 +38,14 @@ export interface MapDisposition {
38
38
  reason?: string;
39
39
  }
40
40
 
41
+ export type RequirementStatus =
42
+ | 'covered' | 'partially_covered' | 'covered_elsewhere' | 'planned' | 'gap' | 'not_applicable';
43
+
44
+ export interface RequirementOverride {
45
+ status: RequirementStatus;
46
+ note?: string;
47
+ }
48
+
41
49
  export interface DeliveryMap {
42
50
  version: number;
43
51
  unit: string;
@@ -46,6 +54,9 @@ export interface DeliveryMap {
46
54
  groups: MapGroup[];
47
55
  /** VP-id → intentionally-not-grouped disposition. */
48
56
  dispositions: Record<string, MapDisposition>;
57
+ /** Requirement-id → reviewed status override (partially_covered / not_applicable / …).
58
+ * Without an override a requirement is `covered` when a variant traces to it, else `gap`. */
59
+ requirements: Record<string, RequirementOverride>;
49
60
  /** VP-id → scenario fingerprint stamped at approval time (drift detector input). */
50
61
  fingerprints: Record<string, string>;
51
62
  }
@@ -90,6 +101,9 @@ export interface CoverageVariant {
90
101
  oracleShape: string[];
91
102
  /** Oracle for display. */
92
103
  oracle: string[];
104
+ /** HOW to check (manual `Oracle:` lines — tools, panes, queries). Rendered as
105
+ * "Verification method" under the expected result, never inside it. */
106
+ verification: string[];
93
107
  /** Precondition profile: auth role + scenario-level Given shapes — signature input. */
94
108
  preconditionProfile: string;
95
109
  /** Human preconditions (auth + own Given steps). */
@@ -133,11 +147,21 @@ export interface MatrixDisposition {
133
147
  reason: string;
134
148
  }
135
149
 
150
+ /** One row of the requirement-coverage table (Coverage sheet). */
151
+ export interface RequirementCoverage {
152
+ id: string; // FR-003 / TR-001 / NFR-…
153
+ status: RequirementStatus;
154
+ /** Delivery items whose variants trace to this requirement. */
155
+ items: string[];
156
+ variantCount: number;
157
+ note: string;
158
+ }
159
+
136
160
  // ---------------------------------------------------------------------------
137
161
  // Gate findings
138
162
  // ---------------------------------------------------------------------------
139
163
 
140
- export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G';
164
+ export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'R' | 'W';
141
165
  export type FindingSeverity = 'error' | 'review' | 'warning';
142
166
 
143
167
  export interface MatrixFinding {
@@ -168,6 +192,8 @@ export interface MatrixModel {
168
192
  formNo: string;
169
193
  items: DeliveryItem[];
170
194
  dispositions: MatrixDisposition[];
195
+ /** Requirement coverage — empty when the unit has no requirements/spec.md ids. */
196
+ requirements: RequirementCoverage[];
171
197
  findings: MatrixFinding[];
172
198
  manifest: MatrixManifest;
173
199
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Wording normalization — deterministic rendering step AFTER semantic
3
+ * normalization (review feedback §10): turn sungen DSL steps into controlled
4
+ * manual-test English without changing the target, condition, trigger,
5
+ * precondition, oracle, or trace.
6
+ *
7
+ * - Actions render in the imperative: "User fill [Email] field with X"
8
+ * → "Enter X in the Email field."
9
+ * - Expected results render as observable assertions (never tester actions):
10
+ * "User see [Jobs] page" → "The Jobs page is displayed."
11
+ * - Manual `# Tester verifies:` labels (Setup:/Action:/Observable:/Oracle:)
12
+ * become structured fields instead of prose: Setup → precondition,
13
+ * Action → action, Observable → expected, Oracle → verification method.
14
+ */
15
+
16
+ // `[Email] field` → `Email field` (the visible label + its element type).
17
+ function deRef(text: string): string {
18
+ return text.replace(/\[([^\]]+)\]/g, '$1');
19
+ }
20
+
21
+ function sentence(text: string): string {
22
+ let s = text.trim().replace(/\s+/g, ' ');
23
+ if (!s) return s;
24
+ s = s.charAt(0).toUpperCase() + s.slice(1);
25
+ if (!/[.!?…]$/.test(s)) s += '.';
26
+ return s;
27
+ }
28
+
29
+ /**
30
+ * Render one action step in the imperative. Pattern table covers the common
31
+ * sungen step verbs; anything unmatched falls back to actor-stripped text —
32
+ * still readable, never a raw `User fill`.
33
+ */
34
+ export function renderAction(raw: string): string {
35
+ let s = raw.trim().replace(/^(User|The user)\s+/i, '');
36
+
37
+ const rules: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
38
+ // fill [X] field with V
39
+ [/^fills? \[([^\]]+)\][a-z ]* with (.+)$/i, (m) => `Enter ${m[2]} in the ${m[1]} field`],
40
+ // clear [X] field
41
+ [/^clears? \[([^\]]+)\](.*)$/i, (m) => `Clear the ${m[1]}${m[2] || ' field'}`],
42
+ // click [X] <type>
43
+ [/^clicks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Click the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
44
+ // press <Key> at/in [X] field
45
+ [/^press(?:es)? (.+?) (?:at|in|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
46
+ [/^press(?:es)? (.+)$/i, (m) => `Press ${m[1]}`],
47
+ // select V in/from [X] dropdown
48
+ [/^selects? (.+?) (?:in|from) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Select ${m[1]} in the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
49
+ // check/uncheck [X] checkbox
50
+ [/^(un)?checks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `${m[1] ? 'Uncheck' : 'Check'} the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
51
+ // hover [X]
52
+ [/^hovers? (?:over )?\[([^\]]+)\]\s*(\w+)?$/i, (m) => `Hover over the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
53
+ // upload V to [X]
54
+ [/^uploads? (.+?) (?:to|into) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Upload ${m[1]} to the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
55
+ // is on [X] page (as an action = navigate)
56
+ [/^is on \[([^\]]+)\] page(.*)$/i, (m) => `Open the ${m[1]} page${m[2] ?? ''}`],
57
+ // wait for [X] <type> (is )?visible
58
+ [/^waits? for \[([^\]]+)\]\s*(\w+)?(?: is)?(?: visible)?$/i, (m) => `Wait until the ${m[1]}${m[2] ? ` ${m[2]}` : ''} is visible`],
59
+ // scroll to [X]
60
+ [/^scrolls? (?:to|into) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Scroll to the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
61
+ ];
62
+
63
+ for (const [re, out] of rules) {
64
+ const m = s.match(re);
65
+ if (m) return sentence(deRef(out(m)));
66
+ }
67
+ return sentence(deRef(s));
68
+ }
69
+
70
+ /**
71
+ * Render one expected step as an observable assertion (no tester action, no
72
+ * `should`, no DSL `User see`).
73
+ */
74
+ export function renderExpected(raw: string): string {
75
+ let s = raw.trim().replace(/^(User|The user)\s+/i, '');
76
+
77
+ const rules: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
78
+ // see [X] page
79
+ [/^sees? \[([^\]]+)\] page$/i, (m) => `The ${m[1]} page is displayed`],
80
+ // see [X] <type> with V
81
+ [/^sees? \[([^\]]+)\]\s*(\w+)? with (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} shows ${m[3]}`],
82
+ // see [X] <type> contains V
83
+ [/^sees? \[([^\]]+)\]\s*(\w+)? contains (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} contains ${m[3]}`],
84
+ // see [X] <type> has text V
85
+ [/^sees? \[([^\]]+)\]\s*(\w+)? has text (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} shows ${m[3]}`],
86
+ // see [X] <type> is hidden / is disabled / is enabled / …
87
+ [/^sees? \[([^\]]+)\]\s*(\w+)? is (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is ${m[3]}`],
88
+ // not see [X] <type>
89
+ [/^(?:do(?:es)? )?not sees? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is not displayed`],
90
+ // see [X] <type>
91
+ [/^sees? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is visible`],
92
+ ];
93
+
94
+ for (const [re, out] of rules) {
95
+ const m = s.match(re);
96
+ if (m) return sentence(deRef(out(m)));
97
+ }
98
+ return sentence(deRef(s));
99
+ }
100
+
101
+ /** Precondition wording: a state, not an action ("The user is signed out."). */
102
+ export function renderPrecondition(raw: string): string {
103
+ const s = raw.trim().replace(/^(User|The user)\s+/i, '');
104
+ const m = s.match(/^is on \[([^\]]+)\] page(.*)$/i);
105
+ if (m) return sentence(`The user is on the ${m[1]} page${m[2] ?? ''}`);
106
+ return sentence(deRef(`The user ${s.charAt(0).toLowerCase()}${s.slice(1)}`));
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Manual `# Tester verifies:` comment classification (structured, label-free)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface ManualProcedure {
114
+ /** Setup/Precondition/Arrange lines — the state to establish first. */
115
+ preconditions: string[];
116
+ /** Action/unlabelled lines — the imperative procedure. */
117
+ actions: string[];
118
+ /** Observable/Expect/Result/Assert lines — the observable outcome. */
119
+ expected: string[];
120
+ /** Oracle/Verify lines — HOW to check (tools, panes, queries). */
121
+ verification: string[];
122
+ }
123
+
124
+ /**
125
+ * Split a manual scenario's numbered comment lines into the four structured
126
+ * fields. Labels are consumed (structured), never left inside the prose —
127
+ * review feedback §10.2(3). Continuation lines append to the previous item;
128
+ * pre-amble (rationale/header/dividers) is skipped.
129
+ */
130
+ export function classifyManualComments(comments: string[]): ManualProcedure {
131
+ const out: ManualProcedure = { preconditions: [], actions: [], expected: [], verification: [] };
132
+ let last: { list: string[]; idx: number } | null = null;
133
+
134
+ const bucketOf = (label: string): keyof ManualProcedure => {
135
+ if (/setup|precondition|arrange|given/i.test(label)) return 'preconditions';
136
+ if (/oracle|verify|verification|how to check/i.test(label)) return 'verification';
137
+ if (/observ|expect|result|then|assert/i.test(label)) return 'expected';
138
+ return 'actions';
139
+ };
140
+
141
+ for (const raw of comments) {
142
+ const line = raw.trim();
143
+ if (!line) continue;
144
+ if (/^[-=*_]{2,}/.test(line)) { last = null; continue; }
145
+ const m = line.match(/^\d+[.)]\s*(?:([A-Za-z][A-Za-z /]*?):\s*)?(.+)$/);
146
+ if (m) {
147
+ const label = (m[1] || '').trim();
148
+ const text = m[2].trim();
149
+ const list = out[bucketOf(label)];
150
+ list.push(text);
151
+ last = { list, idx: list.length - 1 };
152
+ } else if (last) {
153
+ last.list[last.idx] += ' ' + line;
154
+ }
155
+ }
156
+ return out;
157
+ }