plan/react-rewrite #1
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user