@sungen/driver-ui 3.2.9 → 3.2.10-beta.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-ui",
3
- "version": "3.2.9",
3
+ "version": "3.2.10-beta.10",
4
4
  "description": "Sungen UI capability (Playwright) — step patterns, viewpoint gate, and phase hooks. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.9"
16
+ "@sun-asterisk/sungen": "3.2.10-beta.10"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -0,0 +1,74 @@
1
+ /**
2
+ * App-profile presets shipped with the UI driver (spec: sungen_app_profile_spec.md §3).
3
+ * A preset is itself an AppProfile-shaped object that a project's `qa/app.yaml` pulls in
4
+ * via `extends: <name>` (or `extends: "@sungen/app-<name>"`). The project file always
5
+ * wins over the preset; per-element `variant:`/`scope:` in selectors.yaml wins over both.
6
+ *
7
+ * Keep presets truthful and minimal — every key must be consumed by a compiler slice
8
+ * (portal_roles → dialog-scope exemption; feedback.toast → name policy; widgets → the
9
+ * P3 recipe engine reads these when it lands).
10
+ */
11
+ export const APP_PROFILE_PRESETS: Record<string, unknown> = {
12
+ // Reserved base layer — ALWAYS merged first by the loader (not listed in extends).
13
+ // Ships the built-in widget recipes so a per-element `variant: radix-select` works
14
+ // even in a project with no qa/app.yaml at all.
15
+ builtin: {
16
+ recipes: {
17
+ // Radix UI Select (incl. shadcn/ui): options portal to body with proper ARIA roles.
18
+ 'radix-select': {
19
+ select: [
20
+ { click: '$trigger' },
21
+ { clickText: { role: 'option', text: '$value', exact: true } },
22
+ ],
23
+ },
24
+ // Ant Design Select: closed panels stay in the DOM hidden — scope every lookup
25
+ // to the VISIBLE dropdown panel. `waitFor: attached` first because a select that
26
+ // already holds a value collapses its input to ~0px (the chip covers the click
27
+ // target). `clickText` scrolls the target option into view before clicking, which
28
+ // nudges a lazily-rendered / below-the-fold option list.
29
+ 'antd-select': {
30
+ select: [
31
+ { waitFor: { target: '$trigger', state: 'attached' } },
32
+ { click: '$trigger' },
33
+ { waitFor: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)' },
34
+ { clickText: { within: '.ant-select-dropdown:not(.ant-select-dropdown-hidden)', text: '$value' } },
35
+ ],
36
+ },
37
+ // Ant Design DatePicker (typed input + panel): after typing, the panel keeps
38
+ // blocking pointer events; a single blur dismisses it (Escape/force-click are
39
+ // unreliable). Enter commits the typed value first.
40
+ 'antd-datepicker': {
41
+ fill: [
42
+ { click: '$trigger' },
43
+ { type: '$value' },
44
+ { press: 'Enter' },
45
+ { blur: '$trigger' },
46
+ ],
47
+ },
48
+ },
49
+ },
50
+
51
+ // Classic server-rendered / native-widget apps: no portals anywhere, native <select>.
52
+ native: {
53
+ app: { ui_library: 'native' },
54
+ layers: { portal_roles: [] },
55
+ widgets: { select: 'native' },
56
+ },
57
+
58
+ // Radix UI (incl. shadcn/ui): Select/Menu/Tooltip render through portals onto
59
+ // document.body; Toast has no accessible label by default (name derives from content).
60
+ radix: {
61
+ app: { ui_library: 'radix' },
62
+ layers: { portal_roles: ['option', 'listbox', 'menu', 'menuitem', 'tooltip'] },
63
+ feedback: { toast: { role: 'status', named_by: 'content' } },
64
+ widgets: { select: 'radix-select' },
65
+ },
66
+
67
+ // Ant Design: dropdown/picker panels portal to document.body (and closed panels stay
68
+ // in the DOM hidden — recipes must scope to the VISIBLE panel, handled in P3).
69
+ antd: {
70
+ app: { ui_library: 'antd' },
71
+ layers: { portal_roles: ['option', 'listbox', 'menu', 'menuitem', 'tooltip'] },
72
+ widgets: { select: 'antd-select' },
73
+ },
74
+ };
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  } from '@sun-asterisk/sungen';
27
27
  import { loadCatalog, viewpointGate, assertionDepth, dataThemesFor } from '@sun-asterisk/sungen';
