@sungen/driver-data-factory 3.2.12-beta.10 → 3.2.12-beta.11
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/package.json +2 -2
- package/src/cli.ts +5 -2
- package/src/coverage.ts +165 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.2.12-beta.
|
|
3
|
+
"version": "3.2.12-beta.11",
|
|
4
4
|
"description": "Sungen Data Factory capability — standardized test-data generation from a field-type + security knowledge catalog (valid/boundary/invalid + traceability), the .overwrite merge layer, and (opt-in) @dataFactory runtime seeding. Design-time knowledge is runtime-free. Plugs into @sun-asterisk/sungen via the capability SPI.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@sun-asterisk/sungen": "3.2.12-beta.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.12-beta.11",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { expandFieldMap, renderTestData, type FieldMap } from './generator';
|
|
|
15
15
|
import { buildRequest, fingerprint } from './contract';
|
|
16
16
|
import { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, hasDatasource, scaffoldRecipe, renderRecipeYaml } from './state';
|
|
17
17
|
import { crosscheckUnit, crosscheckShared } from './crosscheck';
|
|
18
|
+
import { coverageUnit } from './coverage';
|
|
18
19
|
import { detectPreconditionSignals } from './detect';
|
|
19
20
|
import { lintFieldMap, summarize } from './lint';
|
|
20
21
|
import { validateGenerated } from './validate';
|
|
@@ -238,21 +239,23 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
238
239
|
|
|
239
240
|
data
|
|
240
241
|
.command('crosscheck')
|
|
241
|
-
.description('Cross-artifact Gate
|
|
242
|
+
.description('Cross-artifact + coverage Gate: testcase ↔ data agree (no dangling {{var}}, shared datasets identical) AND the authored data COVERS the required BVA + the login experience checklist')
|
|
242
243
|
.option('--screen <name>', 'Cross-check one unit (default: all field-maps + all shared datasets)')
|
|
243
244
|
.action((opts: { screen?: string }) => {
|
|
244
245
|
const cwd = process.cwd();
|
|
245
246
|
ensureDataFactoryReadme(cwd);
|
|
246
247
|
const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
|
|
248
|
+
const { catalog } = loadEffectiveCatalog(cwd);
|
|
247
249
|
const findings = [
|
|
248
250
|
...names.flatMap((n) => crosscheckUnit(cwd, n)),
|
|
249
251
|
...(opts.screen ? [] : crosscheckShared(cwd)),
|
|
252
|
+
...names.flatMap((n) => coverageUnit(cwd, n, catalog)),
|
|
250
253
|
];
|
|
251
254
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
252
255
|
const warns = findings.filter((f) => f.severity === 'warn').length;
|
|
253
256
|
const badge = errors ? '✗' : warns ? '⚠' : '✓';
|
|
254
257
|
console.log(`${badge} crosscheck: ${errors} error · ${warns} warn (${names.length} unit(s))`);
|
|
255
|
-
for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${f.unit}] ${f.code}: ${f.message}`);
|
|
258
|
+
for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${(f as any).unit}] ${f.code}: ${f.message}`);
|
|
256
259
|
if (errors) process.exitCode = 1;
|
|
257
260
|
});
|
|
258
261
|
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — coverage check (does the AUTHORED test-data actually cover the standardized set?).
|
|
3
|
+
*
|
|
4
|
+
* `data validate` checks the DF-generated field block is internally correct; `data crosscheck` checks
|
|
5
|
+
* testcase ↔ data agree. But `/sungen:create-test` often AUTHORS login/flow test-data by hand (seeded
|
|
6
|
+
* accounts + `@cases`) instead of merging `data gen`'s output — so the deterministic BVA the generator
|
|
7
|
+
* would produce (e.g. email length 4/5/6 for `minLength: 5`) and the experience checklist (soft-deleted
|
|
8
|
+
* login…) can be silently missed. This check reconciles the authored values against BOTH:
|
|
9
|
+
* - the BVA the field-map's constraints REQUIRE (per bounded dimension), and
|
|
10
|
+
* - the auth-account-states experience checklist (for a login/auth unit).
|
|
11
|
+
* It matches fuzzily (authored keys are free-form): length for length dims, numeric value for numeric
|
|
12
|
+
* dims, keyword for experience. Advisory (warn) — a visible nudge the create-test loop must address or
|
|
13
|
+
* the QA waives; never blocks. Pure + deterministic.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import type { Catalog } from './types';
|
|
19
|
+
import { expandField, type FieldMap, type FieldDef } from './generator';
|
|
20
|
+
|
|
21
|
+
export interface CoverageFinding {
|
|
22
|
+
unit: string;
|
|
23
|
+
severity: 'warn' | 'info';
|
|
24
|
+
code: 'BVA_UNCOVERED' | 'EXPERIENCE_UNCOVERED';
|
|
25
|
+
field?: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STR_LEN_TYPES = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana']);
|
|
30
|
+
const NUM_TYPES = new Set(['number-integer', 'number-float', 'currency']);
|
|
31
|
+
|
|
32
|
+
function unitTestDataPath(cwd: string, name: string): string | null {
|
|
33
|
+
for (const kind of ['screens', 'flows']) {
|
|
34
|
+
const p = join(cwd, 'qa', kind, name, 'test-data', `${name}.yaml`);
|
|
35
|
+
if (existsSync(p)) return p;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function unitFeatureText(cwd: string, name: string): string {
|
|
40
|
+
for (const kind of ['screens', 'flows']) {
|
|
41
|
+
const dir = join(cwd, 'qa', kind, name, 'features');
|
|
42
|
+
if (!existsSync(dir)) continue;
|
|
43
|
+
try { return readdirSync(dir).filter((f) => f.endsWith('.feature')).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n'); } catch { return ''; }
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
function loadYaml(p: string): Record<string, any> {
|
|
48
|
+
if (!existsSync(p)) return {};
|
|
49
|
+
try { return (parseYaml(readFileSync(p, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every string value anywhere in the test-data (top-level scalars + nested + `@cases` rows). */
|
|
53
|
+
function collectStrings(data: Record<string, any>): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
const walk = (v: unknown) => {
|
|
56
|
+
if (typeof v === 'string') out.push(v);
|
|
57
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
58
|
+
else if (v && typeof v === 'object') Object.values(v as Record<string, unknown>).forEach(walk);
|
|
59
|
+
};
|
|
60
|
+
for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; walk(v); }
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Short human label for a boundary point (from its note / id). */
|
|
65
|
+
function pointLabel(p: { id: string; note?: string }): string {
|
|
66
|
+
if (p.note) { const tail = p.note.split(/·|:/).pop()?.trim(); if (tail) return tail; }
|
|
67
|
+
return p.id.replace(/^CHK-[A-Z0-9]+-/, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** BVA coverage for one field: which required boundary points are not represented among authored values. */
|
|
71
|
+
function bvaGaps(field: FieldDef, catalog: Catalog, authored: string[]): string[] {
|
|
72
|
+
if (!field.type) return [];
|
|
73
|
+
const ef = expandField(field, catalog);
|
|
74
|
+
const synth = ef.points.filter((p) => p.synthesized && p.value != null);
|
|
75
|
+
if (!synth.length) return [];
|
|
76
|
+
const gaps: string[] = [];
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
if (STR_LEN_TYPES.has(field.type)) {
|
|
79
|
+
// Type-aware filter so an unrelated string of the same LENGTH doesn't falsely "cover" the boundary
|
|
80
|
+
// (e.g. a 5-char URL must not count as covering an email `minLength`-5 case). Email → values with
|
|
81
|
+
// an `@`; katakana → values with kana; others (generic string) stay loose.
|
|
82
|
+
const plausible =
|
|
83
|
+
field.type === 'email' ? authored.filter((s) => s.includes('@'))
|
|
84
|
+
: field.type === 'katakana' ? authored.filter((s) => /[゠-ヿヲ-゚]/.test(s))
|
|
85
|
+
: authored;
|
|
86
|
+
const authoredLens = new Set(plausible.map((s) => s.length));
|
|
87
|
+
for (const p of synth) {
|
|
88
|
+
const len = (p.value as string).length;
|
|
89
|
+
const key = `len:${len}`;
|
|
90
|
+
if (seen.has(key) || authoredLens.has(len)) { seen.add(key); continue; }
|
|
91
|
+
seen.add(key);
|
|
92
|
+
gaps.push(`${pointLabel(p)} (length ${len}, ${p.expectedValidity})`);
|
|
93
|
+
}
|
|
94
|
+
} else if (NUM_TYPES.has(field.type)) {
|
|
95
|
+
const authoredNums = new Set(authored.map((s) => Number(s)).filter((n) => Number.isFinite(n)));
|
|
96
|
+
for (const p of synth) {
|
|
97
|
+
const n = Number(p.value);
|
|
98
|
+
const key = `num:${n}`;
|
|
99
|
+
if (seen.has(key) || authoredNums.has(n)) { seen.add(key); continue; }
|
|
100
|
+
seen.add(key);
|
|
101
|
+
gaps.push(`${pointLabel(p)} (value ${p.value}, ${p.expectedValidity})`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return gaps;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keyword patterns proving an auth-account-state is covered somewhere in the data/feature. */
|
|
108
|
+
const STATE_COVERED: Record<string, RegExp> = {
|
|
109
|
+
active: /valid|active|success|happy/i,
|
|
110
|
+
'wrong-password': /wrong.?pass|password.?mismatch|incorrect.?pass|bad.?pass/i,
|
|
111
|
+
'non-existent-account': /unregister|non.?exist|not.*(exist|found|in nj)|no account/i,
|
|
112
|
+
'soft-deleted': /soft.?delet|deleted.?account|marked.?deleted|deleted_at/i,
|
|
113
|
+
'locked-or-suspended': /lock|suspend|inactive|disabled account/i,
|
|
114
|
+
'unverified-email': /unverif|not.?verified|pending.?verif|email.?verification/i,
|
|
115
|
+
'password-expired': /expir/i,
|
|
116
|
+
// valid-with-surrounding-whitespace is handled by a VALUE-level check (an actual email with
|
|
117
|
+
// leading/trailing space), not this blob regex — the word "trim" alone (from the empty-trim case)
|
|
118
|
+
// must NOT count as covering the valid-after-trim case.
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Coverage check for one unit — BVA gaps per field + auth-experience gaps for a login/auth unit. */
|
|
122
|
+
export function coverageUnit(cwd: string, name: string, catalog: Catalog): CoverageFinding[] {
|
|
123
|
+
const fmPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
|
|
124
|
+
if (!existsSync(fmPath)) return [];
|
|
125
|
+
const map = loadYaml(fmPath) as FieldMap;
|
|
126
|
+
if (!map || !Array.isArray(map.fields)) return [];
|
|
127
|
+
const tdPath = unitTestDataPath(cwd, name);
|
|
128
|
+
const data = tdPath ? loadYaml(tdPath) : {};
|
|
129
|
+
const authored = collectStrings(data);
|
|
130
|
+
const feature = unitFeatureText(cwd, name);
|
|
131
|
+
const blob = (authored.join('\n') + '\n' + JSON.stringify(data) + '\n' + feature).toLowerCase();
|
|
132
|
+
const out: CoverageFinding[] = [];
|
|
133
|
+
|
|
134
|
+
// 1. BVA coverage per field (flatten nested groups to leaves).
|
|
135
|
+
const leaves: FieldDef[] = [];
|
|
136
|
+
const walk = (fs: FieldDef[]) => { for (const f of fs) { if (f.fields) walk(f.fields); else leaves.push(f); } };
|
|
137
|
+
walk(map.fields);
|
|
138
|
+
for (const f of leaves) {
|
|
139
|
+
const gaps = bvaGaps(f, catalog, authored);
|
|
140
|
+
if (gaps.length) {
|
|
141
|
+
out.push({ unit: name, field: f.name, severity: 'warn', code: 'BVA_UNCOVERED',
|
|
142
|
+
message: `field "${f.name}" (${f.type}): ${gaps.length} boundary case(s) not represented in test-data — ${gaps.join('; ')}. The field-map's constraints require them (e.g. minLength → a below-min & at-min value); author them or waive with a reason.` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Auth-experience coverage — only for a login/auth unit (has an email + a password field).
|
|
147
|
+
const isLogin = leaves.some((f) => f.type === 'email') && leaves.some((f) => f.type === 'password');
|
|
148
|
+
const exp = catalog.experience?.['auth-account-states'];
|
|
149
|
+
if (isLogin && exp) {
|
|
150
|
+
// An actual email value carrying leading/trailing whitespace = the valid-after-trim case covered.
|
|
151
|
+
const hasWhitespaceEmail = authored.some((s) => s.includes('@') && s !== s.trim());
|
|
152
|
+
const missing: string[] = [];
|
|
153
|
+
for (const c of exp.cases || []) {
|
|
154
|
+
const state = String((c as any).state || c.id);
|
|
155
|
+
if (state === 'valid-with-surrounding-whitespace') { if (!hasWhitespaceEmail) missing.push(state); continue; }
|
|
156
|
+
const re = STATE_COVERED[state];
|
|
157
|
+
if (re && !re.test(blob)) missing.push(state);
|
|
158
|
+
}
|
|
159
|
+
if (missing.length) {
|
|
160
|
+
out.push({ unit: name, severity: 'warn', code: 'EXPERIENCE_UNCOVERED',
|
|
161
|
+
message: `login unit: auth-account-states not covered — ${missing.join(', ')}. Each needs a credential-state scenario (a seeded account in that state → the correct rejection/route). Add them or waive per spec (some may be N/A).` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ export { crosscheckUnit, crosscheckShared, crosscheckAll, referencedVars } from
|
|
|
36
36
|
export type { CrossFinding } from './crosscheck';
|
|
37
37
|
export { detectUnit, hasInputFields, hasFieldMap, detectPreconditionSignals, hasStateDeclaration } from './detect';
|
|
38
38
|
export type { DetectFinding } from './detect';
|
|
39
|
+
export { coverageUnit } from './coverage';
|
|
40
|
+
export type { CoverageFinding } from './coverage';
|
|
39
41
|
export { registerDataFactoryCommand } from './cli';
|
|
40
42
|
export { generateFixture } from './fixture';
|
|
41
43
|
export type { FixtureResult, FixtureExpected, ExpectedRow } from './fixture';
|