Add URL and SEO parity harnesses with fast-check fuzz testing

Generic URL parity harness (table-driven + property-based roundtrip)
and SEO parity harness (shape/completeness validation) registered for
Online Board. 170 tests covering all 6 route types with 200-iteration
fast-check fuzz runs ensuring no serialization asymmetry.
This commit is contained in:
2026-04-15 08:45:09 +03:00
parent 1e9523088b
commit 9bd3697a17
8 changed files with 707 additions and 1 deletions
+1
View File
@@ -56,6 +56,7 @@
"eslint": "^9.0.0",
"eslint-plugin-boundaries": "^5.0.0",
"eslint-plugin-unused-imports": "^4.0.0",
"fast-check": "^4.6.0",
"jsdom": "^29.0.2",
"react-test-renderer": "^19.2.5",
"typescript": "^5.5.0",
+16
View File
@@ -108,6 +108,9 @@ importers:
eslint-plugin-unused-imports:
specifier: ^4.0.0
version: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))
fast-check:
specifier: ^4.6.0
version: 4.6.0
jsdom:
specifier: ^29.0.2
version: 29.0.2
@@ -3863,6 +3866,10 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-check@4.6.0:
resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==}
engines: {node: '>=12.17.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -5308,6 +5315,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pure-rand@8.4.0:
resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
@@ -11060,6 +11070,10 @@ snapshots:
expect-type@1.3.0: {}
fast-check@4.6.0:
dependencies:
pure-rand: 8.4.0
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -12545,6 +12559,8 @@ snapshots:
punycode@2.3.1: {}
pure-rand@8.4.0: {}
qs@6.15.1:
dependencies:
side-channel: 1.1.0
+82
View File
@@ -0,0 +1,82 @@
[
{
"url": "onlineboard",
"expected": { "type": "start" }
},
{
"url": "onlineboard/flight/SU0100-20250115",
"expected": { "type": "flight", "carrier": "SU", "flightNumber": "0100", "date": "20250115" }
},
{
"url": "onlineboard/flight/SU0100D-20250115",
"expected": { "type": "flight", "carrier": "SU", "flightNumber": "0100", "suffix": "D", "date": "20250115" }
},
{
"url": "onlineboard/flight/SU0001-20260301",
"expected": { "type": "flight", "carrier": "SU", "flightNumber": "0001", "date": "20260301" }
},
{
"url": "onlineboard/flight/SUA0100-20250115",
"expected": { "type": "flight", "carrier": "SUA", "flightNumber": "0100", "date": "20250115" }
},
{
"url": "onlineboard/flight/AF1234-20250601",
"expected": { "type": "flight", "carrier": "AF", "flightNumber": "1234", "date": "20250601" }
},
{
"url": "onlineboard/departure/SVO-20250115",
"expected": { "type": "departure", "station": "SVO", "date": "20250115" }
},
{
"url": "onlineboard/departure/SVO-20250115-08001800",
"expected": { "type": "departure", "station": "SVO", "date": "20250115", "timeFrom": "0800", "timeTo": "1800" }
},
{
"url": "onlineboard/departure/LED-20260115",
"expected": { "type": "departure", "station": "LED", "date": "20260115" }
},
{
"url": "onlineboard/departure/JFK-20250301-06002359",
"expected": { "type": "departure", "station": "JFK", "date": "20250301", "timeFrom": "0600", "timeTo": "2359" }
},
{
"url": "onlineboard/arrival/LED-20250115",
"expected": { "type": "arrival", "station": "LED", "date": "20250115" }
},
{
"url": "onlineboard/arrival/JFK-20250115-08001800",
"expected": { "type": "arrival", "station": "JFK", "date": "20250115", "timeFrom": "0800", "timeTo": "1800" }
},
{
"url": "onlineboard/arrival/SVO-20260701",
"expected": { "type": "arrival", "station": "SVO", "date": "20260701" }
},
{
"url": "onlineboard/route/SVO-LED-20250115",
"expected": { "type": "route", "departure": "SVO", "arrival": "LED", "date": "20250115" }
},
{
"url": "onlineboard/route/SVO-JFK-20250115-08001800",
"expected": { "type": "route", "departure": "SVO", "arrival": "JFK", "date": "20250115", "timeFrom": "0800", "timeTo": "1800" }
},
{
"url": "onlineboard/route/LED-SVO-20260301",
"expected": { "type": "route", "departure": "LED", "arrival": "SVO", "date": "20260301" }
},
{
"url": "onlineboard/route/DME-AER-20250801-00002359",
"expected": { "type": "route", "departure": "DME", "arrival": "AER", "date": "20250801", "timeFrom": "0000", "timeTo": "2359" }
},
{
"url": "onlineboard/SU0100-20250115",
"expected": { "type": "details", "carrier": "SU", "flightNumber": "0100", "date": "20250115" }
},
{
"url": "onlineboard/SU0100D-20250115",
"expected": { "type": "details", "carrier": "SU", "flightNumber": "0100", "suffix": "D", "date": "20250115" }
},
{
"url": "onlineboard/AF1234-20250601",
"expected": { "type": "details", "carrier": "AF", "flightNumber": "1234", "date": "20250601" }
}
]
+140
View File
@@ -0,0 +1,140 @@
/**
* Generic SEO parity test harness.
*
* Features register their SEO builder functions against this harness.
* The harness asserts that every builder produces a complete `SeoHeadProps`
* with all required fields:
* - title (non-empty string)
* - description (non-empty string)
* - canonical (valid URL)
* - hreflang (10 entries: 9 languages + x-default)
* - og (all required fields present)
* - twitter (card field present)
*
* Designed for reuse across Phase 3+ features.
*
* @module
*/
import { describe, it, expect } from "vitest";
import type { SeoHeadProps } from "@/ui/seo/SeoHead.js";
import type { Language } from "@/i18n/resolver.js";
// ---------------------------------------------------------------------------
// Public config contract
// ---------------------------------------------------------------------------
export interface SeoParityEntry {
/** Human-readable label for the test (e.g. "start page", "flight search"). */
label: string;
/** Call the SEO builder and return the result. */
render(lang: Language): SeoHeadProps;
}
export interface SeoParityConfig {
/** Feature slug (used in describe block labels). */
slug: string;
/** The entries to test. */
entries: SeoParityEntry[];
/** Languages to test each entry against. Defaults to ["ru", "en"]. */
languages?: Language[];
}
// ---------------------------------------------------------------------------
// Expected hreflang count
// ---------------------------------------------------------------------------
const EXPECTED_HREFLANG_COUNT = 10; // 9 languages + x-default
// ---------------------------------------------------------------------------
// Harness entry point
// ---------------------------------------------------------------------------
/**
* Defines a full SEO parity test suite for a feature.
*
* Call this from a `*.test.ts` file — it registers `describe` / `it` blocks
* via Vitest globals.
*/
export function defineSeoParityTests(config: SeoParityConfig): void {
const { slug, entries, languages = ["ru", "en"] } = config;
describe(`[${slug}] SEO parity`, () => {
for (const entry of entries) {
for (const lang of languages) {
describe(`${entry.label} (${lang})`, () => {
let result: SeoHeadProps;
// Build once per describe block
// Using a beforeAll-style pattern via lazy init
function getResult(): SeoHeadProps {
if (!result) {
result = entry.render(lang);
}
return result;
}
it("has a non-empty title", () => {
const r = getResult();
expect(r.title).toBeTruthy();
expect(typeof r.title).toBe("string");
});
it("has a non-empty description", () => {
const r = getResult();
expect(r.description).toBeTruthy();
expect(typeof r.description).toBe("string");
});
it("has a canonical URL containing the locale", () => {
const r = getResult();
expect(r.canonical).toBeTruthy();
expect(r.canonical).toContain(`/${lang}/`);
});
it(`has ${EXPECTED_HREFLANG_COUNT} hreflang entries`, () => {
const r = getResult();
expect(r.hreflang).toHaveLength(EXPECTED_HREFLANG_COUNT);
});
it("has x-default in hreflang entries", () => {
const r = getResult();
const xDefault = r.hreflang.find((h) => h.lang === "x-default");
expect(xDefault).toBeDefined();
expect(xDefault?.href).toBeTruthy();
});
it("has all required OG fields", () => {
const r = getResult();
expect(r.og.title).toBeTruthy();
expect(r.og.description).toBeTruthy();
expect(r.og.url).toBeTruthy();
expect(r.og.image).toBeTruthy();
expect(["website", "article"]).toContain(r.og.type);
expect(r.og.locale).toBe(lang);
expect(r.og.siteName).toBeTruthy();
});
it("has og.url matching canonical", () => {
const r = getResult();
expect(r.og.url).toBe(r.canonical);
});
it("has og.title matching title", () => {
const r = getResult();
expect(r.og.title).toBe(r.title);
});
it("has twitter card defined", () => {
const r = getResult();
expect(r.twitter).toBeDefined();
expect(r.twitter?.card).toBeTruthy();
});
});
}
}
});
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Online Board SEO parity tests.
*
* Registers all 6 Online Board SEO builder functions against the generic
* SEO parity harness. Tests shape/completeness for ru and en locales.
*
* @module
*/
import {
buildOnlineBoardStartSeo,
buildFlightSearchSeo,
buildDepartureSearchSeo,
buildArrivalSearchSeo,
buildRouteSearchSeo,
buildFlightDetailsSeo,
} from "@/features/online-board/seo.js";
import type { ISimpleFlight } from "@/features/online-board/types.js";
import type { Language } from "@/i18n/resolver.js";
import { defineSeoParityTests } from "./harness.js";
// ---------------------------------------------------------------------------
// Stub translation function
// ---------------------------------------------------------------------------
function stubT(key: string, opts?: Record<string, unknown>): string {
if (opts && Object.keys(opts).length > 0) {
const vars = Object.entries(opts)
.map(([k, v]) => `${k}=${String(v)}`)
.join(",");
return `${key}|${vars}`;
}
return key;
}
const CANONICAL_ORIGIN = "https://www.aeroflot.ru";
// ---------------------------------------------------------------------------
// Test flight fixture for details page
// ---------------------------------------------------------------------------
const testFlight: ISimpleFlight = {
id: "SU100-20250115",
routeType: "Direct",
flightId: {
carrier: "SU",
flightNumber: "0100",
suffix: "",
date: "20250115",
},
flyingTime: "10h 30m",
operatingBy: {},
status: "Scheduled",
leg: {
index: 0,
status: "Scheduled",
flyingTime: "10h 30m",
updated: "2025-01-15T10:00:00Z",
dayChange: 0,
equipment: { name: "Boeing 777-300ER", code: "773" },
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
operatingBy: {},
departure: {
scheduled: {
airport: "Sheremetyevo International Airport",
airportCode: "SVO",
city: "Moscow",
cityCode: "MOW",
countryCode: "RU",
},
checkingStatus: "closed",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "2025-01-15T10:00:00",
localTime: "10:00",
tzOffset: 3,
utc: "2025-01-15T07:00:00Z",
},
},
},
arrival: {
scheduled: {
airport: "John F. Kennedy International Airport",
airportCode: "JFK",
city: "New York",
cityCode: "NYC",
countryCode: "US",
},
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: "2025-01-15T14:30:00",
localTime: "14:30",
tzOffset: -5,
utc: "2025-01-15T19:30:00Z",
},
},
},
},
};
// ---------------------------------------------------------------------------
// Register against harness
// ---------------------------------------------------------------------------
defineSeoParityTests({
slug: "OnlineBoard",
entries: [
{
label: "start page",
render: (lang: Language) =>
buildOnlineBoardStartSeo(stubT, lang, CANONICAL_ORIGIN),
},
{
label: "flight search",
render: (lang: Language) =>
buildFlightSearchSeo(
stubT,
{ type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" },
lang,
CANONICAL_ORIGIN,
),
},
{
label: "departure search",
render: (lang: Language) =>
buildDepartureSearchSeo(
stubT,
{ type: "departure", station: "SVO", date: "20250115" },
lang,
CANONICAL_ORIGIN,
{ departure: "Moscow" },
),
},
{
label: "arrival search",
render: (lang: Language) =>
buildArrivalSearchSeo(
stubT,
{ type: "arrival", station: "JFK", date: "20250115" },
lang,
CANONICAL_ORIGIN,
{ arrival: "New York" },
),
},
{
label: "route search",
render: (lang: Language) =>
buildRouteSearchSeo(
stubT,
{ type: "route", departure: "SVO", arrival: "JFK", date: "20250115" },
lang,
CANONICAL_ORIGIN,
{ departure: "Moscow", arrival: "New York" },
),
},
{
label: "flight details",
render: (lang: Language) =>
buildFlightDetailsSeo(stubT, testFlight, lang, CANONICAL_ORIGIN),
},
],
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Generic URL parity test harness.
*
* Features register their URL serializer/parser against this harness.
* The harness runs:
* 1. Table-driven fixture tests (parse + build parity against corpus).
* 2. fast-check property-based fuzz tests (roundtrip: parse(build(x)) === x).
*
* Designed for reuse across Phase 3+ features.
*
* @module
*/
import { describe, it, expect } from "vitest";
import * as fc from "fast-check";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// ---------------------------------------------------------------------------
// Public config contract
// ---------------------------------------------------------------------------
export interface UrlParityConfig<TQuery> {
/** Feature name (used in describe block labels). */
feature: string;
/** Path to the JSON fixture file relative to the project root. */
fixturePath: string;
/** Parse a raw URL string into the typed query object. Returns null on failure. */
parse(raw: string): TQuery | null;
/** Build a URL string from a typed query object. */
build(query: TQuery): string;
/** fast-check arbitrary for generating random valid query objects. */
fuzzArbitrary: fc.Arbitrary<TQuery>;
}
// ---------------------------------------------------------------------------
// Fixture shape
// ---------------------------------------------------------------------------
interface FixtureEntry<T> {
url: string;
expected: T;
}
// ---------------------------------------------------------------------------
// Harness entry point
// ---------------------------------------------------------------------------
/**
* Defines a full URL parity test suite for a feature.
*
* Call this from a `*.test.ts` file — it registers `describe` / `it` blocks
* via Vitest globals.
*/
export function defineUrlParityTests<T>(config: UrlParityConfig<T>): void {
const { feature, fixturePath, parse, build, fuzzArbitrary } = config;
// -------------------------------------------------------------------------
// Load fixtures
// -------------------------------------------------------------------------
const fixtureAbsPath = resolve(process.cwd(), fixturePath);
const raw = readFileSync(fixtureAbsPath, "utf-8");
const fixtures = JSON.parse(raw) as FixtureEntry<T>[];
// -------------------------------------------------------------------------
// 1. Table-driven fixture tests
// -------------------------------------------------------------------------
describe(`[${feature}] URL parity — fixture corpus (${fixtures.length} entries)`, () => {
for (const entry of fixtures) {
it(`parse("${entry.url}") matches expected`, () => {
const parsed = parse(entry.url);
expect(parsed).toEqual(entry.expected);
});
it(`build(expected) === "${entry.url}"`, () => {
const built = build(entry.expected);
expect(built).toBe(entry.url);
});
it(`roundtrip: build(parse("${entry.url}")) === "${entry.url}"`, () => {
const parsed = parse(entry.url);
expect(parsed).not.toBeNull();
if (parsed === null) return;
const rebuilt = build(parsed);
expect(rebuilt).toBe(entry.url);
});
}
});
// -------------------------------------------------------------------------
// 2. fast-check fuzz roundtrip tests
// -------------------------------------------------------------------------
describe(`[${feature}] URL parity — fast-check fuzz roundtrip`, () => {
it("parse(build(params)) deep-equals params for all generated inputs", () => {
fc.assert(
fc.property(fuzzArbitrary, (params) => {
const url = build(params);
const parsed = parse(url);
expect(parsed).toEqual(params);
}),
{ numRuns: 200 },
);
});
it("build(parse(build(params))) === build(params) for all generated inputs", () => {
fc.assert(
fc.property(fuzzArbitrary, (params) => {
const url = build(params);
const parsed = parse(url);
expect(parsed).not.toBeNull();
if (parsed === null) return;
const rebuilt = build(parsed);
expect(rebuilt).toBe(url);
}),
{ numRuns: 200 },
);
});
});
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Online Board URL parity tests.
*
* Registers the Online Board URL serializer against the generic URL parity
* harness. Tests fixture corpus + fast-check fuzz roundtrip.
*
* @module
*/
import * as fc from "fast-check";
import { parseOnlineBoardUrl, buildOnlineBoardUrl } from "@/features/online-board/url.js";
import type { OnlineBoardParams } from "@/features/online-board/url.js";
import { defineUrlParityTests } from "./harness.js";
// ---------------------------------------------------------------------------
// fast-check arbitraries for OnlineBoardParams
// ---------------------------------------------------------------------------
const ALPHA_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as [string, ...string[]];
/** Single uppercase letter arbitrary */
const alphaCharArb: fc.Arbitrary<string> = fc.constantFrom(...ALPHA_CHARS);
/** 2-char uppercase carrier code (standard IATA) */
const carrierArb: fc.Arbitrary<string> = fc
.tuple(alphaCharArb, alphaCharArb)
.map(([a, b]) => `${a}${b}`);
/** 3-char uppercase IATA station code */
const stationArb: fc.Arbitrary<string> = fc
.tuple(alphaCharArb, alphaCharArb, alphaCharArb)
.map(([a, b, c]) => `${a}${b}${c}`);
/** Valid yyyyMMdd date string in a reasonable range */
const dateArb = fc
.record({
year: fc.integer({ min: 2020, max: 2030 }),
month: fc.integer({ min: 1, max: 12 }),
day: fc.integer({ min: 1, max: 28 }), // avoid invalid day-of-month
})
.map(({ year, month, day }) => {
const m = String(month).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}${m}${d}`;
});
/**
* 4-digit zero-padded flight number.
*
* The URL serializer pads to 4 digits on build, so the roundtrip-stable
* representation is always 4 digits.
*/
const flightNumberArb = fc
.integer({ min: 1, max: 9999 })
.map((n) => String(n).padStart(4, "0"));
/** Optional time range: 4-digit HHmm strings */
const timeArb = fc
.record({
hour: fc.integer({ min: 0, max: 23 }),
minute: fc.integer({ min: 0, max: 59 }),
})
.map(({ hour, minute }) => {
return `${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}`;
});
/** Optional suffix: single uppercase letter or absent */
const suffixArb: fc.Arbitrary<string> = fc.constantFrom(...ALPHA_CHARS);
// ---------------------------------------------------------------------------
// Discriminated union arbitraries
// ---------------------------------------------------------------------------
const startArb: fc.Arbitrary<OnlineBoardParams> = fc.constant({ type: "start" as const });
const flightArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
// Without suffix
fc.record({
type: fc.constant("flight" as const),
carrier: carrierArb,
flightNumber: flightNumberArb,
date: dateArb,
}),
// With suffix
fc.record({
type: fc.constant("flight" as const),
carrier: carrierArb,
flightNumber: flightNumberArb,
suffix: suffixArb,
date: dateArb,
}),
);
const departureArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
fc.record({
type: fc.constant("departure" as const),
station: stationArb,
date: dateArb,
}),
fc.record({
type: fc.constant("departure" as const),
station: stationArb,
date: dateArb,
timeFrom: timeArb,
timeTo: timeArb,
}),
);
const arrivalArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
fc.record({
type: fc.constant("arrival" as const),
station: stationArb,
date: dateArb,
}),
fc.record({
type: fc.constant("arrival" as const),
station: stationArb,
date: dateArb,
timeFrom: timeArb,
timeTo: timeArb,
}),
);
const routeArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
fc.record({
type: fc.constant("route" as const),
departure: stationArb,
arrival: stationArb,
date: dateArb,
}),
fc.record({
type: fc.constant("route" as const),
departure: stationArb,
arrival: stationArb,
date: dateArb,
timeFrom: timeArb,
timeTo: timeArb,
}),
);
const detailsArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
fc.record({
type: fc.constant("details" as const),
carrier: carrierArb,
flightNumber: flightNumberArb,
date: dateArb,
}),
fc.record({
type: fc.constant("details" as const),
carrier: carrierArb,
flightNumber: flightNumberArb,
suffix: suffixArb,
date: dateArb,
}),
);
/** Combined arbitrary covering all 6 OnlineBoardParams discriminants */
const onlineBoardParamsArb: fc.Arbitrary<OnlineBoardParams> = fc.oneof(
{ weight: 1, arbitrary: startArb },
{ weight: 3, arbitrary: flightArb },
{ weight: 2, arbitrary: departureArb },
{ weight: 2, arbitrary: arrivalArb },
{ weight: 2, arbitrary: routeArb },
{ weight: 3, arbitrary: detailsArb },
);
// ---------------------------------------------------------------------------
// Register against harness
// ---------------------------------------------------------------------------
defineUrlParityTests<OnlineBoardParams>({
feature: "OnlineBoard",
fixturePath: "tests/fixtures/phase-2/url-corpus/onlineboard.json",
parse: parseOnlineBoardUrl,
build: buildOnlineBoardUrl,
fuzzArbitrary: onlineBoardParamsArb,
});
+1 -1
View File
@@ -25,6 +25,6 @@
},
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts", "modern.config.ts", "module-federation.config.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "vitest.config.ts", "modern.config.ts", "module-federation.config.ts"],
"exclude": ["node_modules", "dist", "ClientApp", "wwwroot"]
}