compound.test.ts
1 import { describe, expect, it } from 'vitest'; 2 import { 3 COMPOUND_INFO_JS, 4 COMPOUND_LABEL_CAP, 5 COMPOUND_SELECT_OPTIONS_CAP, 6 type CompoundInfo, 7 } from './compound.js'; 8 9 /** 10 * Tests run the JS source in a sandbox via `new Function`, feeding it 11 * minimal mock elements shaped like the DOM elements the real code sees 12 * at runtime. Avoids a full jsdom setup while still exercising the logic 13 * end-to-end instead of only snapshotting string markers. 14 */ 15 function runCompound(mockEl: unknown): CompoundInfo | null { 16 const fn = new Function('el', `${COMPOUND_INFO_JS}\nreturn compoundInfoOf(el);`); 17 return fn(mockEl) as CompoundInfo | null; 18 } 19 20 function mockInput(attrs: Record<string, string | undefined>, extras: Partial<{ value: string; multiple: boolean; files: { name: string }[] }> = {}) { 21 return { 22 tagName: 'INPUT', 23 value: extras.value, 24 multiple: extras.multiple, 25 files: extras.files, 26 getAttribute(name: string) { 27 return attrs[name] ?? null; 28 }, 29 }; 30 } 31 32 function mockSelect(options: { value: string; label?: string; text?: string; selected?: boolean; disabled?: boolean }[], multiple = false) { 33 const opts = options.map(o => ({ ...o, selected: !!o.selected })); 34 return { 35 tagName: 'SELECT', 36 multiple, 37 options: opts, 38 getAttribute: () => null, 39 }; 40 } 41 42 describe('compoundInfoOf — date-like inputs', () => { 43 it('returns { control, format, current } for <input type=date>', () => { 44 const info = runCompound(mockInput({ type: 'date' }, { value: '2026-04-21' })); 45 expect(info).toEqual({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21' }); 46 }); 47 48 it('surfaces min + max when present', () => { 49 const info = runCompound(mockInput({ type: 'date', min: '2026-01-01', max: '2026-12-31' }, { value: '2026-04-21' })); 50 expect(info).toMatchObject({ min: '2026-01-01', max: '2026-12-31' }); 51 }); 52 53 it('handles time / datetime-local / month / week with correct format strings', () => { 54 const formats: Record<string, string> = { 55 time: 'HH:MM', 56 'datetime-local': 'YYYY-MM-DDTHH:MM', 57 month: 'YYYY-MM', 58 week: 'YYYY-W##', 59 }; 60 for (const [type, fmt] of Object.entries(formats)) { 61 const info = runCompound(mockInput({ type }, { value: '' })) as { format: string }; 62 expect(info.format).toBe(fmt); 63 } 64 }); 65 66 it('coerces null value into empty string instead of crashing', () => { 67 const info = runCompound(mockInput({ type: 'date' })); 68 expect(info).toMatchObject({ control: 'date', current: '' }); 69 }); 70 }); 71 72 describe('compoundInfoOf — file inputs', () => { 73 it('returns { control: file, multiple, current[] }', () => { 74 const info = runCompound(mockInput({ type: 'file' }, { 75 multiple: true, 76 files: [{ name: 'a.png' }, { name: 'b.jpg' }], 77 })); 78 expect(info).toEqual({ control: 'file', multiple: true, current: ['a.png', 'b.jpg'] }); 79 }); 80 81 it('includes accept when present', () => { 82 const info = runCompound(mockInput({ type: 'file', accept: 'image/*' }, { multiple: false })); 83 expect(info).toMatchObject({ control: 'file', accept: 'image/*' }); 84 }); 85 86 it('returns empty current[] when nothing uploaded', () => { 87 const info = runCompound(mockInput({ type: 'file' }, { multiple: false })); 88 expect(info).toEqual({ control: 'file', multiple: false, current: [] }); 89 }); 90 91 it('caps file name at COMPOUND_LABEL_CAP', () => { 92 const longName = 'x'.repeat(COMPOUND_LABEL_CAP + 50); 93 const info = runCompound(mockInput({ type: 'file' }, { multiple: false, files: [{ name: longName }] })) as { current: string[] }; 94 expect(info.current[0]!.length).toBe(COMPOUND_LABEL_CAP); 95 }); 96 }); 97 98 describe('compoundInfoOf — select', () => { 99 it('returns full options list with labels, values, selected flag', () => { 100 const info = runCompound(mockSelect([ 101 { value: 'us', label: 'United States', selected: true }, 102 { value: 'ca', label: 'Canada' }, 103 { value: 'fr', label: 'France' }, 104 ])) as { options: Array<{ label: string; value: string; selected: boolean }> }; 105 expect(info.options).toHaveLength(3); 106 expect(info.options[0]).toEqual({ label: 'United States', value: 'us', selected: true }); 107 expect(info.options[2]).toEqual({ label: 'France', value: 'fr', selected: false }); 108 }); 109 110 it('sets current to the selected label (single-select)', () => { 111 const info = runCompound(mockSelect([ 112 { value: 'a', label: 'Alpha' }, 113 { value: 'b', label: 'Bravo', selected: true }, 114 ])); 115 expect(info).toMatchObject({ control: 'select', multiple: false, current: 'Bravo' }); 116 }); 117 118 it('sets current to an array of labels when multiple=true', () => { 119 const info = runCompound(mockSelect([ 120 { value: 'a', label: 'Alpha', selected: true }, 121 { value: 'b', label: 'Bravo' }, 122 { value: 'c', label: 'Charlie', selected: true }, 123 ], true)); 124 expect(info).toMatchObject({ control: 'select', multiple: true, current: ['Alpha', 'Charlie'] }); 125 }); 126 127 it('falls back from option.label to option.text', () => { 128 const info = runCompound(mockSelect([ 129 { value: 'a', text: 'FromText' }, 130 { value: 'b', label: '', text: 'EmptyLabelFallback' }, 131 ])) as { options: Array<{ label: string }> }; 132 expect(info.options[0]!.label).toBe('FromText'); 133 expect(info.options[1]!.label).toBe('EmptyLabelFallback'); 134 }); 135 136 it('marks disabled options', () => { 137 const info = runCompound(mockSelect([ 138 { value: 'a', label: 'A' }, 139 { value: 'b', label: 'B', disabled: true }, 140 ])) as { options: Array<{ disabled?: boolean }> }; 141 expect(info.options[0]!.disabled).toBeUndefined(); 142 expect(info.options[1]!.disabled).toBe(true); 143 }); 144 145 it('caps options[] at COMPOUND_SELECT_OPTIONS_CAP but keeps true options_total', () => { 146 const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({ 147 value: 'v' + i, 148 label: 'L' + i, 149 })); 150 const info = runCompound(mockSelect(big)) as { options: unknown[]; options_total: number }; 151 expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP); 152 expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25); 153 }); 154 155 it('returns "" for current on single-select with no selected option', () => { 156 const info = runCompound(mockSelect([ 157 { value: 'a', label: 'A' }, 158 { value: 'b', label: 'B' }, 159 ])); 160 expect(info).toMatchObject({ current: '' }); 161 }); 162 163 // Regression: the previous loop stopped walking options once it hit 164 // COMPOUND_SELECT_OPTIONS_CAP, so a long country dropdown with the 165 // selected country sitting at index 80 would be reported with current="". 166 // Agents then thought nothing was selected and picked another country. 167 it('populates current even when the selected option sits past the serialization cap', () => { 168 const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({ 169 value: 'v' + i, 170 label: 'L' + i, 171 selected: i === COMPOUND_SELECT_OPTIONS_CAP + 10, 172 })); 173 const info = runCompound(mockSelect(big)) as { current: string; options: unknown[]; options_total: number }; 174 expect(info.current).toBe('L' + (COMPOUND_SELECT_OPTIONS_CAP + 10)); 175 expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP); 176 expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25); 177 }); 178 179 it('multi-select: current[] includes labels for selected options beyond the cap', () => { 180 const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 10 }, (_, i) => ({ 181 value: 'v' + i, 182 label: 'L' + i, 183 selected: i === 3 || i === COMPOUND_SELECT_OPTIONS_CAP + 5, 184 })); 185 const info = runCompound(mockSelect(big, true)) as { current: string[] }; 186 expect(info.current).toEqual(['L3', 'L' + (COMPOUND_SELECT_OPTIONS_CAP + 5)]); 187 }); 188 }); 189 190 describe('compoundInfoOf — unsupported shapes', () => { 191 it('returns null for plain text input', () => { 192 expect(runCompound(mockInput({ type: 'text' }, { value: 'hi' }))).toBeNull(); 193 }); 194 195 it('returns null for non-form tags', () => { 196 expect(runCompound({ tagName: 'DIV', getAttribute: () => null })).toBeNull(); 197 }); 198 199 it('returns null for null / missing element', () => { 200 expect(runCompound(null)).toBeNull(); 201 expect(runCompound({} as unknown)).toBeNull(); 202 }); 203 });