Files
ersatztv/web/src/builder/rules/validation.test.ts
T
timothyandClaude Opus 4.8 a1d3adda9c fix(435): reject non-integer relative-date N (1e2/7.0) — strict digit-string validation
- validation.ts: ruleError now uses /^\d+$/ regex instead of Number.isInteger
- dateMacro.ts: isValidN now uses /^\d+$/ regex instead of Number.isInteger
- Prevents accepting scientific/decimal notation (e.g. '1e2', '7.0') that break compile↔parse contract
- Added tests for both invalid cases returning null/error

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00

60 lines
3.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { groupHasErrors, normalizeGroup, ruleError, topGroupAllNegative } from './validation';
import type { Group, Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'title', operator: 'is', value: 'x', ...o });
describe('ruleError', () => {
it('flags between with a missing upper bound', () => {
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '', value2: '60' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '60' }))).toBeNull();
});
it('flags empty text values', () => {
for (const operator of ['is', 'isNot', 'contains', 'startsWith'] as const) {
expect(ruleError(r({ operator, value: '' }))).toBeTruthy();
expect(ruleError(r({ operator, value: 'x' }))).toBeNull();
}
});
it('flags relative-date rule with a non-positive or non-integer N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '0', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }))).toBeNull();
});
it('flags relative-date rule with decimal notation N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7.0', unit: 'day' }))).toBeTruthy();
});
it('flags relative-date rule with scientific notation N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '1e2', unit: 'day' }))).toBeTruthy();
});
});
describe('groupHasErrors', () => {
it('walks nested groups', () => {
const g: Group = { match: 'all', children: [{ match: 'any', children: [r({ value: '' })] }] };
expect(groupHasErrors(g)).toBe(true);
expect(groupHasErrors({ match: 'all', children: [r({ value: 'ok' })] })).toBe(false);
});
});
describe('topGroupAllNegative', () => {
it('is true only when every top-level child is a negative rule', () => {
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'notMatches', field: 'plot' })] })).toBe(true);
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'is' })] })).toBe(false);
expect(topGroupAllNegative({ match: 'all', children: [] })).toBe(false);
});
});
describe('normalizeGroup', () => {
it("coerces a single-child group's match to 'all'", () => {
const g: Group = { match: 'any', children: [r({})] };
expect(normalizeGroup(g).match).toBe('all');
});
it('leaves a 2+ child group match unchanged and recurses', () => {
const g: Group = { match: 'any', children: [r({}), { match: 'any', children: [r({})] }] };
const n = normalizeGroup(g);
expect(n.match).toBe('any');
expect((n.children[1] as Group).match).toBe('all');
});
});