@sun-asterisk/sungen 3.1.0 → 3.1.1

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 (109) hide show
  1. package/dist/cli/commands/challenge.d.ts.map +1 -1
  2. package/dist/cli/commands/challenge.js +9 -2
  3. package/dist/cli/commands/challenge.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +3 -2
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/commands/generate.d.ts.map +1 -1
  8. package/dist/cli/commands/generate.js +8 -0
  9. package/dist/cli/commands/generate.js.map +1 -1
  10. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  11. package/dist/exporters/csv-exporter.js +92 -76
  12. package/dist/exporters/csv-exporter.js.map +1 -1
  13. package/dist/exporters/spec-parser.d.ts.map +1 -1
  14. package/dist/exporters/spec-parser.js +3 -1
  15. package/dist/exporters/spec-parser.js.map +1 -1
  16. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
  17. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  18. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  19. package/dist/generators/test-generator/code-generator.d.ts +8 -0
  20. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  21. package/dist/generators/test-generator/code-generator.js +107 -3
  22. package/dist/generators/test-generator/code-generator.js.map +1 -1
  23. package/dist/generators/test-generator/patterns/database-patterns.d.ts +2 -1
  24. package/dist/generators/test-generator/patterns/database-patterns.d.ts.map +1 -1
  25. package/dist/generators/test-generator/patterns/database-patterns.js +2 -1
  26. package/dist/generators/test-generator/patterns/database-patterns.js.map +1 -1
  27. package/dist/generators/test-generator/patterns/expect-patterns.d.ts +3 -0
  28. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -0
  29. package/dist/generators/test-generator/patterns/expect-patterns.js +54 -0
  30. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -0
  31. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  32. package/dist/generators/test-generator/patterns/index.js +2 -0
  33. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  34. package/dist/generators/test-generator/step-mapper.d.ts +6 -0
  35. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  36. package/dist/generators/test-generator/step-mapper.js +8 -0
  37. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  38. package/dist/generators/test-generator/template-engine.d.ts +3 -0
  39. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  40. package/dist/generators/test-generator/template-engine.js.map +1 -1
  41. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +1 -1
  42. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  43. package/dist/generators/test-generator/utils/runtime-data-transformer.js +5 -5
  44. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  45. package/dist/harness/challenge.d.ts +1 -0
  46. package/dist/harness/challenge.d.ts.map +1 -1
  47. package/dist/harness/challenge.js +49 -2
  48. package/dist/harness/challenge.js.map +1 -1
  49. package/dist/harness/data-driven-lint.d.ts +7 -0
  50. package/dist/harness/data-driven-lint.d.ts.map +1 -0
  51. package/dist/harness/data-driven-lint.js +153 -0
  52. package/dist/harness/data-driven-lint.js.map +1 -0
  53. package/dist/harness/parse.d.ts +2 -0
  54. package/dist/harness/parse.d.ts.map +1 -1
  55. package/dist/harness/parse.js +16 -0
  56. package/dist/harness/parse.js.map +1 -1
  57. package/dist/harness/query-catalog.d.ts +48 -0
  58. package/dist/harness/query-catalog.d.ts.map +1 -0
  59. package/dist/harness/query-catalog.js +0 -0
  60. package/dist/harness/query-catalog.js.map +1 -0
  61. package/dist/harness/script-check.d.ts.map +1 -1
  62. package/dist/harness/script-check.js +7 -4
  63. package/dist/harness/script-check.js.map +1 -1
  64. package/dist/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  65. package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  66. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  67. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  68. package/dist/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  69. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  70. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  71. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  72. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  73. package/dist/orchestrator/templates/specs-db.d.ts +8 -0
  74. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  75. package/dist/orchestrator/templates/specs-db.js +22 -0
  76. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  77. package/dist/orchestrator/templates/specs-db.ts +22 -0
  78. package/dist/orchestrator/templates/specs-test-data.ts +76 -15
  79. package/package.json +1 -1
  80. package/src/cli/commands/challenge.ts +6 -2
  81. package/src/cli/commands/delivery.ts +3 -2
  82. package/src/cli/commands/generate.ts +8 -0
  83. package/src/exporters/csv-exporter.ts +22 -6
  84. package/src/exporters/spec-parser.ts +3 -1
  85. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -0
  86. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  87. package/src/generators/test-generator/code-generator.ts +105 -3
  88. package/src/generators/test-generator/patterns/database-patterns.ts +2 -1
  89. package/src/generators/test-generator/patterns/expect-patterns.ts +49 -0
  90. package/src/generators/test-generator/patterns/index.ts +2 -0
  91. package/src/generators/test-generator/step-mapper.ts +9 -0
  92. package/src/generators/test-generator/template-engine.ts +3 -0
  93. package/src/generators/test-generator/utils/runtime-data-transformer.ts +5 -5
  94. package/src/harness/challenge.ts +47 -2
  95. package/src/harness/data-driven-lint.ts +119 -0
  96. package/src/harness/parse.ts +12 -0
  97. package/src/harness/query-catalog.ts +0 -0
  98. package/src/harness/script-check.ts +8 -5
  99. package/src/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  100. package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  101. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  102. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  103. package/src/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  104. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  105. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  106. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  107. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  108. package/src/orchestrator/templates/specs-db.ts +22 -0
  109. package/src/orchestrator/templates/specs-test-data.ts +76 -15
