ZDDC/tests/zddc-filter.spec.js
2026-06-11 13:32:31 -05:00

177 lines
8.3 KiB
JavaScript

/**
* Tests for shared/zddc-filter.js — standardised filter expression engine.
*
* Runs against shared/zddc-filter-test.html which loads the parser as a
* standalone script and exposes window.zddc.filter.
*/
import { test, expect } from '@playwright/test';
import * as path from 'path';
const SHIM_PATH = 'file://' + path.resolve('shared/zddc-filter-test.html');
// Helper: parse then matches
async function fm(page, expr, value) {
return page.evaluate(
([expr, value]) => {
const ast = window.zddc.filter.parse(expr);
return window.zddc.filter.matches(value, ast);
},
[expr, value]
);
}
test.beforeEach(async ({ page }) => {
await page.goto(SHIM_PATH, { waitUntil: 'load' });
const ok = await page.evaluate(() => typeof window.zddc.filter === 'object');
expect(ok).toBe(true);
});
// ── empty / wildcard-all ──────────────────────────────────────────────────────
test('empty string matches everything', async ({ page }) => {
expect(await fm(page, '', 'anything')).toBe(true);
expect(await fm(page, '', '')).toBe(true);
});
test('whitespace-only matches everything', async ({ page }) => {
expect(await fm(page, ' ', 'anything')).toBe(true);
});
test('bare * matches everything', async ({ page }) => {
expect(await fm(page, '*', 'hello world')).toBe(true);
expect(await fm(page, '*', '')).toBe(true);
});
// ── plain term (substring, case-insensitive) ──────────────────────────────────
test('plain term: case-insensitive substring match', async ({ page }) => {
expect(await fm(page, 'IFA', 'status IFA rev')).toBe(true);
expect(await fm(page, 'ifa', 'status IFA rev')).toBe(true);
expect(await fm(page, 'IFA', 'status IFB rev')).toBe(false);
});
test('plain term: substring anywhere in value', async ({ page }) => {
expect(await fm(page, 'el', '123456-el-spc-2623')).toBe(true);
expect(await fm(page, 'spc', '123456-el-spc-2623')).toBe(true);
});
// ── ! negation ────────────────────────────────────────────────────────────────
test('!term: excludes values containing term', async ({ page }) => {
expect(await fm(page, '!draft', 'final document')).toBe(true);
expect(await fm(page, '!draft', 'draft document')).toBe(false);
});
// ── ^ prefix anchor ───────────────────────────────────────────────────────────
test('^term: only matches at start', async ({ page }) => {
expect(await fm(page, '^IFA', 'IFA document')).toBe(true);
expect(await fm(page, '^IFA', 'revision IFA')).toBe(false);
});
// ── $ suffix anchor ───────────────────────────────────────────────────────────
test('term$: only matches at end', async ({ page }) => {
expect(await fm(page, 'pdf$', 'document.pdf')).toBe(true);
expect(await fm(page, 'pdf$', 'document.docx')).toBe(false);
});
// ── !^ not-prefix ─────────────────────────────────────────────────────────────
test('!^term: excludes values starting with term', async ({ page }) => {
expect(await fm(page, '!^~', 'A (IFA) title')).toBe(true);
expect(await fm(page, '!^~', '~A (IFR) title')).toBe(false);
});
// ── !$ not-suffix ─────────────────────────────────────────────────────────────
test('!term$: excludes values ending with term', async ({ page }) => {
expect(await fm(page, '!pdf$', 'document.docx')).toBe(true);
expect(await fm(page, '!pdf$', 'document.pdf')).toBe(false);
});
// ── AND (space-separated) ─────────────────────────────────────────────────────
test('a b: both must match (AND)', async ({ page }) => {
expect(await fm(page, 'pdf spc', 'spc document.pdf')).toBe(true);
expect(await fm(page, 'pdf spc', 'spc document.docx')).toBe(false);
expect(await fm(page, 'pdf spc', 'other document.pdf')).toBe(false);
});
// ── OR ────────────────────────────────────────────────────────────────────────
test('a | b: either matches (OR)', async ({ page }) => {
expect(await fm(page, 'IFA | IFB', 'status IFA')).toBe(true);
expect(await fm(page, 'IFA | IFB', 'status IFB')).toBe(true);
expect(await fm(page, 'IFA | IFB', 'status IFC')).toBe(false);
});
test('(a | b): grouped OR', async ({ page }) => {
expect(await fm(page, '(IFA | IFB)', 'status IFA')).toBe(true);
expect(await fm(page, '(IFA | IFB)', 'status IFB')).toBe(true);
expect(await fm(page, '(IFA | IFB)', 'status IFC')).toBe(false);
});
test('^IFA | ^IFB: prefix OR', async ({ page }) => {
expect(await fm(page, '^IFA | ^IFB', 'IFA document')).toBe(true);
expect(await fm(page, '^IFA | ^IFB', 'IFB document')).toBe(true);
expect(await fm(page, '^IFA | ^IFB', 'IFC document')).toBe(false);
});
// ── regex patterns (the new capability replacing glob) ────────────────────────
test('regex: .* spans characters within a term', async ({ page }) => {
// el.*spc matches "123456-el-spc-2623" because it's a substring regex
expect(await fm(page, 'el.*spc', '123456-el-spc-2623')).toBe(true);
expect(await fm(page, 'el.*spc', '123456-me-spc-2623')).toBe(false);
});
test('regex: ^el.*spc anchored to start', async ({ page }) => {
expect(await fm(page, '^el.*spc', 'el-spc-2623')).toBe(true);
expect(await fm(page, '^el.*spc', '123456-el-spc-2623')).toBe(false);
});
test('regex: character class [ei]fa', async ({ page }) => {
expect(await fm(page, '[ei]fa', 'status efa')).toBe(true);
expect(await fm(page, '[ei]fa', 'status ifa')).toBe(true);
expect(await fm(page, '[ei]fa', 'status ofa')).toBe(false);
});
test('regex: invalid regex falls back to literal match', async ({ page }) => {
// "[unclosed" is invalid regex — should fall back and treat as literal
// It won't crash, just won't match normally
const result = await page.evaluate(() => {
try {
const ast = window.zddc.filter.parse('[unclosed');
window.zddc.filter.matches('test', ast);
return 'no-crash';
} catch (e) {
return 'crashed: ' + e.message;
}
});
expect(result).toBe('no-crash');
});
// ── regression: hyphen in tracking number is literal ─────────────────────────
test('hyphen in tracking number is literal (not an operator)', async ({ page }) => {
expect(await fm(page, '123456-EL-SPC', '123456-EL-SPC-2623')).toBe(true);
expect(await fm(page, '123456-EL-SPC', '654321-EL-SPC-9999')).toBe(false);
});
// ── AND + NOT combined ────────────────────────────────────────────────────────
test('pdf !draft: AND with exclusion', async ({ page }) => {
expect(await fm(page, 'pdf !draft', 'final.pdf')).toBe(true);
expect(await fm(page, 'pdf !draft', 'draft.pdf')).toBe(false);
expect(await fm(page, 'pdf !draft', 'final.docx')).toBe(false);
});
// ── complex real-world expressions ───────────────────────────────────────────
test('!^~ !^[0-9]: excludes drafts and numeric revisions', async ({ page }) => {
expect(await fm(page, '!^~ !^[0-9]', 'A (IFA) title')).toBe(true);
expect(await fm(page, '!^~ !^[0-9]', '~A (IFR) title')).toBe(false);
expect(await fm(page, '!^~ !^[0-9]', '0 (IFC) title')).toBe(false);
});