28
28
 
29
+ import { APP_PROFILE_PRESETS } from './app-profile-presets';
29
30
  import { setupPatterns } from './patterns/setup-patterns';
30
31
  import { navigationPatterns } from './patterns/navigation-patterns';
31
32
  import { formPatterns } from './patterns/form-patterns';
@@ -113,6 +114,7 @@ export function register(registry: CapabilityRegistry): void {
113
114
  viewpoints: loadCatalog, // UI owns the 17-pattern universal viewpoint catalog
114
115
  gateProvider: uiGateProvider as (i: unknown) => unknown, // viewpoint coverage + depth
115
116
  sensors: [uiViewpointGateSensor],
117
+ appProfilePresets: APP_PROFILE_PRESETS, // qa/app.yaml `extends: native|radix|antd`
116
118
  });
117
119
  }
118
120
 
@@ -1,5 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
3
+ import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
3
4
 
4
5
  /**
5
6
  * Capture & collection patterns (P5) — enable cross-screen data consistency and
@@ -20,6 +21,44 @@ import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
20
21
  * Roadmap Q2 — the fix for "each card exposes X" claims.
21
22
  */
22
23
  export const capturePatterns: StepPattern[] = [
24
+ {
25
+ // FR2 (issue #468): `User remember [Col] column in [Table] table row {{n}} as {{var}}`
26
+ // — read a cell by POSITION so a later `… in [Table] table row {{n}}` action targets
27
+ // the same `getByRole('row').nth(...)` chain (no read-then-act two-query race).
28
+ // The cell comes from the table entry's `columns:` config (index); without one, the
29
+ // whole row's text is captured.
30
+ // NOTE: the `table-` name prefix opts out of generateStep's parentRef auto-scoping
31
+ // (the table locator is resolved internally from the step text, like all table-* patterns).
32
+ name: 'table-capture-row-nth',
33
+ matcher: (step: ParsedStep) =>
34
+ /\bremember\b/i.test(step.text) && /\bas\s+\{\{[^}]+\}\}/i.test(step.text) && TABLE_ROW_NTH.test(step.text),
35
+ resolver: (step, context): StepTemplateData => {
36
+ const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
37
+ const columnName = brackets[0]?.replace(/[\[\]]/g, '') || '';
38
+ const tableName = brackets.length >= 2 ? brackets[1].replace(/[\[\]]/g, '') : '';
39
+ const rowToken = step.text.match(TABLE_ROW_NTH)![1];
40
+ const varName = step.text.match(/\bas\s+\{\{([^}]+)\}\}/i)![1].trim();
41
+
42
+ const tableResolved = context.selectorResolver.resolveSelector(
43
+ tableName, context.featureName, 'table', 0
44
+ );
45
+
46
+ // Cell by column index from the table entry's `columns:` config.
47
+ const columnKey = columnName.normalize('NFC').toLowerCase().trim().replace(/\s+/g, ' ');
48
+ const column = tableResolved.columns?.[columnKey];
49
+ const cellExpr = column !== undefined
50
+ ? `tableRow.getByRole('cell').nth(${column.index})`
51
+ : 'tableRow';
52
+
53
+ context.dataResolver.registerCaptured(varName);
54
+ return {
55
+ templateName: 'capture-row-column',
56
+ data: { ...tableResolved, varName, cellExpr, rowIndexExpr: rowIndexExpr(rowToken, context) },
57
+ comment: `Remember ${columnName} column in ${tableName} table row ${rowToken} as ${varName}`,
58
+ };
59
+ },
60
+ priority: 36,
61
+ },
23
62
  {
24
63
  name: 'capture-variable',
25
64
  matcher: (step: ParsedStep) =>
@@ -1,5 +1,5 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
- import { StepPattern, StepTemplateData } from '@sun-asterisk/sungen';
2
+ import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
3
3
 
4
4
  /**
5
5
  * Form action patterns: fill, type, select, check, uncheck
@@ -52,6 +52,28 @@ export const formPatterns: StepPattern[] = [
52
52
  value = step.value!;
53
53
  }
54
54
 
55
+ // Widget recipe hook: per-element `variant:` wins; else, for a date-picker element,
56
+ // the profile's `widgets.datepicker` (custom datepickers — readonly inputs that need
57
+ // open-panel choreography). Resolves to a recipe with a `fill` intent.
58
+ const isDatePicker = /date-?picker|calendar/.test(String(step.elementType || ''));
59
+ const fillVariant = resolved.variant || (isDatePicker ? context.appProfile?.widgetVariants?.['datepicker'] : undefined);
60
+ const fillRecipe = fillVariant ? context.appProfile?.recipes?.[fillVariant]?.['fill'] : undefined;
61
+ if (fillRecipe) {
62
+ try {
63
+ const steps = expandRecipe(fillRecipe as unknown[], {
64
+ triggerCode: context.renderLocator(resolved),
65
+ value,
66
+ });
67
+ return {
68
+ templateName: 'recipe-sequence',
69
+ data: { steps },
70
+ comment: `Fill ${step.selectorRef} with ${step.dataRef || step.value} (${fillVariant} recipe)`,
71
+ };
72
+ } catch {
73
+ // malformed recipe → generic fill below
74
+ }
75
+ }
76
+
55
77
  // Use pressSequentially template for contenteditable/rich-text editors (auto-detected by live-scan)
56
78
  // or when Gherkin explicitly uses 'editor' element type
57
79
  const isEditor = resolved.inputMethod === 'pressSequentially' || step.elementType === 'editor';
@@ -132,6 +154,29 @@ export const formPatterns: StepPattern[] = [
132
154
  // A native <select> is detected by its ARIA role, or by an explicit `variant: native`
133
155
  // on the selector entry (lets a CSS/#id-located select opt into .selectOption()).
134
156
  const isNativeSelect = nativeSelectRoles.includes(resolved.role) || resolved.variant === 'native';
157
+ // Widget variant resolution (spec §3): per-element `variant:` wins over the app
158
+ // profile's `widgets.select`. A matching recipe's `select` intent expands to
159
+ // Playwright lines at compile time; native/unknown variants keep the legacy fork.
160
+ const variantName = resolved.variant || context.appProfile?.widgetVariants?.['select'];
161
+ const recipeSteps = variantName && variantName !== 'native'
162
+ ? context.appProfile?.recipes?.[variantName]?.['select']
163
+ : undefined;
164
+
165
+ if (recipeSteps) {
166
+ try {
167
+ const steps = expandRecipe(recipeSteps as unknown[], {
168
+ triggerCode: context.renderLocator(resolved),
169
+ value,
170
+ });
171
+ return {
172
+ templateName: 'recipe-sequence',
173
+ data: { steps },
174
+ comment: `Select ${step.dataRef || step.value} in ${step.selectorRef} (${variantName} recipe)`,
175
+ };
176
+ } catch {
177
+ // malformed recipe → generic fallback below
178
+ }
179
+ }
135
180
 
136
181
  if (isNativeSelect) {
137
182
  templateName = 'select-action';
@@ -9,6 +9,8 @@ import { escapeRegex, pathToRegexSource } from '../utils/url-path-regex';
9
9
  function extractWaitState(text: string): string {
10
10
  if (/\bhidden\b/.test(text)) return 'hidden';
11
11
  if (/\bdetached\b/.test(text)) return 'detached';
12
+ if (/\bdisabled\b/.test(text)) return 'disabled';
13
+ if (/\benabled\b/.test(text)) return 'enabled';
12
14
  if (/\bvisible\b/.test(text)) return 'visible';
13
15
  return 'visible';
14
16
  }
@@ -32,7 +34,42 @@ function elementKeywordToRole(text: string): string | null {
32
34
  */
33
35
  export const interactionPatterns: StepPattern[] = [
34
36
  // === Browser Alert (system dialog) patterns ===
35
- // These must appear BEFORE the click that triggers the alert in Gherkin:
37
+ // Compound form (preferred since v3.3): trigger + dialog response in ONE step, in the
38
+ // user's natural order — the compiler emits the listener FIRST (Playwright requires
39
+ // registration before the triggering action; a late handler lets the dialog
40
+ // auto-dismiss with no error):
41
+ // When User click [Đăng xuất] button and accept [OK] alert
42
+ // When User click [Xoá] button and dismiss [Cancel] alert
43
+ {
44
+ name: 'click-with-alert',
45
+ matcher: (step: ParsedStep) =>
46
+ /\b(click|clicks)\b/.test(step.text) &&
47
+ /\band\s+(accept|accepts|dismiss|dismisses)\s+\[[^\]]*\]\s+alert\b/i.test(step.text) &&
48
+ (step.text.match(/\[[^\]]+\]/g) || []).length >= 2,
49
+ resolver: (step, context) => {
50
+ // brackets[0] = trigger element, brackets[1] = alert button (verb decides the action)
51
+ const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
52
+ const triggerName = brackets[0].replace(/[\[\]]/g, '');
53
+ const dialogAction = /\band\s+dismiss(?:es)?\b/i.test(step.text) ? 'dismiss' : 'accept';
54
+
55
+ // Element type = the word right after the trigger bracket (button/link/…)
56
+ const typeMatch = step.text.match(/\]\s+([\w-]+)\s+and\b/i);
57
+ const triggerType = typeMatch ? typeMatch[1].toLowerCase() : 'button';
58
+
59
+ const resolved = context.selectorResolver.resolveSelector(
60
+ triggerName, context.featureName, triggerType, step.nth
61
+ );
62
+
63
+ return {
64
+ templateName: 'click-with-alert-action',
65
+ data: { ...resolved, dialogAction },
66
+ comment: `Click ${triggerName} and ${dialogAction} the browser dialog`,
67
+ };
68
+ },
69
+ priority: 21,
70
+ },
71
+ // Two-step form (still valid): the alert step must appear BEFORE the click that
72
+ // triggers the alert in Gherkin — SG-W007 warns when it is written after:
36
73
  // When User click [OK] alert ← registers page.once('dialog', ...)
