polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul

Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and
knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with
eager-bundled per-namespace resources, a persisted accessible language switcher (topbar
and mobile drawer), locale-aware date/number formatting, and a coverage test that fails
the build on any missing or empty translation key.

Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves
from fixed English/Dutch prose to stable message codes + params so the frontend can
localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus
(11 documents each) with per-language retrieval and localized evidence-state messages.

The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a
floating panel that auto-collapses to a persistent, closable progress chip on standard
desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+
highlight on "go to this step", Escape handling, and reduced-motion support.

The Data Quality Workbench gets accessible choice-card decisions with a clear primary/
secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and
uses meaningful short refs; the Audit trail groups events by correlation id with human
action labels and readable before/after diffs. Attention Queue, Today's movements,
Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern)
with independent secondary links, keyboard support and mobile touch targets.

Fixes a topbar overflow on mobile caused by the new language switcher (moved into the
mobile drawer at <=960px) and two dangling aria-labelledby references introduced this
session. Updates all affected Playwright specs for the new nl-BE default and the new
Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and
clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-03 18:33:22 +02:00
co-authored by Claude Sonnet 5
parent 257a4cf6c0
commit 337f8716bb
127 changed files with 5529 additions and 1287 deletions
+105
View File
@@ -0,0 +1,105 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
// just its title/reference text, while any secondary in-row link stays independently usable.
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
expect(box).not.toBeNull();
// Click near the far right edge of the row -- empty space, not the title text or badge.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
const link = row.locator(".row-link");
await link.focus();
await expect(link).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("today's movements row opens the correct booking on click", async ({ page }) => {
const row = page.locator(".movement-timeline li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/bookings\/BK-/);
});
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
await page.goto("/vehicles");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
});
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
await page.goto("/bookings");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const vehicleLink = row.locator(".cell-link");
const vehicleRef = (await vehicleLink.innerText()).trim();
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
// it must not be swallowed by the row-spanning overlay link sitting behind it.
await vehicleLink.click();
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
await page.goto("/bookings");
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
const box = await rowAgain.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
});
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
});
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
// a mouse click at the same mobile viewport size still exercises the same CSS layout
// and click-target logic, since the app has no touch-specific event handling.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toHaveCSS("cursor", "pointer");
await row.locator(".row-link").focus();
await expect(row.locator(".row-link")).toBeFocused();
});
+3 -3
View File
@@ -30,10 +30,10 @@ test("demo guide does not cover the return form's action buttons on desktop", as
await expect(page).toHaveURL(/\/dashboard/);
await page.goto("/bookings/BK-DEMO-RETURN");
const reviewButton = page.getByRole("button", { name: "Review return" });
const reviewButton = page.getByRole("button", { name: "Retour nakijken" });
await expect(reviewButton).toBeVisible();
await reviewButton.click({ timeout: 5000 });
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible();
});
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
@@ -79,7 +79,7 @@ test("key demo pages load without console errors", async ({ page }) => {
await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
await page.goto("/about");
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
+2 -2
View File
@@ -37,9 +37,9 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
await page.getByRole("link", { name: /Over deze demo/ }).click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
await expect(page.getByText("Demomodus — lokale kennisprovider")).toBeVisible();
await expect(page.getByText("Demomodus").first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
});
+80 -4
View File
@@ -36,7 +36,10 @@ test("starting a scenario navigates to its fixed record", async ({ page }) => {
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
});
test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => {
// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
@@ -50,9 +53,26 @@ test("demo guide: navigating steps, jumping to a step, and closing works", async
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
// page's primary "Ask" action after navigation.
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
await expect(chip).toBeVisible();
await chip.click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
});
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
await expect(chip).toBeVisible();
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(chip).toBeHidden();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
});
@@ -61,12 +81,12 @@ test("demo guide progress persists across navigation and the trigger shows it",
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Sluiten" }).click();
await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8");
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
await page.goto("/vehicles");
await page.getByRole("button", { name: /Demo-gids/ }).click();
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
});
@@ -85,3 +105,59 @@ test("restarting the demo from the guide resets data and returns to login", asyn
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
});
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-wide");
await expect(panel).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(panel).toBeVisible();
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
});
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-mobile");
await expect(panel).toBeVisible();
await expect(panel).toHaveClass(/sheet-half/);
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-full/);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-collapsed/);
});
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
});
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
const target = page.locator("#ask-heading");
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
+15 -15
View File
@@ -17,17 +17,17 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
const odometerInput = page.getByLabel("End odometer (km)");
const odometerInput = page.getByLabel("Eindkilometerstand (km)");
await expect(odometerInput).not.toHaveValue("");
const prefilled = Number(await odometerInput.inputValue());
expect(prefilled).toBeGreaterThan(0);
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible();
await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible();
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
});
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
@@ -36,9 +36,9 @@ test("data quality issue detail explains what's wrong and why it matters", async
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByText("What's wrong")).toBeVisible();
await expect(page.getByText("Why it matters")).toBeVisible();
await expect(page.getByText(/likely the same person/)).toBeVisible();
await expect(page.getByText("Wat is er mis")).toBeVisible();
await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
});
test("data quality list can filter to demo scenarios only", async ({ page }) => {
@@ -49,7 +49,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count();
await page.getByRole("checkbox", { name: "Demo scenario's only" }).check();
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
const filteredRows = await page.locator(".data-table tbody tr").count();
expect(filteredRows).toBeGreaterThan(0);
expect(filteredRows).toBeLessThanOrEqual(allRows);
@@ -68,10 +68,10 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
// The status badge and retrieval-flow diagram must name the actual active provider
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
// allowed to mention RAGcore by name when explaining it isn't live yet.
await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base");
await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base");
await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
});
+24 -25
View File
@@ -24,7 +24,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
});
await test.step("2. verify dashboard metrics are loaded", async () => {
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
const metricValues = page.locator(".metric-cell dd");
await expect(metricValues.first()).toBeVisible();
const values = await metricValues.allTextContents();
@@ -35,62 +35,61 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("3. open active demo booking", async () => {
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.getByText("active", { exact: true })).toBeVisible();
await expect(page.getByText("actief", { exact: true })).toBeVisible();
});
await test.step("4. register an odometer-regression return (S1)", async () => {
const vehicleOdometerText = await page
.locator(".detail-grid div", { hasText: "Start odometer" })
.locator(".detail-grid div", { hasText: "Startkilometerstand" })
.locator("dd")
.textContent();
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
const lowReading = Math.max(0, startOdometer - 500);
await page.getByLabel("End odometer (km)").fill(String(lowReading));
await page.getByLabel("Fuel level (%)").fill("55");
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click();
await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
await page.getByLabel("Brandstofniveau (%)").fill("55");
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
});
await test.step("5. verify quality issue and queued automation event", async () => {
await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible();
await expect(page.getByText(/Queued for delivery \(/)).toBeVisible();
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
});
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
await page.getByRole("button", { name: /Merge into CUS-0012/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.locator(".badge.status-resolved")).toBeVisible();
});
await test.step("7. ask the damage question and inspect citations (S6)", async () => {
await page.goto("/knowledge");
await page
.getByPlaceholder(/What must I do when a vehicle returns with damage/)
.fill("What must I do when a vehicle returns with damage?");
await page.getByRole("button", { name: "Ask" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await expect(page.getByText("Damage handling procedure").first()).toBeVisible();
await expect(page.getByText("Vehicle return procedure").first()).toBeVisible();
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
await page.getByRole("button", { name: "Vraag stellen" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
});
await test.step("8. inspect audit entries", async () => {
await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
await expect(page.getByText("return registered").first()).toBeVisible();
await page.getByLabel("Actie").fill("return_registered");
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
});
await test.step("9. verify responsive navigation at mobile width", async () => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/dashboard");
await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+21 -16
View File
@@ -14,6 +14,11 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
request,
}) => {
await resetDemoData(request);
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
// directly across every step -- the standard-tier auto-collapse behaviour itself is
// covered separately in demo-guide.spec.ts.
await page.setViewportSize({ width: 1600, height: 1000 });
await test.step("start the guided demo from the login screen", async () => {
await page.goto("/login");
@@ -24,7 +29,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await test.step("step 1: understand the operational state", async () => {
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
});
@@ -38,10 +43,10 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
@@ -50,28 +55,28 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a");
await firstIssueLink.click();
await expect(page.getByText("What's wrong")).toBeVisible();
await expect(page.getByText("Wat is er mis")).toBeVisible();
// The newest issue is the odometer regression this return just created.
await page.getByRole("radio", { name: /Retain canonical/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
await page.getByRole("button", { name: "Probleem oplossen" }).click();
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
await test.step("step 5: review and merge the possible duplicate customer", async () => {
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
await page.getByRole("button", { name: /^Merge into/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
await page.getByRole("button", { name: /^Samenvoegen met/ }).click();
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
await test.step("step 6: ask the procedure assistant a question", async () => {
await expect(page).toHaveURL(/\/knowledge$/);
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
});
@@ -83,7 +88,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.locator(".integration-cards")).not.toContainText("degraded");
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
await page.goto("/audit");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await page.getByRole("button", { name: "Volgende" }).click();
});
@@ -92,7 +97,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
});
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Pure Node-context checks (no browser needed): every locale must define exactly the
// same set of translation keys. A missing key would otherwise silently fall back to
// showing the raw key string in production -- this test makes that impossible to ship.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
function collectKeyPaths(value: unknown, prefix = ""): string[] {
if (value === null || typeof value !== "object") {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
);
}
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
const namespaces = fs
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
expect(namespaces.length).toBeGreaterThan(0);
for (const namespace of namespaces) {
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
for (const language of LANGUAGES) {
if (language === "nl-BE") continue;
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
const missing = referenceKeys.filter((k) => !keys.includes(k));
const extra = keys.filter((k) => !referenceKeys.includes(k));
expect(
missing,
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
).toEqual([]);
expect(
extra,
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
).toEqual([]);
}
}
});
test("no locale file contains an empty string value", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const data = loadNamespace(language, namespace);
const keys = collectKeyPaths(data);
for (const keyPath of keys) {
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") {
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
}
}
}
}
});
+27 -23
View File
@@ -7,9 +7,13 @@ async function resetDemoData(request: APIRequestContext) {
test.describe.configure({ mode: "serial" });
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -52,7 +56,7 @@ test("vehicles page: free-text search actually filters the rendered rows", async
const totalRows = await page.locator(".data-table tbody tr").count();
expect(totalRows).toBeGreaterThan(1);
const searchBox = page.getByRole("form", { name: "Filter vehicles" }).getByLabel("Search");
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
await searchBox.fill("MO-001");
await expect(async () => {
const rows = await page.locator(".data-table tbody tr").count();
@@ -140,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
);
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy();
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
@@ -159,7 +163,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
await firstLink.click();
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
await page.getByRole("button", { name: "Defer" }).click();
await expect(page.getByText("deferred", { exact: true })).toBeVisible();
await expect(page.locator(".badge.status-deferred")).toBeVisible();
});
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
@@ -173,7 +177,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({
await page.getByLabel("Location").fill("Depot");
await page.getByRole("button", { name: "Save and re-check" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByText("Resolved").first()).toBeVisible();
});
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
@@ -184,7 +188,7 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByText("Resolved").first()).toBeVisible();
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
expect((await booking.json()).status).toBe("blocked");
});
@@ -220,7 +224,7 @@ test("data quality: retaining canonical resolves an odometer regression issue",
await page.getByRole("radio", { name: /Retain canonical/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByText("Resolved").first()).toBeVisible();
});
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
@@ -255,11 +259,11 @@ test("automation page: status filter and retry button work", async ({ page, requ
test("audit page: action filter works", async ({ page }) => {
await page.goto("/audit");
await expect(page.locator(".data-table")).toBeVisible();
await expect(page.locator(".audit-group-list")).toBeVisible();
await page.getByLabel("Action").fill("demo_login");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents();
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy();
await expect(page.locator(".audit-group").first()).toBeVisible();
const headings = await page.locator(".audit-group-heading strong").allTextContents();
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
});
test("audit page: shows human-readable before/after and a safe entity link", async ({
@@ -284,14 +288,14 @@ test("audit page: shows human-readable before/after and a safe entity link", asy
await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const firstGroup = page.locator(".audit-group").first();
await expect(firstGroup).toBeVisible();
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4);
await expect(changeCell).toContainText("status");
await expect(changeCell).toContainText("returned");
const changeDiff = firstGroup.locator(".change-diff");
await expect(changeDiff).toContainText(/status/i);
await expect(changeDiff).toContainText("returned");
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3);
await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/);
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
});
test("knowledge page: form submits and clears input", async ({ page }) => {
@@ -340,7 +344,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// Manager-only nav items are not shown at all, not merely disabled.
@@ -352,16 +356,16 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
// message as a defense-in-depth measure, not just a hidden button.
await page.goto("/automation");
await expect(
page.getByText("Automation delivery status is visible to Operations Managers only."),
page.getByText("Automation is visible to Operations Managers only.").first(),
).toBeVisible();
await page.goto("/data-quality");
await expect(
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."),
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
).toBeVisible();
await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible();
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
});
@@ -374,7 +378,7 @@ test("operations manager can reset demo data and is returned to login", async ({
await expect(page).toHaveURL(/\/login$/);
// The reset must not have affected the ability to log back in against fresh data.
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -392,7 +396,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// page.request shares the browser context's cookies, and (via the web container's
+10 -6
View File
@@ -5,10 +5,14 @@ async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/reset");
}
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -22,7 +26,7 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
await page.keyboard.press("Control+k");
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await expect(search).toBeFocused();
await search.fill("MO-024");
const result = page.getByRole("option", { name: /MO-024/ });
@@ -33,7 +37,7 @@ test("global search supports its keyboard shortcut and finds a vehicle by refere
});
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("fleet");
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
await search.press("ArrowDown");
@@ -42,7 +46,7 @@ test("global search supports arrow-key navigation and Enter to select", async ({
});
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("zzz-nothing-matches-zzz");
await expect(page.getByText(/No matches for/)).toBeVisible();
await search.press("Escape");
@@ -50,7 +54,7 @@ test("global search shows a no-results state and closes on Escape", async ({ pag
});
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("BK-DEMO-RETURN");
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
await expect(bookingResult).toBeVisible();
@@ -83,7 +87,7 @@ test("return review separates capture from irreversible commit", async ({ page }
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating
// preview call is expected before any commit.
expect(previewRequests).toBe(1);
await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible();
await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
await page.getByRole("button", { name: "Edit details" }).click();
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");