@@ -102,6 +102,22 @@ User see [Table] table match data:
102
102
 
103
103
  Row scope: `see [Ref] row in [Table] table with {{v}}` enters scope. Subsequent `see [Col] column with {{v}}` checks cell in that row. Use `table match data:` for multi-row verification.
104
104
 
105
+ ### Database verification (optional Data Driver)
106
+
107
+ Read-only DB-state checks. **Prefer named queries** — SQL lives in `qa/screens/<screen>/database/queries.yaml` (reviewed once, parameterized). Invoke with the `@query:<name>` annotation; it binds the result rows to `{{name}}`, then assert with `expect`:
108
+
109
+ ```gherkin
110
+ @query:active_user # precondition: run query, bind {{active_user}}
111
+ @query:orders(buyer={{email}}) # …with explicit param override
112
+ Scenario: ...
113
+ Then expect {{active_user.count}} is at least {{one}} # ≥1 row
114
+ And expect {{active_user.first.status}} is "active" # first row's column
115
+ And expect {{orders.count}} is {{expected}} # exact count
116
+ And User see [Total] text is {{orders.first.total}} # UI ↔ DB
117
+ ```
118
+
119
+ Path access on a bound result: `{{q.count}}`/`{{q.length}}`, `{{q.first.col}}`, `{{q.last.col}}`, `{{q[2].col}}`, `{{q.col}}` (= first row's col). `expect A is B` also supports `is at least` / `is at most` / `is not`. Tier-2 declarative (trivial inline, no catalog): `User see [<table>] row where [<col>] is {{v}} [has [<col2>] = "x"]`, `… no row where …`, `… count is {{n}}`. Full grammar + catalog/datasource/secret rules → **Advanced → Database** doc. Only emit DB steps when the project has a `database/` catalog / `datasources.yaml`.
120
+
105
121
  ### States
106
122
 
107
123
  `hidden` `visible` `disabled` `enabled` `checked` `unchecked` `focused` `empty` `loading` `selected` `sorted ascending` `sorted descending`
@@ -195,6 +211,30 @@ Options: `nth` `exact` `scope` `match` `variant` `frame` `contenteditable` `colu
195
211
  | `@afterEach` | Hook: runs after each test → `test.afterEach()` (custom cleanup) |
196
212
  | `@afterAll` | Hook: runs once after all tests → `test.afterAll()` |
197
213
  | `@flow` | Mark feature as E2E flow (cross-screen testing) |
214
+ | `@cases:dataset` | Data-driven: run the scenario once per row of the `dataset` LIST in test-data → one `test()` per row |
215
+ | `@query:name` | Database: run the named query from `database/queries.yaml` (precondition) and bind its rows to `{{name}}`; assert with `expect {{name.count}} …` + path access. Override params `@query:name(p={{v}})`. Repeatable. (Optional Data Driver — see Database verification above) |
216
+
217
+ ### Data-driven scenarios (`@cases`)
218
+
219
+ For one test case × many inputs (email/format/boundary validation, decision tables), tag the
220
+ scenario `@cases:<dataset>` and reference each row's columns as `{{col}}`. Put the rows as a LIST
221
+ in test-data — NOT inline; data stays runtime + env-overlayable.
222
+
223
+ ```gherkin
224
+ @high @cases:email_validation
225
+ Scenario: VP-VAL-001 The email field rejects invalid formats
226
+ When User fill [Email] field with {{email}}
227
+ Then User see [Login Error] message with {{expected_error}}
228
+ ```
229
+ ```yaml
230
+ # test-data/<screen>.yaml
231
+ email_validation:
232
+ - { case: "no @", email: "plainaddress", expected_error: "Invalid email" }
233
+ - { case: "valid", email: "ok@x.com", expected_error: "" }
234
+ ```
235
+ An optional `case`/`name`/`label` column labels each run. Each row → its own pass/fail. Prefer
236
+ `@cases` over duplicating a scenario per value. (Gherkin `Scenario Outline`/`Examples` is NOT
237
+ supported — use `@cases`.)
198
238
 
199
239
  ### Pass-through tags (filter at runtime via Playwright --grep)
200
240
 
@@ -54,6 +54,25 @@ user-invocable: false
54
54
  OR condition: generate 1 scenario per branch where that branch alone triggers the outcome.
55
55
  → Happy-path only = missing the most common multi-condition implementation bug.
56
56
 
57
+ - **Many inputs, same steps → ONE data-driven scenario (`@cases`), not N copies:**
58
+ When a rule needs lots of inputs with the *same* step shape (email/format validation,
59
+ BVA boundary triples, EP classes, decision-table rows), tag one scenario `@cases:<dataset>`,
60
+ reference each row's columns as `{{col}}`, and put the rows as a LIST in test-data:
61
+ ```gherkin
62
+ @high @cases:email_validation
63
+ Scenario: VP-VAL-001 The email field rejects invalid formats
64
+ When User fill [Email] field with {{email}}
65
+ Then User see [Error] message with {{expected_error}}
66
+ ```
67
+ ```yaml
68
+ email_validation:
69
+ - { case: "no @", email: "plainaddress", expected_error: "Invalid email" }
70
+ - { case: "valid", email: "ok@x.com", expected_error: "" }
71
+ ```
72
+ → one `test()` per row, each labelled by `case`. Adding inputs = editing test-data (no recompile),
73
+ and env overlays apply. Prefer this over duplicating a scenario per value. (Gherkin
74
+ `Scenario Outline`/`Examples` is NOT supported — use `@cases`.)
75
+
57
76
  ---
58
77
 
59
78
  ## Tier System
@@ -120,6 +120,7 @@ Build a mapping table: for each applicable group, does the feature have a matchi
120
120
  - **EP**: keep only **one representative** per invalid class; same-class duplicates → flag as redundant.
121
121
  - **BVA**: spec defines min/max → cover `min-1`, `min`, `max`, `max+1` (Maxlength, counts…).
122
122
  - Error messages must match the spec **word-for-word**, not generic.
123
+ - **Data-driven (`@cases`)**: a `@cases:<dataset>` scenario legitimately covers many inputs in ONE scenario (one row per EP class / boundary / rule). Do **not** flag it as "too few negative cases" or as duplication — instead review the **dataset rows**: are all EP classes / boundary triples present, each labelled, expected values exact? N near-identical scenarios that differ only by input value → flag and recommend collapsing to `@cases`.
123
124
 
124
125
  ---
125
126
 
@@ -17,6 +17,12 @@ Apply selectively — not every screen needs all four techniques. Use the techni
17
17
 
18
18
  **Rule:** These techniques determine **how many** and **which** scenarios to generate. `sungen-viewpoint` determines **which viewpoints** to cover.
19
19
 
20
+ **Implementing the data table → `@cases` (data-driven):** when EP classes / BVA boundary triples /
21
+ decision-table rows share the *same step shape* and differ only by input/expected values, encode
22
+ them as ONE `@cases:<dataset>` scenario (each class/boundary/rule = one row in the test-data list,
23
+ labelled by a `case` column) instead of N near-duplicate scenarios. The technique still decides the
24
+ rows; `@cases` is how you write them compactly. See `sungen-gherkin-syntax` → Data-driven.
25
+
20
26
  ---
21
27
 
22
28
  ## 1. Equivalence Partitioning (EP)
@@ -102,6 +102,22 @@ User see [Table] table match data:
102
102
 
103
103
  Row scope: `see [Ref] row in [Table] table with {{v}}` enters scope. Subsequent `see [Col] column with {{v}}` checks cell in that row. Use `table match data:` for multi-row verification.
104
104
 
105
+ ### Database verification (optional Data Driver)
106
+
107
+ Read-only DB-state checks. **Prefer named queries** — SQL lives in `qa/screens/<screen>/database/queries.yaml` (reviewed once, parameterized). Invoke with the `@query:<name>` annotation; it binds the result rows to `{{name}}`, then assert with `expect`:
108
+
109
+ ```gherkin
110
+ @query:active_user # precondition: run query, bind {{active_user}}
111
+ @query:orders(buyer={{email}}) # …with explicit param override
112
+ Scenario: ...
113
+ Then expect {{active_user.count}} is at least {{one}} # ≥1 row
114
+ And expect {{active_user.first.status}} is "active" # first row's column
115
+ And expect {{orders.count}} is {{expected}} # exact count
116
+ And User see [Total] text is {{orders.first.total}} # UI ↔ DB
117
+ ```
118
+
119
+ Path access on a bound result: `{{q.count}}`/`{{q.length}}`, `{{q.first.col}}`, `{{q.last.col}}`, `{{q[2].col}}`, `{{q.col}}` (= first row's col). `expect A is B` also supports `is at least` / `is at most` / `is not`. Tier-2 declarative (trivial inline, no catalog): `User see [<table>] row where [<col>] is {{v}} [has [<col2>] = "x"]`, `… no row where …`, `… count is {{n}}`. Full grammar + catalog/datasource/secret rules → **Advanced → Database** doc. Only emit DB steps when the project has a `database/` catalog / `datasources.yaml`.
120
+
105
121
  ### States
106
122
 
107
123
  `hidden` `visible` `disabled` `enabled` `checked` `unchecked` `focused` `empty` `loading` `selected` `sorted ascending` `sorted descending`
@@ -195,6 +211,30 @@ Options: `nth` `exact` `scope` `match` `variant` `frame` `contenteditable` `colu
195
211
  | `@afterEach` | Hook: runs after each test → `test.afterEach()` (custom cleanup) |
196
212
  | `@afterAll` | Hook: runs once after all tests → `test.afterAll()` |
197
213
  | `@flow` | Mark feature as E2E flow (cross-screen testing) |
214
+ | `@cases:dataset` | Data-driven: run the scenario once per row of the `dataset` LIST in test-data → one `test()` per row |
215
+ | `@query:name` | Database: run the named query from `database/queries.yaml` (precondition) and bind its rows to `{{name}}`; assert with `expect {{name.count}} …` + path access. Override params `@query:name(p={{v}})`. Repeatable. (Optional Data Driver — see Database verification above) |
216
+
217
+ ### Data-driven scenarios (`@cases`)
218
+
219
+ For one test case × many inputs (email/format/boundary validation, decision tables), tag the
220
+ scenario `@cases:<dataset>` and reference each row's columns as `{{col}}`. Put the rows as a LIST
221
+ in test-data — NOT inline; data stays runtime + env-overlayable.
222
+
223
+ ```gherkin
224
+ @high @cases:email_validation
225
+ Scenario: VP-VAL-001 The email field rejects invalid formats
226
+ When User fill [Email] field with {{email}}
227
+ Then User see [Login Error] message with {{expected_error}}
228
+ ```
229
+ ```yaml
230
+ # test-data/<screen>.yaml
231
+ email_validation:
232
+ - { case: "no @", email: "plainaddress", expected_error: "Invalid email" }
233
+ - { case: "valid", email: "ok@x.com", expected_error: "" }
234
+ ```
235
+ An optional `case`/`name`/`label` column labels each run. Each row → its own pass/fail. Prefer
236
+ `@cases` over duplicating a scenario per value. (Gherkin `Scenario Outline`/`Examples` is NOT
237
+ supported — use `@cases`.)
198
238
 
199
239
  ### Pass-through tags (filter at runtime via Playwright --grep)
200
240
 
@@ -54,6 +54,25 @@ user-invocable: false
54
54
  OR condition: generate 1 scenario per branch where that branch alone triggers the outcome.
55
55
  → Happy-path only = missing the most common multi-condition implementation bug.
56
56
 
57
+ - **Many inputs, same steps → ONE data-driven scenario (`@cases`), not N copies:**
58
+ When a rule needs lots of inputs with the *same* step shape (email/format validation,
59
+ BVA boundary triples, EP classes, decision-table rows), tag one scenario `@cases:<dataset>`,
60
+ reference each row's columns as `{{col}}`, and put the rows as a LIST in test-data:
61
+ ```gherkin
62
+ @high @cases:email_validation
63
+ Scenario: VP-VAL-001 The email field rejects invalid formats
64
+ When User fill [Email] field with {{email}}
65
+ Then User see [Error] message with {{expected_error}}
66
+ ```
67
+ ```yaml
68
+ email_validation:
69
+ - { case: "no @", email: "plainaddress", expected_error: "Invalid email" }
70
+ - { case: "valid", email: "ok@x.com", expected_error: "" }
71
+ ```
72
+ → one `test()` per row, each labelled by `case`. Adding inputs = editing test-data (no recompile),
73
+ and env overlays apply. Prefer this over duplicating a scenario per value. (Gherkin
74
+ `Scenario Outline`/`Examples` is NOT supported — use `@cases`.)
75
+
57
76
  ---
58
77
 
59
78
  ## Tier System
@@ -120,6 +120,7 @@ Build a mapping table: for each applicable group, does the feature have a matchi
120
120
  - **EP**: keep only **one representative** per invalid class; same-class duplicates → flag as redundant.
121
121
  - **BVA**: spec defines min/max → cover `min-1`, `min`, `max`, `max+1` (Maxlength, counts…).
122
122
  - Error messages must match the spec **word-for-word**, not generic.
123
+ - **Data-driven (`@cases`)**: a `@cases:<dataset>` scenario legitimately covers many inputs in ONE scenario (one row per EP class / boundary / rule). Do **not** flag it as "too few negative cases" or as duplication — instead review the **dataset rows**: are all EP classes / boundary triples present, each labelled, expected values exact? N near-identical scenarios that differ only by input value → flag and recommend collapsing to `@cases`.
123
124
 
124
125
  ---
125
126
 
@@ -17,6 +17,12 @@ Apply selectively — not every screen needs all four techniques. Use the techni
17
17
 
18
18
  **Rule:** These techniques determine **how many** and **which** scenarios to generate. `sungen-viewpoint` determines **which viewpoints** to cover.
19
19
 
20
+ **Implementing the data table → `@cases` (data-driven):** when EP classes / BVA boundary triples /
21
+ decision-table rows share the *same step shape* and differ only by input/expected values, encode
22
+ them as ONE `@cases:<dataset>` scenario (each class/boundary/rule = one row in the test-data list,
23
+ labelled by a `case` column) instead of N near-duplicate scenarios. The technique still decides the
24
+ rows; `@cases` is how you write them compactly. See `sungen-gherkin-syntax` → Data-driven.
25
+
20
26
  ---
21
27
 
22
28
  ## 1. Equivalence Partitioning (EP)
@@ -138,6 +138,28 @@ class DataSource {
138
138
  private sqlFor(conf: DataSourceConfig, sql: string): string {
139
139
  return conf.engine === 'sqlite' ? sql.replace(/\$\d+/g, '?') : sql;
140
140
  }
141
+
142
+ // --- Named queries (catalog-backed; SQL is resolved + embedded at compile time) -----------
143
+ /** Read-only guard (second layer): a named query must be a single SELECT/WITH statement. */
144
+ private assertSelectOnly(label: string, sql: string): void {
145
+ const s = sql.trim().replace(/;\s*$/, '');
146
+ if (!/^(SELECT|WITH)\b/i.test(s)) throw new Error(`Data Driver: ${label} is not a read-only SELECT — refused.`);
147
+ if (s.includes(';')) throw new Error(`Data Driver: ${label} contains multiple statements — refused.`);
148
+ if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|MERGE|REPLACE|CALL|EXEC|EXECUTE|ATTACH|PRAGMA|VACUUM)\b/i.test(s)) {
149
+ throw new Error(`Data Driver: ${label} contains a write/DDL keyword — refused.`);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Run a catalog query (read-only) and return its rows. The result is bound to a `{{name}}`
155
+ * variable via `testData.bind(...)`, so the scenario asserts on it with `expect …` steps and
156
+ * path access (`{{name.count}}`, `{{name.first.col}}`, `{{name[2].col}}`).
157
+ */
158
+ async fetchQuery(label: string, sql: string, params: any[], datasource?: string): Promise<any[]> {
159
+ this.assertSelectOnly(label, sql);
160
+ const { engine, conf } = await this.engine(datasource);
161
+ return engine.query(this.sqlFor(conf, sql), params);
162
+ }
141
163
  }
142
164
 
143
165
  function desc(filter: Record<string, any>): string {
@@ -5,6 +5,8 @@ import yaml from 'yaml';
5
5
 
6
6
  export class TestDataLoader {
7
7
  private data: Record<string, any>;
8
+ // Data-driven (@cases): when set (via withRow), get() prefers this row's columns.
9
+ private row?: Record<string, any>;
8
10
 
9
11
  private constructor(data: Record<string, any>) {
10
12
  this.data = data;
@@ -41,23 +43,56 @@ export class TestDataLoader {
41
43
  }
42
44
 
43
45
  get(key: string): string {
44
- // Captured/runtime vars (set() below) are stored under their literal — possibly
45
- // dotted — key (e.g. "cart.product_name"), so check the flat key first.
46
- let current: any = this.data[key];
47
- if (current === undefined || current === null) {
48
- // Fall back to nested navigation for YAML-structured keys (e.g. "cart.qty_two").
49
- current = this.data;
50
- for (const part of key.split('.')) {
51
- if (current == null || typeof current !== 'object') {
52
- throw new Error(`Test data key not found: ${key} (failed at '${part}')`);
53
- }
54
- current = current[part];
55
- }
56
- }
57
- if (current === undefined || current === null) {
46
+ const value = this.resolve(key);
47
+ if (value === undefined || value === null) {
58
48
  throw new Error(`Test data key not found: ${key}`);
59
49
  }
60
- return String(current);
50
+ return String(value);
51
+ }
52
+
53
+ /**
54
+ * Resolve a `{{...}}` reference to its raw value. Supports:
55
+ * - flat keys (incl. captured runtime vars stored under a literal dotted key);
56
+ * - `@cases` row columns (the current row wins);
57
+ * - structured paths over nested data AND `@query`-bound result arrays:
58
+ * `q.count` / `q.length` → number of rows
59
+ * `q.first.col` / `q.last.col` / `q[2].col` → a specific row's column
60
+ * `q.col` → shorthand for the first row's column
61
+ */
62
+ private resolve(key: string): any {
63
+ // 1. Exact flat key — captured vars (set()) live under a literal, possibly dotted, key.
64
+ if (this.row && key in this.row && this.row[key] !== undefined && this.row[key] !== null) {
65
+ return this.row[key];
66
+ }
67
+ if (this.data[key] !== undefined && this.data[key] !== null) {
68
+ return this.data[key];
69
+ }
70
+ // 2. Structured path: head from the row (cases) or shared data, then walk segments.
71
+ const tokens = String(key).replace(/\[(\d+)\]/g, '.$1').split('.');
72
+ let cur: any = (this.row && tokens[0] in this.row) ? this.row[tokens[0]] : this.data[tokens[0]];
73
+ for (let i = 1; i < tokens.length && cur != null; i++) cur = TestDataLoader.step(cur, tokens[i]);
74
+ return cur;
75
+ }
76
+
77
+ /** One navigation step over an array (with count/first/last/index/field-shorthand) or object. */
78
+ private static step(cur: any, token: string): any {
79
+ if (Array.isArray(cur)) {
80
+ if (token === 'count' || token === 'length') return cur.length;
81
+ if (token === 'first') return cur[0];
82
+ if (token === 'last') return cur[cur.length - 1];
83
+ if (/^\d+$/.test(token)) return cur[Number(token)];
84
+ return cur[0] == null ? undefined : cur[0][token]; // shorthand: first row's field
85
+ }
86
+ if (cur && typeof cur === 'object') return cur[token];
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Bind a raw value (e.g. an `@query` result array) under `key` so `{{key.…}}` paths resolve.
92
+ * Unlike set(), the value is stored as-is (array/object), not coerced to a string.
93
+ */
94
+ bind(key: string, value: any): void {
95
+ this.data[key] = value;
61
96
  }
62
97
 
63
98
  /**
@@ -68,6 +103,32 @@ export class TestDataLoader {
68
103
  set(key: string, value: string): void {
69
104
  this.data[key] = value;
70
105
  }
106
+
107
+ /**
108
+ * Data-driven (@cases): return the list of rows at `key` (after env-overlay merge),
109
+ * each stamped with `__label` for the test title / report. Throws if missing or not a list.
110
+ */
111
+ cases(key: string): Array<Record<string, any>> {
112
+ const list = this.data[key];
113
+ if (!Array.isArray(list)) {
114
+ throw new Error(`@cases dataset "${key}" not found or not a list in test-data (got ${typeof list}).`);
115
+ }
116
+ return list.map((row: any, i: number) => {
117
+ const r: Record<string, any> = (row && typeof row === 'object' && !Array.isArray(row)) ? { ...row } : { value: row };
118
+ r.__label = String(r.case ?? r.name ?? r.label ?? `row ${i + 1}`);
119
+ return r;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Data-driven (@cases): a view whose get() prefers the given row's columns and falls
125
+ * back to the shared data. Used inside the per-row test() loop.
126
+ */
127
+ withRow(row: Record<string, any>): TestDataLoader {
128
+ const view = new TestDataLoader({ ...this.data }); // clone → per-row set() stays isolated
129
+ view.row = row;
130
+ return view;
131
+ }
71
132
  }
72
133
 
73
134
  function loadYamlSync(filePath: string): Record<string, any> | null {