|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | | -import { toEntries, rowsToValue, type Row } from './FlowKeyValueField'; |
| 4 | +import { toEntries, rowsToValue, toRows, switchRowMode, type Row } from './FlowKeyValueField'; |
| 5 | +import { valueEnvelopeRefusal } from './flow-value-envelope'; |
5 | 6 |
|
6 | 7 | const row = (key: string, raw: string): Row => ({ id: key, key, raw }); |
7 | 8 |
|
@@ -51,3 +52,76 @@ describe('FlowKeyValueField shape handling (#1934 — assignment array form)', ( |
51 | 52 | expect((out as Array<Record<string, unknown>>).map((e) => e.variable)).toEqual(['lead_score', 'enrichment_data']); |
52 | 53 | }); |
53 | 54 | }); |
| 55 | + |
| 56 | +/** |
| 57 | + * objectui#7588 — the text / expression choice on a `value`-role slot, pinned |
| 58 | + * through BOTH storage paths `rowsToValue` writes (the legacy array and the |
| 59 | + * canonical map). A stored value that nobody edits must come back unchanged. |
| 60 | + */ |
| 61 | +describe('FlowKeyValueField value-envelope slot (objectui#7588)', () => { |
| 62 | + const roundTrip = (stored: unknown, arrayShape: boolean) => rowsToValue(toRows(stored, [], true), arrayShape); |
| 63 | + |
| 64 | + it('round-trips a `{token}` string byte-identically in the MAP and the ARRAY shape', () => { |
| 65 | + const token = '{record.first_name} {record.last_name}'; |
| 66 | + const map = roundTrip({ full_name: token, bare: '{amount}' }, false) as Record<string, unknown>; |
| 67 | + expect(map.full_name).toBe(token); |
| 68 | + expect(map.bare).toBe('{amount}'); |
| 69 | + const list = roundTrip([{ variable: 'full_name', value: token }], true); |
| 70 | + expect(list).toEqual([{ variable: 'full_name', value: token }]); |
| 71 | + // Never normalized into an envelope: the text form stays a string. |
| 72 | + expect(toRows({ full_name: token }, [], true)[0].mode).toBeUndefined(); |
| 73 | + }); |
| 74 | + |
| 75 | + it('round-trips a CEL value envelope in the MAP as the same object, and in the ARRAY as the literal it is', () => { |
| 76 | + const envelope = { dialect: 'cel', source: 'joinNonEmpty(names, ", ")', meta: { rationale: 'digest' } }; |
| 77 | + const map = roundTrip({ digest: envelope }, false) as Record<string, unknown>; |
| 78 | + expect(map.digest).toBe(envelope); |
| 79 | + expect(toRows({ digest: envelope }, [], true)[0]).toMatchObject({ mode: 'expression', raw: envelope.source }); |
| 80 | + // In the legacy array an envelope-shaped object is a literal, so it is a |
| 81 | + // text row showing its JSON and it comes back deep-equal. |
| 82 | + const listRows = toRows([{ variable: 'digest', value: envelope }], [], true); |
| 83 | + expect(listRows[0].mode).toBeUndefined(); |
| 84 | + expect(rowsToValue(listRows, true)).toEqual([{ variable: 'digest', value: envelope }]); |
| 85 | + }); |
| 86 | + |
| 87 | + it('writes an expression row as a `{ dialect, source }` envelope, never as a string', () => { |
| 88 | + const rows: Row[] = [switchRowMode(row('total', 'amount * 2'), 'expression'), row('label', '{name}')]; |
| 89 | + expect(rowsToValue(rows, false)).toEqual({ total: { dialect: 'cel', source: 'amount * 2' }, label: '{name}' }); |
| 90 | + }); |
| 91 | + |
| 92 | + it('moves a legacy ARRAY to the map once a row is an expression, leaving the other values unchanged', () => { |
| 93 | + const rows = toRows([{ variable: 'label', value: '{name}' }, { variable: 'n', value: 3 }], [], true); |
| 94 | + rows[1] = switchRowMode(rows[1], 'expression'); |
| 95 | + expect(rowsToValue(rows, true)).toEqual({ label: '{name}', n: { dialect: 'cel', source: '3' } }); |
| 96 | + }); |
| 97 | + |
| 98 | + it('keeps a malformed envelope as the stored object with the spec refusal, not as a silent string', () => { |
| 99 | + const malformed = { dialect: 'cel' }; |
| 100 | + const rows = toRows({ bad: malformed }, [], true); |
| 101 | + expect(rows[0].mode).toBeUndefined(); |
| 102 | + const out = rowsToValue(rows, false) as Record<string, unknown>; |
| 103 | + expect(out.bad).toEqual(malformed); |
| 104 | + expect(valueEnvelopeRefusal(out.bad)).not.toBeNull(); |
| 105 | + // A blank expression is written as an envelope the spec refuses, not as ''. |
| 106 | + const blank = rowsToValue([switchRowMode(row('empty', ''), 'expression')], false) as Record<string, unknown>; |
| 107 | + expect(blank.empty).toEqual({ dialect: 'cel', source: '' }); |
| 108 | + expect(valueEnvelopeRefusal(blank.empty)).not.toBeNull(); |
| 109 | + }); |
| 110 | + |
| 111 | + it('switches text and expression both ways without losing the typed text', () => { |
| 112 | + const expr = switchRowMode(row('x', 'a + b'), 'expression'); |
| 113 | + expect(expr).toMatchObject({ mode: 'expression', raw: 'a + b' }); |
| 114 | + expect(switchRowMode(expr, 'text')).toEqual(row('x', 'a + b')); |
| 115 | + // Text that already is an envelope's JSON becomes that envelope. |
| 116 | + const adopted = switchRowMode(row('x', '{"dialect":"cel","source":"a"}'), 'expression'); |
| 117 | + expect(adopted).toMatchObject({ mode: 'expression', raw: 'a', envelope: { dialect: 'cel', source: 'a' } }); |
| 118 | + }); |
| 119 | + |
| 120 | + it('leaves every map that is NOT a value-envelope slot exactly as before', () => { |
| 121 | + const envelope = { dialect: 'cel', source: 'a' }; |
| 122 | + const rows = toRows({ param: envelope }, []); |
| 123 | + expect(rows[0].mode).toBeUndefined(); |
| 124 | + expect(rows[0].raw).toBe(JSON.stringify(envelope)); |
| 125 | + expect(rowsToValue(rows, false)).toEqual({ param: envelope }); |
| 126 | + }); |
| 127 | +}); |
0 commit comments