|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | + |
| 3 | +import { processRichTextField } from './processRichTextField.js' |
| 4 | + |
| 5 | +// Minimal helpers to keep assertions readable |
| 6 | +const asRecord = (v: unknown) => v as Record<string, unknown> |
| 7 | +const asArray = (v: unknown) => v as unknown[] |
| 8 | + |
| 9 | +// ─── Fixtures ──────────────────────────────────────────────────────────────── |
| 10 | + |
| 11 | +const textNode = (overrides: Record<string, unknown> = {}) => ({ |
| 12 | + detail: 0, |
| 13 | + format: 0, |
| 14 | + mode: 'normal', |
| 15 | + text: 'Hello', |
| 16 | + type: 'text', |
| 17 | + version: 1, |
| 18 | + ...overrides, |
| 19 | +}) |
| 20 | + |
| 21 | +const paragraphNode = (children: unknown[] = [textNode()]) => ({ |
| 22 | + children, |
| 23 | + direction: 'ltr', |
| 24 | + format: '', |
| 25 | + indent: 0, |
| 26 | + type: 'paragraph', |
| 27 | + version: 1, |
| 28 | +}) |
| 29 | + |
| 30 | +const lexicalDoc = (children: unknown[] = [paragraphNode()]) => ({ |
| 31 | + children, |
| 32 | + direction: 'ltr', |
| 33 | + format: '', |
| 34 | + indent: 0, |
| 35 | + type: 'root', |
| 36 | + version: 1, |
| 37 | +}) |
| 38 | + |
| 39 | +// ─── Primitive pass-through ────────────────────────────────────────────────── |
| 40 | + |
| 41 | +describe('processRichTextField — primitive pass-through', () => { |
| 42 | + it('should return null unchanged', () => { |
| 43 | + expect(processRichTextField(null)).toBeNull() |
| 44 | + }) |
| 45 | + |
| 46 | + it('should return undefined unchanged', () => { |
| 47 | + expect(processRichTextField(undefined)).toBeUndefined() |
| 48 | + }) |
| 49 | + |
| 50 | + it('should return a number unchanged', () => { |
| 51 | + expect(processRichTextField(42)).toBe(42) |
| 52 | + }) |
| 53 | + |
| 54 | + it('should return false unchanged', () => { |
| 55 | + expect(processRichTextField(false)).toBe(false) |
| 56 | + }) |
| 57 | +}) |
| 58 | + |
| 59 | +// ─── String input (the recovered-from-missed-hook path) ───────────────────── |
| 60 | + |
| 61 | +describe('processRichTextField — JSON string input', () => { |
| 62 | + it('should parse a valid JSON string into an object', () => { |
| 63 | + const doc = lexicalDoc() |
| 64 | + expect(processRichTextField(JSON.stringify(doc))).toEqual(doc) |
| 65 | + }) |
| 66 | + |
| 67 | + it('should return an unparseable string unchanged', () => { |
| 68 | + expect(processRichTextField('not json')).toBe('not json') |
| 69 | + }) |
| 70 | + |
| 71 | + it('should return an empty string unchanged', () => { |
| 72 | + expect(processRichTextField('')).toBe('') |
| 73 | + }) |
| 74 | + |
| 75 | + it('should parse a string and still convert numeric string properties', () => { |
| 76 | + const node = { type: 'text', detail: '0', format: '3', indent: '0', version: '1', text: 'hi' } |
| 77 | + const result = asRecord(processRichTextField(JSON.stringify(node))) |
| 78 | + expect(result.detail).toBe(0) |
| 79 | + expect(result.format).toBe(3) |
| 80 | + expect(result.indent).toBe(0) |
| 81 | + expect(result.version).toBe(1) |
| 82 | + }) |
| 83 | +}) |
| 84 | + |
| 85 | +// ─── Numeric property coercion ─────────────────────────────────────────────── |
| 86 | + |
| 87 | +describe('processRichTextField — numeric property coercion', () => { |
| 88 | + it('should convert detail, format, indent, version from strings to numbers', () => { |
| 89 | + const input = textNode({ detail: '0', format: '0', indent: '0', version: '1' }) |
| 90 | + const result = asRecord(processRichTextField(input)) |
| 91 | + expect(result.detail).toBe(0) |
| 92 | + expect(result.format).toBe(0) |
| 93 | + expect(result.indent).toBe(0) |
| 94 | + expect(result.version).toBe(1) |
| 95 | + }) |
| 96 | + |
| 97 | + it('should convert start and value from strings to numbers (list/listitem nodes)', () => { |
| 98 | + const listItemNode = { type: 'listitem', start: '1', value: '2', version: '1' } |
| 99 | + const result = asRecord(processRichTextField(listItemNode)) |
| 100 | + expect(result.start).toBe(1) |
| 101 | + expect(result.value).toBe(2) |
| 102 | + }) |
| 103 | + |
| 104 | + it('should convert textFormat and textStyle from strings to numbers', () => { |
| 105 | + const input = { type: 'text', textFormat: '4', textStyle: '0', version: '1', text: 'x' } |
| 106 | + const result = asRecord(processRichTextField(input)) |
| 107 | + expect(result.textFormat).toBe(4) |
| 108 | + expect(result.textStyle).toBe(0) |
| 109 | + }) |
| 110 | + |
| 111 | + it('should leave a non-numeric string in a numeric-property slot unchanged', () => { |
| 112 | + // Lexical paragraph format can be "" (empty) or alignment strings |
| 113 | + const input = paragraphNode() |
| 114 | + const result = asRecord(processRichTextField(input)) |
| 115 | + expect(result.format).toBe('') |
| 116 | + }) |
| 117 | + |
| 118 | + it('should leave already-numeric values unchanged', () => { |
| 119 | + const input = textNode({ detail: 0, format: 3, version: 1 }) |
| 120 | + const result = asRecord(processRichTextField(input)) |
| 121 | + expect(result.detail).toBe(0) |
| 122 | + expect(result.format).toBe(3) |
| 123 | + expect(result.version).toBe(1) |
| 124 | + }) |
| 125 | + |
| 126 | + it('should not convert boolean values on numeric-property keys', () => { |
| 127 | + const input = { type: 'custom', format: true, version: '1' } |
| 128 | + const result = asRecord(processRichTextField(input)) |
| 129 | + expect(result.format).toBe(true) |
| 130 | + expect(result.version).toBe(1) |
| 131 | + }) |
| 132 | +}) |
| 133 | + |
| 134 | +// ─── Recursive children processing ────────────────────────────────────────── |
| 135 | + |
| 136 | +describe('processRichTextField — recursive children', () => { |
| 137 | + it('should recursively convert numeric strings inside children arrays', () => { |
| 138 | + const input = paragraphNode([textNode({ detail: '0', format: '0', version: '1' })]) |
| 139 | + const result = asRecord(processRichTextField(input)) |
| 140 | + const child = asRecord(asArray(result.children)[0]) |
| 141 | + expect(child.detail).toBe(0) |
| 142 | + expect(child.format).toBe(0) |
| 143 | + expect(child.version).toBe(1) |
| 144 | + }) |
| 145 | + |
| 146 | + it('should handle three levels of nesting (root → paragraph → text)', () => { |
| 147 | + const input = lexicalDoc([paragraphNode([textNode({ version: '1', format: '2' })])]) |
| 148 | + const result = asRecord(processRichTextField(input)) |
| 149 | + const paragraph = asRecord(asArray(result.children)[0]) |
| 150 | + const text = asRecord(asArray(paragraph.children)[0]) |
| 151 | + expect(text.version).toBe(1) |
| 152 | + expect(text.format).toBe(2) |
| 153 | + }) |
| 154 | + |
| 155 | + it('should handle multiple children at the same level', () => { |
| 156 | + const input = lexicalDoc([ |
| 157 | + paragraphNode([ |
| 158 | + textNode({ text: 'Bold', format: '1' }), |
| 159 | + textNode({ text: ' normal', format: '0' }), |
| 160 | + ]), |
| 161 | + paragraphNode([textNode({ text: 'Second para', version: '1' })]), |
| 162 | + ]) |
| 163 | + const result = asRecord(processRichTextField(input)) |
| 164 | + const firstPara = asRecord(asArray(result.children)[0]) |
| 165 | + const secondPara = asRecord(asArray(result.children)[1]) |
| 166 | + const bold = asRecord(asArray(firstPara.children)[0]) |
| 167 | + const normal = asRecord(asArray(firstPara.children)[1]) |
| 168 | + const secondText = asRecord(asArray(secondPara.children)[0]) |
| 169 | + expect(bold.format).toBe(1) |
| 170 | + expect(normal.format).toBe(0) |
| 171 | + expect(secondText.version).toBe(1) |
| 172 | + }) |
| 173 | + |
| 174 | + it('should skip null and non-object entries in children without throwing', () => { |
| 175 | + const input = { type: 'root', version: 1, children: [null, undefined, 'stray', textNode()] } |
| 176 | + const result = asRecord(processRichTextField(input)) |
| 177 | + const children = asArray(result.children) |
| 178 | + expect(children[0]).toBeNull() |
| 179 | + expect(children[1]).toBeUndefined() |
| 180 | + expect(children[2]).toBe('stray') |
| 181 | + expect(asRecord(children[3]).type).toBe('text') |
| 182 | + }) |
| 183 | +}) |
| 184 | + |
| 185 | +// ─── Nested non-children objects ───────────────────────────────────────────── |
| 186 | + |
| 187 | +describe('processRichTextField — nested non-children objects', () => { |
| 188 | + it('should recurse into plain nested objects (e.g. decorator node data)', () => { |
| 189 | + const input = { |
| 190 | + type: 'decorator', |
| 191 | + version: '1', |
| 192 | + data: { detail: '2', label: 'keep me' }, |
| 193 | + } |
| 194 | + const result = asRecord(processRichTextField(input)) |
| 195 | + const data = asRecord(result.data) |
| 196 | + expect(result.version).toBe(1) |
| 197 | + expect(data.detail).toBe(2) |
| 198 | + expect(data.label).toBe('keep me') |
| 199 | + }) |
| 200 | +}) |
| 201 | + |
| 202 | +// ─── Full Lexical document round-trips ─────────────────────────────────────── |
| 203 | + |
| 204 | +describe('processRichTextField — full document', () => { |
| 205 | + it('should produce a stable result when called on an already-processed object', () => { |
| 206 | + const doc = lexicalDoc() |
| 207 | + const firstPass = processRichTextField(doc) |
| 208 | + const secondPass = processRichTextField(firstPass) |
| 209 | + expect(secondPass).toEqual(firstPass) |
| 210 | + }) |
| 211 | + |
| 212 | + it('should fully round-trip a complex Lexical document serialised to JSON', () => { |
| 213 | + const doc = lexicalDoc([ |
| 214 | + paragraphNode([ |
| 215 | + textNode({ text: 'Bold', format: '1', detail: '0', indent: '0', version: '1' }), |
| 216 | + textNode({ text: ' normal', format: '0', version: '1' }), |
| 217 | + ]), |
| 218 | + { |
| 219 | + type: 'list', |
| 220 | + listType: 'number', |
| 221 | + start: '1', |
| 222 | + version: '1', |
| 223 | + indent: '0', |
| 224 | + direction: 'ltr', |
| 225 | + children: [ |
| 226 | + { type: 'listitem', value: '1', detail: '0', format: '0', version: '1', text: 'Item A' }, |
| 227 | + { type: 'listitem', value: '2', detail: '0', format: '0', version: '1', text: 'Item B' }, |
| 228 | + ], |
| 229 | + }, |
| 230 | + ]) |
| 231 | + |
| 232 | + const result = asRecord(processRichTextField(JSON.stringify(doc))) |
| 233 | + |
| 234 | + const para = asRecord(asArray(result.children)[0]) |
| 235 | + const boldText = asRecord(asArray(para.children)[0]) |
| 236 | + expect(boldText.format).toBe(1) |
| 237 | + expect(boldText.detail).toBe(0) |
| 238 | + |
| 239 | + const list = asRecord(asArray(result.children)[1]) |
| 240 | + expect(list.start).toBe(1) |
| 241 | + expect(list.indent).toBe(0) |
| 242 | + |
| 243 | + const itemA = asRecord(asArray(list.children)[0]) |
| 244 | + const itemB = asRecord(asArray(list.children)[1]) |
| 245 | + expect(itemA.value).toBe(1) |
| 246 | + expect(itemB.value).toBe(2) |
| 247 | + }) |
| 248 | +}) |
0 commit comments