/ src / browser / compound.test.ts
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  });