diff --git a/e2e/kattis-parser.spec.ts b/e2e/kattis-parser.spec.ts new file mode 100644 index 0000000..178b637 --- /dev/null +++ b/e2e/kattis-parser.spec.ts @@ -0,0 +1,109 @@ +import { test, expect, type Page } from '@playwright/test'; +import { parseKattisProblemPage } from '../src/lib/providers/kattis/ingestion.ts'; + +// Regression coverage for the real Kattis problem-page HTML parser. +// +// parseKattisProblemPage scrapes the live open.kattis.com markup for a +// problem's display name (the

) and difficulty (.difficulty_number, with a +// .difficulty fallback). Every other Kattis test mocks parsePage out, so the +// selector logic that is most likely to break when Kattis changes its markup is +// otherwise unexercised. These snapshots pin its behavior against captured +// fixtures so a markup drift or an accidental selector change fails loudly. +// +// The parser uses the browser-only DOMParser, so it cannot run in the Node +// unit-test process. We run the *production* function (serialized via +// .toString(), not a copy) inside a real browser DOM via page.evaluate. Adding +// a DOM shim (jsdom/happy-dom) purely for this would introduce a dependency the +// repo guidelines discourage; the Playwright browser already provides one. + +const parserSource = parseKattisProblemPage.toString(); + +async function parseInBrowser( + page: Page, + html: string, + problemId: string +): Promise<{ name: string; rating: number }> { + return page.evaluate( + ([source, htmlArg, idArg]) => { + const fn = new Function(`return (${source});`)() as ( + html: string, + problemId: string + ) => { name: string; rating: number }; + return fn(htmlArg, idArg); + }, + [parserSource, html, problemId] as const + ); +} + +// A realistic slice of the open.kattis.com problem page: the problem title in an +//

and the difficulty rendered in a .difficulty_number badge. +const FULL_PAGE = ` + + Two Stones – Kattis + +
+
+

Two Stones

+
+
+ +
+
+ +`; + +// Older/alternate markup that exposes the difficulty via the .difficulty class +// rather than .difficulty_number. +const LEGACY_DIFFICULTY_CLASS = ` + + +

Legacy Problem

+ 7 + +`; + +// A page with a title but no difficulty element at all. +const NO_DIFFICULTY = ` + + +

Nameless Difficulty

+ +`; + +// A page with neither a title nor a difficulty (e.g. an error/interstitial page). +const NO_TITLE = ` + + +
Problem not available
+ +`; + +test.describe('parseKattisProblemPage', () => { + test('reads the h1 name and .difficulty_number rating', async ({ page }) => { + await page.goto('about:blank'); + const result = await parseInBrowser(page, FULL_PAGE, 'twostones'); + expect(result).toEqual({ name: 'Two Stones', rating: 2.1 }); + }); + + test('falls back to the .difficulty class when .difficulty_number is absent', async ({ + page + }) => { + await page.goto('about:blank'); + const result = await parseInBrowser(page, LEGACY_DIFFICULTY_CLASS, 'legacy'); + expect(result).toEqual({ name: 'Legacy Problem', rating: 7 }); + }); + + test('defaults the rating to 5 when no difficulty element is present', async ({ page }) => { + await page.goto('about:blank'); + const result = await parseInBrowser(page, NO_DIFFICULTY, 'nodiff'); + expect(result).toEqual({ name: 'Nameless Difficulty', rating: 5 }); + }); + + test('falls back to the problem id when the h1 is missing', async ({ page }) => { + await page.goto('about:blank'); + const result = await parseInBrowser(page, NO_TITLE, 'missingtitle'); + expect(result).toEqual({ name: 'missingtitle', rating: 5 }); + }); +});