Role names, audit/scenario labels, and status text were previously either left in English or only partially translated: - auth.json/demo.json role labels actually translated (not just labelled as translated): Operationsmanager/Verhuurmedewerker, Responsable des operations/Collaborateur de location. - "Audit trail" -> Auditgeschiedenis/Piste d'audit (title, column header, and every mid-sentence occurrence across demo.json, quality.json, returns.json -- these embedded leaks were previously invisible to the whole-string identity check). - "Open" (status) -> Openstaand, "Recent" -> Recentste, "Start scenario" -> Scenario starten / Demarrer le scenario. Matching Playwright spec text updated in the same commit so the suite never regresses through a broken intermediate state.
390 lines
18 KiB
TypeScript
390 lines
18 KiB
TypeScript
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
|
|
|
// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops-
|
|
// correction/): branding, the redesigned status-recommendation flow (preview/apply/
|
|
// manual-review/stale-token), MO-016 order independence, trilingual knowledge
|
|
// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts
|
|
// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts
|
|
// (breakpoint matrix) for the complementary static-content checks.
|
|
|
|
async function resetDemoData(request: APIRequestContext) {
|
|
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
|
expect(login.ok()).toBeTruthy();
|
|
const reset = await request.post("/api/v1/demo/reset");
|
|
expect(reset.ok()).toBeTruthy();
|
|
// /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any
|
|
// further authenticated call through this same request context needs a fresh login.
|
|
const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
|
expect(relogin.ok()).toBeTruthy();
|
|
}
|
|
|
|
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
|
"nl-BE": "Verken als Operationsmanager",
|
|
"en-GB": "Explore as Operations Manager",
|
|
"fr-BE": "Explorer en tant que Responsable des opérations",
|
|
};
|
|
|
|
const REVIEW_RECOMMENDATION: Record<string, string> = {
|
|
"nl-BE": "Aanbeveling bekijken",
|
|
"en-GB": "Review recommendation",
|
|
"fr-BE": "Voir la recommandation",
|
|
};
|
|
|
|
const CHANGE_STATUS_PREFIX: Record<string, RegExp> = {
|
|
"nl-BE": /^Status wijzigen naar/,
|
|
"en-GB": /^Change status to/,
|
|
"fr-BE": /^Changer le statut vers/,
|
|
};
|
|
|
|
const MANUAL_REVIEW_HEADING: Record<string, string> = {
|
|
"nl-BE": "Handmatige beoordeling vereist",
|
|
"en-GB": "Manual review required",
|
|
"fr-BE": "Évaluation manuelle requise",
|
|
};
|
|
|
|
async function loginAsOpsManager(page: Page, lang: string) {
|
|
await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang);
|
|
await page.goto("/login");
|
|
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
}
|
|
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.describe("branding", () => {
|
|
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
|
test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, lang);
|
|
await expect(page.locator(".brand-mark").first()).toBeVisible();
|
|
await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible();
|
|
await expect(page.locator(".app-footer")).toContainText("Fleet Ops");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
|
|
|
for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) {
|
|
await page.goto(path);
|
|
const text = await page.locator("body").innerText();
|
|
expect(text, `${path} (${lang})`).not.toContain("MobilityOps");
|
|
expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/login");
|
|
const href = await page.locator('link[rel="icon"]').getAttribute("href");
|
|
expect(href).toBe("/favicon.svg");
|
|
const response = await page.request.get(href as string);
|
|
expect(response.ok()).toBeTruthy();
|
|
expect(response.headers()["content-type"]).toContain("svg");
|
|
});
|
|
|
|
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
|
// nl-BE on every reload, defeating exactly the persistence behaviour under test.
|
|
await page.goto("/login");
|
|
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible();
|
|
|
|
await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE");
|
|
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
|
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
|
|
|
await page.reload();
|
|
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
|
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
|
});
|
|
|
|
test.describe("status-recommendation flow", () => {
|
|
test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "nl-BE");
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
|
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible();
|
|
|
|
// Previewing must not have resolved the issue -- still open, using the page's own
|
|
// authenticated session (page.request shares cookies with the browser context).
|
|
const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
|
expect((await issue.json()).status).toBe("open");
|
|
|
|
const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] });
|
|
await expect(confirmButton).toHaveText(/Geblokkeerd/);
|
|
await confirmButton.click();
|
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
|
|
|
const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
|
expect((await resolved.json()).status).toBe("resolved");
|
|
});
|
|
|
|
test("manual review state offers no generic apply button for a genuine fact contradiction", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
const issues = await (
|
|
await request.get("/api/v1/data-quality/issues", {
|
|
params: { rule_type: "vehicle_status_conflict", status: "open" },
|
|
})
|
|
).json();
|
|
const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024");
|
|
expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy();
|
|
|
|
await loginAsOpsManager(page, "en-GB");
|
|
await page.goto(`/data-quality/${conflicted.public_ref}`);
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
|
|
|
await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0);
|
|
});
|
|
|
|
test("a stale recommendation is rejected and the user must review again before applying", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "en-GB");
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
|
await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible();
|
|
|
|
// Simulate the underlying facts changing after the preview was shown (the same
|
|
// session resolves the booking overlap in the meantime) -- the previously-fetched
|
|
// recommendation token must no longer be accepted.
|
|
await page.evaluate(async () => {
|
|
await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }),
|
|
});
|
|
});
|
|
|
|
await page.getByRole("button", { name: /^Change status to/ }).click();
|
|
await expect(page.getByText(/situation has changed/i)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("MO-016 status conflict is order-independent", () => {
|
|
// Order independence does NOT mean "the same final vehicle status regardless of
|
|
// order" -- resolving the booking overlap first genuinely removes the conflict, so
|
|
// there is correctly nothing left to apply afterwards. What must hold in either
|
|
// order: the recommendation always reflects the real, current facts (never a stale
|
|
// "was some other issue open" proxy), and nothing unsafe is ever applied (never
|
|
// "rented").
|
|
|
|
test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "nl-BE");
|
|
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
|
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
|
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
|
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
|
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
|
await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0);
|
|
|
|
const vehicle = await request.get("/api/v1/vehicles/MO-016");
|
|
expect((await vehicle.json()).operational_status).toBe("available");
|
|
});
|
|
|
|
test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "nl-BE");
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
|
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
|
|
|
const vehicleMid = await request.get("/api/v1/vehicles/MO-016");
|
|
const statusAfterApply = (await vehicleMid.json()).operational_status;
|
|
expect(statusAfterApply).not.toBe("rented");
|
|
|
|
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
|
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
|
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
|
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
|
|
|
// Resolving the now-redundant overlap afterwards must not itself change the
|
|
// vehicle's status as a side effect.
|
|
const vehicleFinal = await request.get("/api/v1/vehicles/MO-016");
|
|
const statusFinal = (await vehicleFinal.json()).operational_status;
|
|
expect(statusFinal).toBe(statusAfterApply);
|
|
expect(statusFinal).not.toBe("rented");
|
|
|
|
await resetDemoData(request);
|
|
});
|
|
});
|
|
|
|
test.describe("knowledge base is grounded in the operator's own language", () => {
|
|
const cases: { lang: string; question: string; sourceHint: RegExp }[] = [
|
|
{
|
|
lang: "nl-BE",
|
|
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
|
sourceHint: /schadeafhandeling/i,
|
|
},
|
|
{
|
|
lang: "en-GB",
|
|
question: "What should I do when a vehicle returns with damage?",
|
|
sourceHint: /damage/i,
|
|
},
|
|
{
|
|
lang: "fr-BE",
|
|
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
|
sourceHint: /dommages/i,
|
|
},
|
|
];
|
|
|
|
for (const { lang, question, sourceHint } of cases) {
|
|
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, lang);
|
|
await page.goto("/knowledge");
|
|
await page.locator("#knowledge-question").fill(question);
|
|
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
|
|
await expect(page.getByText(sourceHint).first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
}
|
|
});
|
|
|
|
test("audit trail shows localized action and field labels with raw codes only in technical details", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "nl-BE");
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
|
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
|
|
|
await page.goto("/audit");
|
|
|
|
// Applying resolves both the vehicle status and the issue in one correlated action --
|
|
// expand the group's "technical events" toggle so the other audit row's diff renders
|
|
// too, regardless of which one the grouping picked as primary.
|
|
const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first();
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible();
|
|
|
|
await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible();
|
|
const diffs = page.locator(".change-diff");
|
|
await expect(diffs.first()).toBeVisible();
|
|
const combinedDiffText = (await diffs.allInnerTexts()).join(" ");
|
|
expect(combinedDiffText).toContain("Operationele status");
|
|
expect(combinedDiffText).not.toContain("operational_status");
|
|
});
|
|
|
|
test("automation shows a localized error explanation with the raw error only under technical details", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, "nl-BE");
|
|
await page.goto("/automation");
|
|
|
|
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
|
|
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
|
await page.getByText("Technische details").first().click();
|
|
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
|
});
|
|
|
|
test.describe("route matrix (section 11F)", () => {
|
|
// Opens every main route in all 3 languages: no console errors, correct html[lang],
|
|
// and a real, non-empty page heading (proving the route actually rendered content
|
|
// instead of silently falling back to a raw i18next key or a blank screen). Key
|
|
// parity across locale files is already proven structurally by i18n-coverage.spec.ts
|
|
// (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
|
|
// matrix focuses on what only a live render can catch.
|
|
const routes = [
|
|
"/dashboard",
|
|
"/vehicles",
|
|
"/vehicles/MO-001",
|
|
"/bookings",
|
|
"/bookings/BK-DEMO-RETURN",
|
|
"/data-quality",
|
|
"/data-quality/DQ-DEMO-STATUS",
|
|
"/automation",
|
|
"/knowledge",
|
|
"/audit",
|
|
"/scenarios",
|
|
"/about",
|
|
];
|
|
|
|
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
|
test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
|
|
const errors: string[] = [];
|
|
page.on("console", (msg) => {
|
|
if (msg.type() !== "error") return;
|
|
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
|
errors.push(msg.text());
|
|
});
|
|
page.on("pageerror", (err) => errors.push(err.message));
|
|
|
|
await loginAsOpsManager(page, lang);
|
|
|
|
for (const route of routes) {
|
|
await page.goto(route);
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
|
const heading = page.getByRole("heading", { level: 1 });
|
|
await expect(heading, `${route} (${lang})`).toBeVisible();
|
|
const headingText = (await heading.first().textContent())?.trim() ?? "";
|
|
expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
|
|
// A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
|
|
// page headings never contain a colon followed by a dotted identifier.
|
|
expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
|
|
/^[a-zA-Z]+:[\w.]+$/,
|
|
);
|
|
}
|
|
|
|
expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
|
|
// The primary evidence line at the top of every issue's detail page must render the
|
|
// structured `evidence.signals` in the operator's language; the legacy English
|
|
// `evidence.summary` string is a technical fallback only, visible solely inside
|
|
// "Technical details". Live-caught: this line was unconditionally showing raw
|
|
// English ("vehicle marked available while reserved bookings conflict") in every
|
|
// language until fixed.
|
|
const cases: { lang: string; expectedText: RegExp }[] = [
|
|
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
|
|
{ lang: "en-GB", expectedText: /overlapping bookings/i },
|
|
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
|
|
];
|
|
|
|
for (const { lang, expectedText } of cases) {
|
|
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await loginAsOpsManager(page, lang);
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
|
|
const summarySection = page.locator(".record-surface-evidence");
|
|
await expect(summarySection).toBeVisible();
|
|
await expect(summarySection).toContainText(expectedText);
|
|
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
|
|
});
|
|
}
|
|
});
|