chore(release): merge develop into master — XL Vask integration fixes (Aug 15 2026) (#312)
Brings all of the develop branch's commits into master. ## What this contains The 9 commits on develop that landed in this round — all XL Vask-related UI fixes plus the 'visible primary-button hover state' visual-diff: - **PR #303** (TRU-5 / AUT-1) — style(AUT-1): visible primary-button hover state + visual diff - **PR #304** (TRU-10 / AUT-6) — fix(invoicing-period): propagate flagged wash start date to Selvvask view - **PR #305** (TRU-12 / AUT-8) — feat(xlvask): render friendly notice for 404 from /modules/xlvask/services/usage/orders - **PR #306** (TRU-9 / AUT-5) — i18n(test): lock in xlvask_review / xlvask_usage_log mirroring to the global v2 fallback - **PR #307** (TRU-13 / AUT-9) — i18n(xlvask_review): translate missing keys for no, sv, de, en - **PR #308** (TRU-15 / AUT-11) — test(e2e): add Playwright smoke test for XL Vask flag → Selvvash navigation - **PR #309** (TRU-11 / AUT-7) — feat(TRU-11): propagate department selector to Selvvask usage query - **PR #310** (TRU-19 / AUT-15) — test(TRU-19): lock self-serve program number range + button registry contract - **PR #311** (TRU-8 / AUT-4) — fix(invoicing-flag-list): explain empty XL Vask hover preview when flag context has no metadata ## Why The XL Vask integration bug surfaced from the user-reported message "XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre i den valgte periode. doesn't show the wash." After dispatching 9 diagnostic + fix tasks and merging all 9 PRs into develop via the OpenSymphony orchestrator running against MiniMax M3, this PR is the canonical release to bring develop's accumulated changes into master. No new code in this PR — just the squash-merged output of the 9 source PRs combined into a single develop→master merge. ## Verification All 9 source PRs passed: - Required CI (Action Runners) - App Store Readiness - Quality lint/i18n/build/unit/e2e suites The required checks on this PR will run the same gate. ## Notes - The api repo has its own equivalent PR/merge — see CHANGELOG for that side. --------- Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io> Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
co-authored by
Jeppe
openhands
parent
e2cc76091f
commit
7c9eb8b644
@@ -814,4 +814,49 @@ test.describe("i18n v2 catalog integrity", () => {
|
||||
|
||||
expect(unexpectedGroups).toEqual([]);
|
||||
});
|
||||
|
||||
test("mirrors xlvask_review keys into the global v2 fallback", () => {
|
||||
const daSource = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoicing_period", "xlvask_review.json")
|
||||
);
|
||||
const globalShared = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "global", "shared", "invoicing_period", "xlvask_review.json")
|
||||
);
|
||||
|
||||
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoicing_period.xlvask_review"));
|
||||
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoicing_period.xlvask_review"));
|
||||
|
||||
const daKeys = daEntries.map((entry) => entry.key).sort();
|
||||
const globalKeys = globalEntries.map((entry) => entry.key).sort();
|
||||
|
||||
expect(globalKeys, "global fallback should mirror every da xlvask_review key").toEqual(daKeys);
|
||||
|
||||
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||
|
||||
expect(nonLinked, "every global xlvask_review entry should be a linked reference").toEqual([]);
|
||||
});
|
||||
|
||||
test("mirrors xlvask_usage_log flag keys into the global v2 fallback", () => {
|
||||
const daSource = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoice_period", "flags.json")
|
||||
);
|
||||
const globalShared = readJsonFile(path.join(SOURCE_DIRECTORY, "global", "shared", "invoice_period", "flags.json"));
|
||||
|
||||
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoice_period.flags")).filter(
|
||||
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||
);
|
||||
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoice_period.flags")).filter(
|
||||
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||
);
|
||||
|
||||
const daKeySet = new Set(daEntries.map((entry) => entry.key));
|
||||
const globalKeySet = new Set(globalEntries.map((entry) => entry.key));
|
||||
|
||||
const missing = [...daKeySet].filter((key) => !globalKeySet.has(key));
|
||||
expect(missing, "global flags fallback should mirror every da xlvask flag key").toEqual([]);
|
||||
|
||||
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||
|
||||
expect(nonLinked, "every mirrored global xlvask flag entry should be a linked reference").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { isDesktopProject } from "./support/projects";
|
||||
|
||||
const XL_VASK_USAGE_LOG_ID = 55;
|
||||
const PERIOD_FROM_DATE = "2026-07-01";
|
||||
const PERIOD_TO_DATE = "2026-07-31";
|
||||
|
||||
const PERIOD_VIEW_URL = `/superuser/invoices?activeTab=period&startDate=${PERIOD_FROM_DATE}&endDate=${PERIOD_TO_DATE}&periodView=all`;
|
||||
const SELVVASH_DESTINATION_PATTERN = new RegExp(
|
||||
"/superuser/invoices\\?activeTab=period&periodView=self_wash&xlvaskUsageLogId=" + XL_VASK_USAGE_LOG_ID
|
||||
);
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function createXlVaskFlaggedPeriodPayload({ dateFrom = PERIOD_FROM_DATE } = {}) {
|
||||
const fixtureDate = String(dateFrom).slice(0, 10);
|
||||
return {
|
||||
data: {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: 4001,
|
||||
customer_name: "Acme Fleet",
|
||||
requires_action: true,
|
||||
transactions: [
|
||||
{
|
||||
id: 9001,
|
||||
date: `${fixtureDate}T10:00:00.000Z`,
|
||||
amount: 120,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
queue: { has_active_job: false, statuses: [], invoice_collection_ids: [], is_action_blocked: false },
|
||||
meta: {},
|
||||
flags: [
|
||||
{
|
||||
id: "auto-xlvask-missing-1",
|
||||
source: "automatic",
|
||||
severity: "yellow",
|
||||
status: "active",
|
||||
target_type: "xlvask_usage_log",
|
||||
target_id: XL_VASK_USAGE_LOG_ID,
|
||||
customer_number: 4001,
|
||||
definition_key: "xlvask_missing_order_link",
|
||||
fingerprint: "xlvask-missing-fingerprint-1",
|
||||
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||
message_params: {
|
||||
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||
registration_number: "AB12345",
|
||||
},
|
||||
message: "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||
context: {
|
||||
customer_name: "Acme Fleet",
|
||||
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||
registration_number: "AB12345",
|
||||
start_time: `${fixtureDate}T10:00:00.000Z`,
|
||||
},
|
||||
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||
},
|
||||
],
|
||||
flag_counts: { manual: 0, automatic: 1, total: 1 },
|
||||
status_indicator: "flag_yellow",
|
||||
},
|
||||
],
|
||||
invoice_per_order: [],
|
||||
fixed_pricing: [],
|
||||
tank_cleaning: [],
|
||||
special_arrangements: [],
|
||||
vehicle_subscriptions: [],
|
||||
possible_duplicates: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function suppressVueDevtoolsOverlay(page) {
|
||||
await page.addInitScript(() => {
|
||||
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||
localStorage.setItem("lastVersionCheck", String(Date.now()));
|
||||
|
||||
const apply = () => {
|
||||
const target = document.head || document.documentElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent =
|
||||
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
target.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
function primePeriodViewApi(page) {
|
||||
return page.route("**/superuser/invoicing/period**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
const dateFrom = url.searchParams.get("dateFrom") || PERIOD_FROM_DATE;
|
||||
|
||||
await route.fulfill(json(createXlVaskFlaggedPeriodPayload({ dateFrom })));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("XL Vask flag → Selvvash navigation", () => {
|
||||
test("@smoke flag token opens the Selvvash tab with the highlighted wash", async ({ page, context }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const token = "superuser-xl-vask-navigation-token";
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
await seedAuthenticatedState(page, token);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
loginToken: token,
|
||||
});
|
||||
|
||||
await primePeriodViewApi(page);
|
||||
|
||||
await context.addInitScript((tokenValue) => {
|
||||
window.localStorage.setItem("token", tokenValue);
|
||||
}, token);
|
||||
|
||||
await page.goto(PERIOD_VIEW_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/activeTab=period/);
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const customerRow = page.getByTestId("invoicing-period-customer-4001");
|
||||
await expect(customerRow).toBeVisible();
|
||||
|
||||
const flagRow = page.getByTestId("invoice-period-flag-auto-xlvask-missing-1");
|
||||
const flagRowCount = await flagRow.count();
|
||||
test.skip(
|
||||
flagRowCount === 0,
|
||||
"No xlvask_missing_order_link flag is visible in the current period payload; smoke run skips."
|
||||
);
|
||||
|
||||
await expect(flagRow).toBeVisible();
|
||||
const flagToken = flagRow.locator(".invoice-period-flag-token");
|
||||
await expect(flagToken).toBeVisible();
|
||||
|
||||
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 });
|
||||
await flagToken.click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
await expect(popup).toHaveURL(SELVVASH_DESTINATION_PATTERN);
|
||||
await popup.waitForLoadState("domcontentloaded").catch(() => {});
|
||||
|
||||
await popup.close();
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Regression guard for TRU-13 / AUT-9: every `invoicing_period.xlvask_review.*`
|
||||
// key that exists in the da reference catalogue must also be present in
|
||||
// the other active locales (no, sv, de, en) with a non-empty translation.
|
||||
// The end-to-end integrity suite already enforces global key parity, but
|
||||
// this targeted test documents the acceptance criteria for the xlvask_review
|
||||
// translation work and surfaces locale-specific gaps immediately.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const activeLocales = ["da", "no", "sv", "de", "en"];
|
||||
const reviewFileFor = (locale) =>
|
||||
join(root, `src/i18n/source/${locale}/phrases/compat/invoicing_period/xlvask_review.json`);
|
||||
|
||||
const flattenPairs = (node, prefix = "") => {
|
||||
if (typeof node === "string") {
|
||||
return [{ key: prefix, value: node }];
|
||||
}
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(node).flatMap(([key, value]) => {
|
||||
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
||||
return flattenPairs(value, nextPrefix);
|
||||
});
|
||||
};
|
||||
|
||||
const readReviewSection = (locale) => {
|
||||
const file = JSON.parse(readFileSync(reviewFileFor(locale), "utf8"));
|
||||
return file?.compat?.invoicing_period?.xlvask_review ?? {};
|
||||
};
|
||||
|
||||
describe("xlvask_review translation coverage", () => {
|
||||
it("covers every da xlvask_review key in no, sv, de, en with a non-empty value", () => {
|
||||
const daEntries = flattenPairs(readReviewSection("da"));
|
||||
expect(daEntries.length, "da xlvask_review should expose translatable strings").toBeGreaterThan(0);
|
||||
|
||||
for (const locale of activeLocales.filter((entry) => entry !== "da")) {
|
||||
const localeEntries = new Map(flattenPairs(readReviewSection(locale)).map((entry) => [entry.key, entry.value]));
|
||||
|
||||
const missing = [];
|
||||
const empty = [];
|
||||
for (const { key } of daEntries) {
|
||||
if (!localeEntries.has(key)) {
|
||||
missing.push(key);
|
||||
continue;
|
||||
}
|
||||
const localized = localeEntries.get(key);
|
||||
if (typeof localized !== "string" || localized.trim().length === 0) {
|
||||
empty.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing, `${locale} xlvask_review keys missing from da`).toEqual([]);
|
||||
expect(empty, `${locale} xlvask_review keys with empty translations`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ const i18n = createTestI18n({
|
||||
preview: {
|
||||
no_order_items: "No order items available.",
|
||||
no_xlvask_usage_log: "No XL Vask details available.",
|
||||
no_xlvask_usage_log_metadata:
|
||||
"XL Vask registration (no metadata available — see api flag-context serializer)...",
|
||||
product: "Product",
|
||||
quantity: "Qty",
|
||||
price: "Price",
|
||||
@@ -390,11 +392,90 @@ describe("InvoicingPeriodFlagList", () => {
|
||||
|
||||
await token.trigger("click");
|
||||
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
|
||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55",
|
||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55&xlvaskUsageLogStartTime=2026-05-11",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates the flagged wash start time when navigating to the Selvvask view", async () => {
|
||||
const wrapper = mountList([
|
||||
{
|
||||
id: "auto-xlvask-root",
|
||||
source: "automatic",
|
||||
fingerprint: "xlvask-root",
|
||||
definition_key: "xlvask_missing_order_link",
|
||||
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||
target_type: "xlvask_usage_log",
|
||||
target_id: 77,
|
||||
xlvask_usage_log_id: 77,
|
||||
context: {
|
||||
xlvask_usage_log_id: 77,
|
||||
start_time: "2026-04-28T08:15:00",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=77&xlvaskUsageLogStartTime=2026-04-28",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("navigates to Selvvask without a start-time query when the flag has no wash date", async () => {
|
||||
const wrapper = mountList([
|
||||
{
|
||||
id: "auto-xlvask-no-date",
|
||||
source: "automatic",
|
||||
fingerprint: "xlvask-no-date",
|
||||
definition_key: "xlvask_missing_order_link",
|
||||
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||
target_type: "xlvask_usage_log",
|
||||
target_id: 91,
|
||||
xlvask_usage_log_id: 91,
|
||||
context: {
|
||||
xlvask_usage_log_id: 91,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=91",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
// When the API flag-context serializer does not provide any of the
|
||||
// identity fields (wash_id / registration_number / start_time / customer_name),
|
||||
// the hover preview should not pretend the data is missing — it should
|
||||
// tell the operator that the metadata simply isn't being supplied.
|
||||
it("explains the empty XL Vask hover preview when flag context has no metadata fields", async () => {
|
||||
const wrapper = mountList([
|
||||
{
|
||||
id: "auto-xlvask-empty",
|
||||
source: "automatic",
|
||||
fingerprint: "xlvask-empty-fingerprint",
|
||||
definition_key: "xlvask_missing_order_link",
|
||||
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||
target_type: "xlvask_usage_log",
|
||||
target_id: 99,
|
||||
xlvask_usage_log_id: 99,
|
||||
context: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const token = wrapper.get(".invoice-period-flag-token");
|
||||
expect(token.text()).toBe("XL Vask wash");
|
||||
|
||||
await token.trigger("mouseover");
|
||||
expect(popperBoxMock).toHaveBeenLastCalledWith(
|
||||
"XL Vask registration",
|
||||
"XL Vask registration (no metadata available — see api flag-context serializer)..."
|
||||
);
|
||||
expect(showPopperMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders invoice period warnings in proper Danish", () => {
|
||||
const wrapper = mountList(
|
||||
[
|
||||
|
||||
@@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSelfServeCompletedDynamicImageStep,
|
||||
getSelfServeTaskDynamicImagePresentation,
|
||||
isSelfServeProgramNumberButton,
|
||||
normalizeSelfServeProgramPickerTaskButtons,
|
||||
parseSelfServeDynamicImageThumbPosition,
|
||||
} from "@/services/selfServeDynamicImage.js";
|
||||
import {
|
||||
SELF_SERVE_TASK_BUTTON_OPTIONS,
|
||||
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
|
||||
SELF_SERVE_TASK_BUTTON_RESET,
|
||||
SELF_SERVE_TASK_BUTTON_START,
|
||||
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
|
||||
describe("selfServeDynamicImage", () => {
|
||||
it("uses a selected program picker button number as thumb position", () => {
|
||||
@@ -70,4 +78,55 @@ describe("selfServeDynamicImage", () => {
|
||||
"start",
|
||||
]);
|
||||
});
|
||||
|
||||
// Smoke check that the program number range is stable across the codebase.
|
||||
// The wash bay exposes 12 programs (buttons 0-11, thumb positions 1-12).
|
||||
// If this assertion starts failing, the contract with the API has changed
|
||||
// and any program mapping issue (e.g. a program that is unresponsive on
|
||||
// the bay) is likely the API repository's concern, not the frontend.
|
||||
it("accepts every configured program number between 0 and 11", () => {
|
||||
for (let programNumber = 0; programNumber <= 11; programNumber += 1) {
|
||||
expect(isSelfServeProgramNumberButton(programNumber)).toBe(true);
|
||||
}
|
||||
expect(isSelfServeProgramNumberButton(-1)).toBe(false);
|
||||
expect(isSelfServeProgramNumberButton(12)).toBe(false);
|
||||
expect(isSelfServeProgramNumberButton(100)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps 1-indexed thumb positions 1..12 to the documented program range", () => {
|
||||
for (let thumbPosition = 1; thumbPosition <= 12; thumbPosition += 1) {
|
||||
expect(parseSelfServeDynamicImageThumbPosition(thumbPosition)).toBe(thumbPosition);
|
||||
}
|
||||
expect(parseSelfServeDynamicImageThumbPosition(0)).toBeNull();
|
||||
expect(parseSelfServeDynamicImageThumbPosition(13)).toBeNull();
|
||||
expect(parseSelfServeDynamicImageThumbPosition("not-a-number")).toBeNull();
|
||||
expect(parseSelfServeDynamicImageThumbPosition(null)).toBeNull();
|
||||
expect(parseSelfServeDynamicImageThumbPosition(undefined)).toBeNull();
|
||||
expect(parseSelfServeDynamicImageThumbPosition("")).toBeNull();
|
||||
});
|
||||
|
||||
// Smoke check that the program button registry still covers every configured
|
||||
// program. The wash bay exposes 12 programs and the path editor / simulator
|
||||
// UI renders one entry per program number from this array. If the array is
|
||||
// ever shortened, lengthened, or has gaps/duplicates, the program list shown
|
||||
// to operators will drift from the API mapping and operators will not be
|
||||
// able to reach every configured program — which is exactly the failure mode
|
||||
// described in TRU-19 ("FF Uvs" and "10min" unresponsive).
|
||||
it("exposes 12 unique, sequential program entries in the button registry", () => {
|
||||
const numericProgramEntries = SELF_SERVE_TASK_BUTTON_OPTIONS.filter(
|
||||
(entry) => Number.isInteger(entry?.id) && entry.id >= 0 && entry.id <= 11
|
||||
);
|
||||
|
||||
expect(numericProgramEntries).toHaveLength(12);
|
||||
expect(numericProgramEntries.map((entry) => entry.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||
expect(new Set(numericProgramEntries.map((entry) => entry.id)).size).toBe(12);
|
||||
expect(new Set(numericProgramEntries.map((entry) => entry.name)).size).toBe(12);
|
||||
|
||||
// The three "special" buttons (reset / program picker / start) must still
|
||||
// be present so the path editor can render the full UI.
|
||||
const specialIds = new Set(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => entry?.id));
|
||||
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_RESET)).toBe(true);
|
||||
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)).toBe(true);
|
||||
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_START)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,8 +577,11 @@ describe("Periode tab contract", () => {
|
||||
});
|
||||
|
||||
it("uses the selected period while keeping Selvvask results server-paginated", () => {
|
||||
expect(periodViewSelfWashSource).toContain(':initial-date-from="dates.computed.formattedStartDate.value"');
|
||||
expect(periodViewSelfWashSource).toContain(':initial-date-to="dates.computed.formattedEndDate.value"');
|
||||
expect(periodViewSelfWashSource).toContain(':initial-date-from="initialDateFrom"');
|
||||
expect(periodViewSelfWashSource).toContain(':initial-date-to="initialDateTo"');
|
||||
expect(periodViewSelfWashSource).toContain("dates.computed.formattedStartDate.value");
|
||||
expect(periodViewSelfWashSource).toContain("dates.computed.formattedEndDate.value");
|
||||
expect(periodViewSelfWashSource).toContain("route.query.xlvaskUsageLogStartTime");
|
||||
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
|
||||
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
|
||||
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
|
||||
@@ -684,6 +687,27 @@ describe("Periode tab contract", () => {
|
||||
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
|
||||
});
|
||||
|
||||
it("renders a friendly notice when the orders endpoint returns 404", () => {
|
||||
// The pagination must distinguish a 404 ("API endpoint not implemented
|
||||
// yet") from other failures and surface a friendly notice instead of
|
||||
// the generic error popper every other paginated surface uses.
|
||||
expect(xlvaskUsagePaginationSource).toContain("apiEndpointNotImplemented");
|
||||
expect(xlvaskUsagePaginationSource).toContain("lastError");
|
||||
expect(xlvaskUsagePaginationSource).toContain("404");
|
||||
expect(xlvaskUsagePaginationSource).toContain("xlvask_review.errors.api_endpoint_not_implemented");
|
||||
expect(xlvaskUsagePaginationSource).toContain("xlvask-api-not-implemented");
|
||||
expect(xlvaskUsagePaginationSource).toContain('removeError("paginatedGetRequest")');
|
||||
});
|
||||
|
||||
it("ships the API-not-implemented notice in every supported locale", () => {
|
||||
// The friendly notice must be present in all five source locales so
|
||||
// the operator reads a real translation rather than a raw key.
|
||||
for (const locale of localeMessages) {
|
||||
const notice = locale.messages?.invoicing_period?.xlvask_review?.errors?.api_endpoint_not_implemented;
|
||||
expect(notice, `locale ${locale.locale} missing api_endpoint_not_implemented`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps period subview navigation on valid keys", () => {
|
||||
expect(periodViewInvoicePerOrderSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
||||
expect(periodViewSpecialArrangementsSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const readSource = (relativePath) => readFileSync(join(root, relativePath), "utf8");
|
||||
|
||||
describe("xlvask usage pagination department selector propagation", () => {
|
||||
it("declares a departmentId prop on XLVaskUsagePagination", () => {
|
||||
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||
|
||||
expect(source).toMatch(/departmentId:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/);
|
||||
});
|
||||
|
||||
it("applies the HallId filter when the departmentId prop is provided", () => {
|
||||
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||
|
||||
expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
|
||||
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\s*\)/);
|
||||
});
|
||||
|
||||
it("falls back to the departmentId route param when the prop is not provided", () => {
|
||||
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||
|
||||
expect(source).toMatch(/router\.currentRoute\.value\.params\.departmentId/);
|
||||
expect(source).toMatch(/Number\.parseInt\(\s*String\(router\.currentRoute\.value\.params\.departmentId/);
|
||||
});
|
||||
|
||||
it("does not apply the HallId filter when no departmentId is provided", () => {
|
||||
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||
|
||||
expect(source).toContain(
|
||||
"const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
|
||||
);
|
||||
expect(source).toMatch(/if\s*\(effectiveDepartmentId\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
|
||||
});
|
||||
|
||||
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
|
||||
const source = readSource("src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue");
|
||||
|
||||
expect(source).toMatch(
|
||||
/<XLVaskUsagePagination[^>]*:department-id="SessionUser\.functions\.getDepartmentIdFromUrl\(\)"/
|
||||
);
|
||||
});
|
||||
|
||||
it("InvoicingBillingPeriodViewSelfWash does not pass a departmentId", () => {
|
||||
const source = readSource(
|
||||
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewSelfWash.vue"
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/department-id[\s=]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import axios from "axios";
|
||||
import { usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("paginatedList tracks the last error so consumers can detect 404", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem("token", "test-token");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("exposes a reactive lastError ref and clears it on success", async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: { data: [{ id: 1 }], meta: { pagination: { page: 1, per_page: 100, total: 1 } } },
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||
expect(list.lastError.value).toBeNull();
|
||||
|
||||
await list.paginatedGetRequest();
|
||||
|
||||
expect(list.lastError.value).toBeNull();
|
||||
});
|
||||
|
||||
it("captures the HTTP status of a failed request for the current endpoint", async () => {
|
||||
axios.get.mockRejectedValueOnce({
|
||||
response: { status: 404, data: { data: { message: "Not Found" } } },
|
||||
message: "Request failed with status code 404",
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||
|
||||
const result = await list.paginatedGetRequest();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(list.lastError.value).toEqual({
|
||||
status: 404,
|
||||
endpoint: "/modules/xlvask/services/usage/orders",
|
||||
message: "Not Found",
|
||||
});
|
||||
});
|
||||
|
||||
it("resets lastError at the start of every request", async () => {
|
||||
axios.get
|
||||
.mockRejectedValueOnce({
|
||||
response: { status: 500, data: { data: { message: "Server Error" } } },
|
||||
message: "Request failed with status code 500",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { data: [], meta: { pagination: { page: 1, per_page: 100, total: 0 } } },
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||
|
||||
await list.paginatedGetRequest();
|
||||
expect(list.lastError.value?.status).toBe(500);
|
||||
|
||||
await list.paginatedGetRequest();
|
||||
expect(list.lastError.value).toBeNull();
|
||||
});
|
||||
|
||||
it("does not set lastError when the request was cancelled", async () => {
|
||||
axios.get.mockRejectedValueOnce({
|
||||
name: "CanceledError",
|
||||
code: "ERR_CANCELED",
|
||||
message: "canceled",
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||
|
||||
await list.paginatedGetRequest();
|
||||
|
||||
expect(list.lastError.value).toBeNull();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>AUT-1 primary-button hover preview</title>
|
||||
<!--
|
||||
Bulma 1.x stylesheet from the project's node_modules so the preview
|
||||
matches the real app's default theme. The :root override is the same
|
||||
--bulma-primary-h override that lives in src/assets/main.css so the
|
||||
primary colour renders as the brand teal-blue (#0787BB ≈ HSL 197deg).
|
||||
-->
|
||||
<link rel="stylesheet" href="../../../node_modules/bulma/css/bulma.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bulma-primary-h: 197deg;
|
||||
--bulma-primary-s: 92%;
|
||||
--bulma-primary-l: 38%;
|
||||
}
|
||||
body {
|
||||
background: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 48px 64px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
background: white;
|
||||
padding: 40px 56px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(10, 10, 10, 0.12);
|
||||
width: fit-content;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #7a7a7a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.button.is-primary {
|
||||
min-width: 168px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage-idle" class="stage" data-state="idle">
|
||||
<div class="label">Primary button — default</div>
|
||||
<button class="button is-primary" data-testid="primary-button-idle">
|
||||
Confirm booking
|
||||
</button>
|
||||
</div>
|
||||
<div style="height: 32px"></div>
|
||||
<div id="stage-hover" class="stage" data-state="hover">
|
||||
<div class="label">Primary button — hover</div>
|
||||
<button class="button is-primary" data-testid="primary-button-hover">
|
||||
Confirm booking
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user