37
74
  // And User click [Delete] button ← triggers the alert
38
75
  {
@@ -98,12 +135,14 @@ export const interactionPatterns: StepPattern[] = [
98
135
  resolver: (step, context) => {
99
136
  let selectorValue = '';
100
137
  let nth: number | undefined;
138
+ let exact = false;
101
139
  try {
102
140
  const resolved = context.selectorResolver.resolveSelector(
103
141
  step.selectorRef!, context.featureName, step.elementType, step.nth
104
142
  );
105
143
  selectorValue = resolved.value || '';
106
144
  nth = resolved.nth;
145
+ exact = resolved.exact === true;
107
146
  } catch (error) {
108
147
  selectorValue = '';
109
148
  nth = undefined;
@@ -122,7 +161,7 @@ export const interactionPatterns: StepPattern[] = [
122
161
 
123
162
  return {
124
163
  templateName: 'unknown-element-action',
125
- data: { selectorValue, dataValue, action: actionMethod, nth },
164
+ data: { selectorValue, dataValue, action: actionMethod, nth, exact },
126
165
  comment: `${actionMethod.charAt(0).toUpperCase() + actionMethod.slice(1)} ${step.selectorRef} with ${step.dataRef}`,
127
166
  };
128
167
  },
@@ -336,6 +375,21 @@ export const interactionPatterns: StepPattern[] = [
336
375
  },
337
376
  priority: 9,
338
377
  },
378
+ {
379
+ // FR3 (issue #468): `User wait for [T] table to refresh` — the dedicated wait for
380
+ // filter/search/pagination round-trips. Signal = the app profile's loading indicator
381
+ // (qa/app.yaml feedback.loading.indicator, default [aria-busy="true"]): soft-wait for
382
+ // it to appear (the spinner may already be gone), hard-wait for it to disappear.
383
+ name: 'wait-table-refresh',
384
+ matcher: (step: ParsedStep) =>
385
+ /\bwait(?:s)?\s+for\b/i.test(step.text) && /\btable\s+to\s+refresh\b/i.test(step.text),
386
+ resolver: (step, context) => ({
387
+ templateName: 'wait-table-refresh',
388
+ data: { indicator: context.appProfile?.loadingIndicator ?? '[aria-busy="true"]' },
389
+ comment: `Wait for ${step.selectorRef || 'table'} to refresh`,
390
+ }),
391
+ priority: 12,
392
+ },
339
393
  {
340
394
  name: 'wait-for-element-with-text',
341
395
  matcher: (step: ParsedStep) =>
@@ -35,19 +35,35 @@ export const navigationPatterns: StepPattern[] = [
35
35
  const finalPath = resolvePathVariables(path, context.scenarioSteps || []);
36
36
  const isAbsoluteUrl = /^https?:\/\//.test(finalPath);
37
37
 
38
- // Then User is on [X] page — assert URL, don't navigate.
38
+ // `[X] page with {{v}}` — the query/fragment value parsed into dataRef (issue #464).
39
+ // Raw concat, no "smart" URL merging: the primary use case is injecting malformed
40
+ // query input, so the compiler must not sanitize what the test wants broken.
41
+ // Runtime mode resolves to a __SUNGEN_TD__ marker → `path + testData.get(...)`.
42
+ let query = '';
43
+ if (step.dataRef) {
44
+ try {
45
+ query = context.dataResolver.resolveData(step.dataRef, context.featureName);
46
+ } catch {
47
+ query = `\${${step.dataRef}}`;
48
+ }
49
+ }
50
+ const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
51
+
52
+ // Then User is on [X] page — assert URL, don't navigate. The query is asserted
53
+ // separately (toHaveURL's regex stays compile-time escapable; the query value is
54
+ // runtime data with regex-special characters).
39
55
  if (isThen) {
40
56
  return {
41
57
  templateName: 'route-assertion',
42
- data: { path: finalPath },
43
- comment: step.selectorRef ? `Assert on ${step.selectorRef} page` : `Assert page route`,
58
+ data: { path: finalPath, query },
59
+ comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
44
60
  };
45
61
  }
46
62
 
47
63
  return {
48
64
  templateName: 'navigation',
49
- data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath },
50
- comment: step.selectorRef ? `Open ${step.selectorRef} page` : `Navigate to page`,
65
+ data: { baseURL: isAbsoluteUrl ? undefined : context.baseURL, path: finalPath + query },
66
+ comment: step.selectorRef ? `Open ${step.selectorRef} page${withSuffix}` : `Navigate to page`,
51
67
  };
52
68
  },
53
69
  priority: 16,
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { StepPattern, PatternContext } from '@sun-asterisk/sungen';
19
+ import { rowIndexExpr, TABLE_ROW_NTH } from '../utils/row-index';
19
20
 
20
21
  /** Helper: check if current step is in a Given context */
21
22
  function isGivenContext(context: PatternContext): boolean {
@@ -179,6 +180,57 @@ export const tablePatterns: StepPattern[] = [
179
180
  },
180
181
 
181
182
  // "User click [Act] button in [Table] table with {{filter}}" — action in row
183
+ {
184
+ // FR2 (issue #468): positional row — `User click [Act] button in [Table] table row {{n}}`.
185
+ // Selecting by POSITION (not content) compiles remember+act steps onto the same
186
+ // `getByRole('row').nth(...)` chain, eliminating the read-then-act two-query race
187
+ // where a re-render between queries hits two different rows. Row numbering follows
188
+ // qa/app.yaml conventions.table (row_base, header_is_row).
189
+ name: 'table-action-in-row-nth',
190
+ matcher: (step) => {
191
+ return TABLE_ROW_NTH.test(step.text) &&
192
+ /\b(click|clicks|check|checks|uncheck|unchecks)\b/i.test(step.text) &&
193
+ !/\bremember\b/i.test(step.text) &&
194
+ /\bin\b.*\btable\b/i.test(step.text);
195
+ },
196
+ resolver: (step, context) => {
197
+ const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
198
+ const elementName = brackets[0]?.replace(/[\[\]]/g, '') || '';
199
+ const tableName = brackets.length >= 2 ? brackets[1].replace(/[\[\]]/g, '') : '';
200
+ const rowToken = step.text.match(TABLE_ROW_NTH)![1];
201
+
202
+ const tableResolved = context.selectorResolver.resolveSelector(
203
+ tableName, context.featureName, 'table', 0
204
+ );
205
+
206
+ const actionMatch = step.text.match(/\b(click|check|uncheck)\b/i);
207
+ const action = actionMatch ? actionMatch[1].toLowerCase() : 'click';
208
+
209
+ const elementTypeMatch = step.text.match(/\]\s+([\w-]+)\s+in\b/i);
210
+ const elementType = elementTypeMatch ? elementTypeMatch[1].toLowerCase() : 'button';
211
+ const elementRole = typeToRole[elementType] || elementType;
212
+
213
+ let actionLocator: string;
214
+ try {
215
+ const elementResolved = context.selectorResolver.resolveSelector(
216
+ elementName, context.featureName, elementType, step.nth
217
+ );
218
+ actionLocator = context.renderLocator({ ...elementResolved, parentLocator: 'tableRow' });
219
+ } catch {
220
+ const escapedName = String(elementName)
221
+ .replace(/\\/g, '\\\\').replace(/'/g, "\\'")
222
+ .replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
223
+ actionLocator = `tableRow.getByRole('${elementRole}', { name: '${escapedName}' })`;
224
+ }
225
+
226
+ return {
227
+ templateName: 'table-action-in-row-nth',
228
+ data: { ...tableResolved, elementName, action, actionLocator, rowIndexExpr: rowIndexExpr(rowToken, context) },
229
+ comment: `${action} ${elementName} in ${tableName} table row ${rowToken}`,
230
+ };
231
+ },
232
+ priority: 18,
233
+ },
182
234
  {
183
235
  name: 'table-action-in-row',
184
236
  matcher: (step) => {
@@ -0,0 +1,33 @@
1
+ import { PatternContext } from '@sun-asterisk/sungen';
2
+
3
+ /**
4
+ * Positional-row index expression for `… table row {{n}}` steps (FR2, issue #468).
5
+ *
6
+ * QA counts rows from `conventions.table.row_base` (default 1) and the header <tr>
7
+ * usually carries role=row at getByRole('row') index 0 (`header_is_row: false`), so:
8
+ * nth index = n + (header_is_row ? 0 : 1) - row_base
9
+ *
10
+ * `rowToken` is the literal number or `{{var}}` from the step text. A runtime marker
11
+ * becomes `Number(testData.get('var'))` via the runtime-data transform, so the offset
12
+ * is emitted as ` + k` / ` - k` around it.
13
+ */
14
+ export function rowIndexExpr(rowToken: string, context: PatternContext): string {
15
+ const rowBase = context.appProfile?.tableRowBase ?? 1;
16
+ const offset = (context.appProfile?.tableHeaderIsRow ? 0 : 1) - rowBase;
17
+
18
+ const varMatch = rowToken.match(/^\{\{([^}]+)\}\}$/);
19
+ if (!varMatch) return String(Number(rowToken) + offset);
20
+
21
+ let expr: string;
22
+ try {
23
+ expr = context.dataResolver.resolveData(varMatch[1].trim(), context.featureName);
24
+ } catch {
25
+ expr = `\${${varMatch[1].trim()}}`;
26
+ }
27
+ if (/^\d+$/.test(expr)) return String(Number(expr) + offset); // inline-data mode
28
+ if (offset === 0) return expr;
29
+ return offset > 0 ? `${expr} + ${offset}` : `${expr} - ${-offset}`;
30
+ }
31
+
32
+ /** Matches `table row 2` / `table row {{n}}` and captures the row token. */
33
+ export const TABLE_ROW_NTH = /\btable\s+row\s+(\d+|\{\{[^}]+\}\})/i;