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