/ src / browser / verify-fixture.test.ts
verify-fixture.test.ts
  1  import { describe, expect, it } from 'vitest';
  2  import { deriveFixture, expandFixtureArgs, validateRows, type Fixture } from './verify-fixture.js';
  3  
  4  describe('validateRows', () => {
  5      it('passes when rows meet all expectations', () => {
  6          const fixture: Fixture = {
  7              expect: {
  8                  rowCount: { min: 1, max: 3 },
  9                  columns: ['id', 'title', 'url'],
 10                  types: { id: 'number', title: 'string', url: 'string' },
 11                  patterns: { url: '^https://' },
 12                  notEmpty: ['title', 'url'],
 13              },
 14          };
 15          const rows = [
 16              { id: 1, title: 'a', url: 'https://x.com/a' },
 17              { id: 2, title: 'b', url: 'https://x.com/b' },
 18          ];
 19          expect(validateRows(rows, fixture)).toEqual([]);
 20      });
 21  
 22      it('reports rowCount below min', () => {
 23          const failures = validateRows([], { expect: { rowCount: { min: 1 } } });
 24          expect(failures).toHaveLength(1);
 25          expect(failures[0]).toMatchObject({ rule: 'rowCount' });
 26          expect(failures[0].detail).toContain('at least 1');
 27      });
 28  
 29      it('reports rowCount above max', () => {
 30          const failures = validateRows(
 31              [{}, {}, {}, {}],
 32              { expect: { rowCount: { max: 3 } } },
 33          );
 34          expect(failures).toHaveLength(1);
 35          expect(failures[0].detail).toContain('at most 3');
 36      });
 37  
 38      it('reports missing columns per row', () => {
 39          const failures = validateRows(
 40              [{ a: 1 }, { a: 2, b: 3 }],
 41              { expect: { columns: ['a', 'b'] } },
 42          );
 43          // row 0 missing 'b', row 1 complete
 44          expect(failures).toEqual([
 45              { rule: 'column', detail: 'missing column "b"', rowIndex: 0 },
 46          ]);
 47      });
 48  
 49      it('reports type mismatch including null', () => {
 50          const failures = validateRows(
 51              [{ a: 'abc' }, { a: null }, { a: 42 }],
 52              { expect: { types: { a: 'string' } } },
 53          );
 54          // row 0 string ok, row 1 null fail, row 2 number fail
 55          expect(failures).toHaveLength(2);
 56          expect(failures[0].rowIndex).toBe(1);
 57          expect(failures[0].detail).toContain('null');
 58          expect(failures[1].rowIndex).toBe(2);
 59          expect(failures[1].detail).toContain('number');
 60      });
 61  
 62      it('accepts union types like "number|string"', () => {
 63          const failures = validateRows(
 64              [{ id: 1 }, { id: 'abc' }],
 65              { expect: { types: { id: 'number|string' } } },
 66          );
 67          expect(failures).toEqual([]);
 68      });
 69  
 70      it('accepts "any" as wildcard type', () => {
 71          const failures = validateRows(
 72              [{ v: 1 }, { v: 'x' }, { v: null }, { v: [1, 2] }],
 73              { expect: { types: { v: 'any' } } },
 74          );
 75          expect(failures).toEqual([]);
 76      });
 77  
 78      it('reports pattern mismatch with row index and truncated value', () => {
 79          const failures = validateRows(
 80              [{ url: 'https://ok.com' }, { url: 'not-a-url' }],
 81              { expect: { patterns: { url: '^https?://' } } },
 82          );
 83          expect(failures).toHaveLength(1);
 84          expect(failures[0]).toMatchObject({ rule: 'pattern', rowIndex: 1 });
 85          expect(failures[0].detail).toContain('not-a-url');
 86      });
 87  
 88      it('skips pattern check for null/undefined values', () => {
 89          const failures = validateRows(
 90              [{ url: null }, { url: undefined }],
 91              { expect: { patterns: { url: '^x' } } },
 92          );
 93          expect(failures).toEqual([]);
 94      });
 95  
 96      it('reports invalid regex without crashing', () => {
 97          const failures = validateRows(
 98              [{ a: 'x' }],
 99              { expect: { patterns: { a: '[unclosed' } } },
100          );
101          expect(failures.some((f) => f.rule === 'pattern' && f.detail.includes('invalid'))).toBe(true);
102      });
103  
104      it('treats empty/whitespace/null as failing notEmpty', () => {
105          const failures = validateRows(
106              [{ t: '' }, { t: '   ' }, { t: null }, { t: 'ok' }],
107              { expect: { notEmpty: ['t'] } },
108          );
109          expect(failures).toHaveLength(3);
110          expect(failures.map((f) => f.rowIndex)).toEqual([0, 1, 2]);
111      });
112  
113      it('no failures when fixture has no expect block', () => {
114          expect(validateRows([{ anything: 1 }], {})).toEqual([]);
115      });
116  
117      it('mustNotContain flags substring bleed in columns', () => {
118          const failures = validateRows(
119              [
120                  { description: 'Lead engineer, 5 years exp. address: Shanghai. category: IT' },
121                  { description: 'Clean text.' },
122              ],
123              {
124                  expect: {
125                      mustNotContain: { description: ['address:', 'category:'] },
126                  },
127              },
128          );
129          expect(failures).toHaveLength(2);
130          expect(failures.every((f) => f.rule === 'mustNotContain')).toBe(true);
131          expect(failures.every((f) => f.rowIndex === 0)).toBe(true);
132      });
133  
134      it('mustNotContain skips null/undefined values', () => {
135          const failures = validateRows(
136              [{ description: null }, { description: undefined }],
137              { expect: { mustNotContain: { description: ['x'] } } },
138          );
139          expect(failures).toEqual([]);
140      });
141  
142      it('mustBeTruthy catches silent 0 / false / "" fallbacks', () => {
143          const failures = validateRows(
144              [{ count: 10 }, { count: 0 }, { count: false }, { count: '' }, { count: null }],
145              { expect: { mustBeTruthy: ['count'] } },
146          );
147          expect(failures).toHaveLength(4);
148          expect(failures.every((f) => f.rule === 'mustBeTruthy')).toBe(true);
149          expect(failures.map((f) => f.rowIndex)).toEqual([1, 2, 3, 4]);
150      });
151  });
152  
153  describe('deriveFixture', () => {
154      it('returns rowCount.min=0 when rows are empty', () => {
155          expect(deriveFixture([])).toEqual({ expect: { rowCount: { min: 0 } } });
156      });
157  
158      it('extracts columns from first row and infers types per column', () => {
159          const fixture = deriveFixture([
160              { id: 1, title: 'a', url: 'https://x' },
161              { id: 2, title: 'b', url: 'https://y' },
162          ]);
163          expect(fixture.expect?.columns).toEqual(['id', 'title', 'url']);
164          expect(fixture.expect?.types).toEqual({
165              id: 'number',
166              title: 'string',
167              url: 'string',
168          });
169          expect(fixture.expect?.rowCount).toEqual({ min: 1 });
170      });
171  
172      it('unions mixed types across rows as "a|b"', () => {
173          const fixture = deriveFixture([
174              { v: 1 },
175              { v: 'two' },
176              { v: null },
177          ]);
178          expect(fixture.expect?.types?.v).toBe('null|number|string');
179      });
180  
181      it('embeds args when provided', () => {
182          const fixture = deriveFixture([{ x: 1 }], { limit: 5 });
183          expect(fixture.args).toEqual({ limit: 5 });
184      });
185  
186      it('embeds positional argv array when provided', () => {
187          const fixture = deriveFixture([{ x: 1 }], ['123', '--limit', '3']);
188          expect(fixture.args).toEqual(['123', '--limit', '3']);
189      });
190  
191      it('does not add patterns or notEmpty automatically', () => {
192          const fixture = deriveFixture([{ a: 'x' }]);
193          expect(fixture.expect?.patterns).toBeUndefined();
194          expect(fixture.expect?.notEmpty).toBeUndefined();
195      });
196  });
197  
198  describe('expandFixtureArgs', () => {
199      it('returns [] for undefined', () => {
200          expect(expandFixtureArgs(undefined)).toEqual([]);
201      });
202  
203      it('expands object form as --key value pairs', () => {
204          expect(expandFixtureArgs({ limit: 3, sort: 'hot' })).toEqual(['--limit', '3', '--sort', 'hot']);
205      });
206  
207      it('passes array form verbatim, stringifying values', () => {
208          expect(expandFixtureArgs(['123456', '--limit', 3])).toEqual(['123456', '--limit', '3']);
209      });
210  
211      it('handles empty object and empty array', () => {
212          expect(expandFixtureArgs({})).toEqual([]);
213          expect(expandFixtureArgs([])).toEqual([]);
214      });
215  
216      it('preserves positional + flag mix (e.g. <tid> --limit 3)', () => {
217          expect(expandFixtureArgs(['https://example.com/thread-1', '--comments', '5'])).toEqual([
218              'https://example.com/thread-1',
219              '--comments',
220              '5',
221          ]);
222      });
223  });