From e07677be2dac81f40dc9ff047ff4a65ba2fbbe28 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 23:57:16 +0800 Subject: [PATCH] test(metadata-admin): unit-test the flow data-picker token insertion (#1934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the cursor-splice math from VariableTextInput into a pure `insertToken` helper and cover it (+ `formatToken`) directly: bare CEL vs `{var}` template insertion, append / mid-string / selection-replace, and clamping a reversed or out-of-range selection. Pure refactor — no behavior change; the DOM focus/caret restore stays in the component. Co-Authored-By: Claude Opus 4.8 --- .changeset/flow-picker-insert-tests.md | 5 +++ .../inspectors/VariableTextInput.test.ts | 43 +++++++++++++++++++ .../inspectors/VariableTextInput.tsx | 28 +++++++++--- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 .changeset/flow-picker-insert-tests.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.test.ts diff --git a/.changeset/flow-picker-insert-tests.md b/.changeset/flow-picker-insert-tests.md new file mode 100644 index 0000000000..9cefe263ae --- /dev/null +++ b/.changeset/flow-picker-insert-tests.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +Flow builder data-picker (#1934): the cursor-insertion math is extracted into a pure `insertToken` helper with unit tests (alongside `formatToken`) — bare CEL vs `{var}` template insertion, append / mid-string / selection-replace, and clamping a reversed or out-of-range selection. Pure refactor, no behavior change. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.test.ts new file mode 100644 index 0000000000..f340475843 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { formatToken, insertToken } from './VariableTextInput'; + +describe('formatToken', () => { + it('inserts a bare token in expression fields (ADR-0032)', () => { + expect(formatToken('discount_pct', 'expression')).toBe('discount_pct'); + expect(formatToken('record.amount', 'expression')).toBe('record.amount'); + }); + it('wraps a token in single braces in template fields', () => { + expect(formatToken('discount_pct', 'template')).toBe('{discount_pct}'); + expect(formatToken('record.amount', 'template')).toBe('{record.amount}'); + }); +}); + +describe('insertToken (cursor splice)', () => { + it('appends at the caret (end of value) — expression mode, no braces', () => { + const r = insertToken('lead_score >= ', 'expression', 'record.amount', 14, 14); + expect(r.next).toBe('lead_score >= record.amount'); + expect(r.caret).toBe('lead_score >= record.amount'.length); + }); + it('inserts mid-string at the caret', () => { + // caret between "a " and "b": "a | b" + const r = insertToken('a b', 'expression', 'x', 2, 2); + expect(r.next).toBe('a x b'); + expect(r.caret).toBe(3); + }); + it('replaces the current selection', () => { + const r = insertToken('hello OLD world', 'expression', 'NEW', 6, 9); + expect(r.next).toBe('hello NEW world'); + }); + it('wraps with braces in template mode', () => { + const r = insertToken('Hi ', 'template', 'record.name', 3, 3); + expect(r.next).toBe('Hi {record.name}'); + expect(r.caret).toBe('Hi {record.name}'.length); + }); + it('clamps out-of-range / reversed selections instead of corrupting the value', () => { + expect(insertToken('abc', 'expression', 'X', 99, 99).next).toBe('abcX'); + expect(insertToken('abc', 'expression', 'X', 3, 1).next).toBe('aX'); // reversed [3,1] → selects 'bc', replaced + expect(insertToken('', 'template', 'v', -5, -5).next).toBe('{v}'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.tsx index e40164ec81..18a465f651 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/VariableTextInput.tsx @@ -37,6 +37,28 @@ export function formatToken(token: string, mode: VariableFieldMode): string { return mode === 'template' ? `{${token}}` : token; } +/** + * Splice a reference into `value` at the selection `[selStart, selEnd]`, in the + * brace mode for the field, returning the new string and the caret position + * just after the inserted token. Pure (the DOM caret restore lives in the + * component); selection bounds are clamped and order-normalized so a reversed or + * out-of-range selection can't corrupt the value. + */ +export function insertToken( + value: string, + mode: VariableFieldMode, + token: string, + selStart: number, + selEnd: number, +): { next: string; caret: number } { + const text = formatToken(token, mode); + const a = Math.min(Math.max(selStart, 0), value.length); + const b = Math.min(Math.max(selEnd, 0), value.length); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return { next: value.slice(0, lo) + text + value.slice(hi), caret: lo + text.length }; +} + export interface VariableTextInputProps { value: string; onValueChange: (v: string) => void; @@ -95,10 +117,7 @@ export function VariableTextInput({ }; const insert = (token: string) => { - const text = formatToken(token, mode); - const start = Math.min(caret.current.start, value.length); - const end = Math.min(caret.current.end, value.length); - const next = value.slice(0, start) + text + value.slice(end); + const { next, caret: pos } = insertToken(value, mode, token, caret.current.start, caret.current.end); onValueChange(next); setOpen(false); // Restore focus + place the caret just after the inserted token. @@ -106,7 +125,6 @@ export function VariableTextInput({ const el = inputRef.current; if (!el) return; el.focus(); - const pos = start + text.length; try { el.setSelectionRange(pos, pos); } catch {