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 });