187 files
This commit is contained in:
@@ -114,6 +114,23 @@ export interface CreateMockHassResult {
|
||||
subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a value in an `<ms-date-field>` the way HA's selector would report it
|
||||
* (#163): the wrapper renders `<ha-selector>` (undefined under web-test-runner,
|
||||
* so it is an inert unknown element) and converts the selector's own value
|
||||
* format — "YYYY-MM-DD", "HH:MM:SS", "YYYY-MM-DD HH:MM:SS" — into the value
|
||||
* contract the consumers store. Dispatching on the inner selector exercises
|
||||
* that conversion end-to-end.
|
||||
*/
|
||||
export function pickDateField(field: Element | null, haValue: string | undefined): void {
|
||||
if (!field) throw new Error("pickDateField: no <ms-date-field>");
|
||||
const sel = field.shadowRoot?.querySelector("ha-selector");
|
||||
if (!sel) throw new Error("pickDateField: <ms-date-field> rendered no <ha-selector>");
|
||||
sel.dispatchEvent(new CustomEvent("value-changed", {
|
||||
bubbles: true, composed: true, detail: { value: haValue },
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CreateMockHassOptions {
|
||||
/** Override the default settings response (deep-merge not done — pass full shape). */
|
||||
settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE;
|
||||
|
||||
+27
-10
@@ -10,7 +10,7 @@
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/complete-dialog.js";
|
||||
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
import { createMockHass, pickDateField } from "./_test-utils.js";
|
||||
|
||||
type MountOpts = {
|
||||
checklist?: string[];
|
||||
@@ -104,12 +104,28 @@ describe("complete-dialog", () => {
|
||||
expect("completed_at" in msg).to.be.false;
|
||||
});
|
||||
|
||||
/** The optional backdate starts as a "Set date & time" button (#163): the
|
||||
* HA datetime selector has no empty state, so the field only appears once
|
||||
* the user asks for it, seeded with the current minute. */
|
||||
async function openBackdate(el: MaintenanceCompleteDialog) {
|
||||
const pick = el.shadowRoot!.querySelector<HTMLButtonElement>(".backdate-pick")!;
|
||||
expect(pick, "backdate seed button rendered").to.exist;
|
||||
expect(el.shadowRoot!.querySelector("ms-date-field"), "no field before the seed click").to.be.null;
|
||||
pick.click();
|
||||
await el.updateComplete;
|
||||
const field = el.shadowRoot!.querySelector("ms-date-field")!;
|
||||
expect(field, "backdate field rendered after the seed click").to.exist;
|
||||
await (field as unknown as { updateComplete: Promise<unknown> }).updateComplete;
|
||||
return field;
|
||||
}
|
||||
|
||||
it("sends completed_at with seconds re-added when a backdate is picked (#133)", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const dt = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="datetime-local"]')!;
|
||||
expect(dt, "backdate field rendered").to.exist;
|
||||
dt.value = "2026-01-10T14:30";
|
||||
dt.dispatchEvent(new Event("change"));
|
||||
const field = await openBackdate(el);
|
||||
// Seeded with the current minute, in the contract format.
|
||||
expect((field as unknown as { value: string }).value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00$/);
|
||||
// HA's datetime selector speaks "YYYY-MM-DD HH:MM:SS".
|
||||
pickDateField(field, "2026-01-10 14:30:00");
|
||||
await el.updateComplete;
|
||||
|
||||
clickComplete(el);
|
||||
@@ -124,10 +140,9 @@ describe("complete-dialog", () => {
|
||||
const { el, sent } = await mount();
|
||||
const future = new Date(Date.now() + 48 * 3600 * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())}T12:00`;
|
||||
const dt = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="datetime-local"]')!;
|
||||
dt.value = v;
|
||||
dt.dispatchEvent(new Event("change"));
|
||||
const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())} 12:00:00`;
|
||||
const field = await openBackdate(el);
|
||||
pickDateField(field, v);
|
||||
await el.updateComplete;
|
||||
|
||||
clickComplete(el);
|
||||
@@ -245,7 +260,9 @@ describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => {
|
||||
c.click();
|
||||
await el.updateComplete;
|
||||
const cost = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][1];
|
||||
expect(cost.value).to.equal("17.00");
|
||||
// Machine value for the <input type="number"> — never profile-formatted
|
||||
// ("17,00" would be rejected by the input under a decimal_comma profile).
|
||||
expect(cost.value).to.equal("17");
|
||||
expect(chip(el), "chip hides once cost is set").to.equal(null);
|
||||
});
|
||||
|
||||
|
||||
+59
-19
@@ -3,31 +3,32 @@
|
||||
* English UI always showed mm/dd/yyyy even with dd/mm/yyyy configured). */
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { formatDate, formatDateTime, setDateTimePrefs } from "../styles";
|
||||
import { formatDate, formatDateTime, formatDateShort, formatTimeOfDay, formatWeekday, formatMonth, weekdayName, monthName, setProfilePrefs } from "../styles";
|
||||
import { fmtDateTick, fmtDateTime } from "../renderers/chart-utils";
|
||||
|
||||
const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined }, null);
|
||||
const reset = () => setProfilePrefs({ date_format: undefined, time_format: undefined, number_format: undefined }, null);
|
||||
|
||||
describe("formatDate with HA profile date_format (#97)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("DMY renders dd/mm/yyyy regardless of language", () => {
|
||||
setDateTimePrefs({ date_format: "DMY" });
|
||||
setProfilePrefs({ date_format: "DMY" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10/08/2026");
|
||||
});
|
||||
|
||||
it("MDY renders mm/dd/yyyy regardless of language", () => {
|
||||
setDateTimePrefs({ date_format: "MDY" });
|
||||
setProfilePrefs({ date_format: "MDY" });
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("08/10/2026");
|
||||
});
|
||||
|
||||
it("YMD renders yyyy-mm-dd", () => {
|
||||
setDateTimePrefs({ date_format: "YMD" });
|
||||
setProfilePrefs({ date_format: "YMD" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
|
||||
});
|
||||
|
||||
it('"language" / unset keeps the language-derived format (backwards compatible)', () => {
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); // en → en-US
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); // de → de-DE
|
||||
reset();
|
||||
@@ -35,7 +36,7 @@ describe("formatDate with HA profile date_format (#97)", () => {
|
||||
});
|
||||
|
||||
it("pl/cs/sv derive their own locale, not en-US (live-check find)", () => {
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
// These three were missing from langToLocale and silently rendered
|
||||
// US-ordered dates. Day must come before month for all of them.
|
||||
expect(formatDate("2026-08-10", "pl")).to.equal("10.08.2026");
|
||||
@@ -45,7 +46,7 @@ describe("formatDate with HA profile date_format (#97)", () => {
|
||||
});
|
||||
|
||||
it("null/invalid input unchanged by prefs", () => {
|
||||
setDateTimePrefs({ date_format: "DMY" });
|
||||
setProfilePrefs({ date_format: "DMY" });
|
||||
expect(formatDate(null, "en")).to.equal("—");
|
||||
});
|
||||
});
|
||||
@@ -54,12 +55,12 @@ describe("formatDateTime with HA profile time_format (#97)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("24 forces 24h clock even for en", () => {
|
||||
setDateTimePrefs({ date_format: "DMY", time_format: "24" });
|
||||
setProfilePrefs({ date_format: "DMY", time_format: "24" });
|
||||
expect(formatDateTime("2026-08-10T14:30:00", "en")).to.equal("10/08/2026 14:30");
|
||||
});
|
||||
|
||||
it("12 forces a 12h clock even for de", () => {
|
||||
setDateTimePrefs({ date_format: "DMY", time_format: "12" });
|
||||
setProfilePrefs({ date_format: "DMY", time_format: "12" });
|
||||
const out = formatDateTime("2026-08-10T14:30:00", "de");
|
||||
expect(out.startsWith("10/08/2026 ")).to.be.true;
|
||||
expect(out).to.match(/2:30/);
|
||||
@@ -71,34 +72,73 @@ describe("server country regionalizes the language default (#140)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it('"en" + AU renders dd/mm/yyyy without any profile setting', () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "AU");
|
||||
setProfilePrefs({ date_format: "language" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
});
|
||||
|
||||
it('"en" + US keeps mm/dd/yyyy; "de" + AT stays dd.mm.yyyy', () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "US");
|
||||
setProfilePrefs({ date_format: "language" }, "US");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "language" }, "AT");
|
||||
setProfilePrefs({ date_format: "language" }, "AT");
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026");
|
||||
});
|
||||
|
||||
it("an explicit profile format always beats the country", () => {
|
||||
setDateTimePrefs({ date_format: "MDY" }, "AU");
|
||||
setProfilePrefs({ date_format: "MDY" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "YMD" }, "AU");
|
||||
setProfilePrefs({ date_format: "YMD" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
|
||||
});
|
||||
|
||||
it("garbage/unset country falls back to the language mapping", () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "!!");
|
||||
setProfilePrefs({ date_format: "language" }, "!!");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "language" }, null);
|
||||
setProfilePrefs({ date_format: "language" }, null);
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
});
|
||||
|
||||
it("country survives a later prefs update that omits it (undefined ≠ clear)", () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "AU");
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" }, "AU");
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("name-style helpers + chart labels route through styles.ts (#163 DRY)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("formatTimeOfDay follows the profile time format", () => {
|
||||
const d = new Date(2026, 7, 10, 14, 30);
|
||||
setProfilePrefs({ time_format: "24" });
|
||||
expect(formatTimeOfDay(d, "en")).to.equal("14:30");
|
||||
setProfilePrefs({ time_format: "12" });
|
||||
expect(formatTimeOfDay(d, "de")).to.match(/2:30/);
|
||||
expect(formatTimeOfDay(d, "de").toLowerCase()).to.match(/pm|nachm/);
|
||||
});
|
||||
|
||||
it("formatDateShort: day+month in the UI language, optional 2-digit year", () => {
|
||||
const d = new Date(2026, 6, 3);
|
||||
expect(formatDateShort(d, "en")).to.equal("Jul 3");
|
||||
expect(formatDateShort(d, "de")).to.equal("3. Juli");
|
||||
expect(formatDateShort(d, "en", true)).to.match(/^Jul 3, ?26$/);
|
||||
});
|
||||
|
||||
it("weekday / month names come from the UI language, indexed Mon=0 / Jan=0", () => {
|
||||
expect(weekdayName(0, "en", "short")).to.equal("Mon");
|
||||
expect(weekdayName(6, "de", "long")).to.equal("Sonntag");
|
||||
expect(monthName(0, "en", "short")).to.equal("Jan");
|
||||
expect(monthName(11, "de", "long")).to.equal("Dezember");
|
||||
const sun = new Date(2026, 6, 5); // 2026-07-05 is a Sunday
|
||||
expect(formatWeekday(sun, "en", "short")).to.equal("Sun");
|
||||
expect(formatMonth(sun, "de", "long")).to.equal("Juli");
|
||||
});
|
||||
|
||||
it("chart crosshair label honours the profile time format (was browser/language-only)", () => {
|
||||
const ts = new Date(2026, 6, 3, 14, 30).getTime();
|
||||
setProfilePrefs({ time_format: "24" });
|
||||
expect(fmtDateTime(ts, "en")).to.equal("Jul 3, 14:30");
|
||||
setProfilePrefs({ time_format: "12" });
|
||||
expect(fmtDateTime(ts, "de")).to.match(/^3\. Juli, .*2:30/);
|
||||
expect(fmtDateTick(ts, "en", false)).to.equal("Jul 3");
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -89,10 +89,10 @@ describe("settings-view vacation section", () => {
|
||||
vacationStart: "2099-06-10",
|
||||
vacationEnd: "2099-06-20",
|
||||
});
|
||||
const dateInputs = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=date]");
|
||||
expect(dateInputs.length, "two date inputs").to.equal(2);
|
||||
expect(dateInputs[0].value).to.equal("2099-06-10");
|
||||
expect(dateInputs[1].value).to.equal("2099-06-20");
|
||||
const dateFields = vacationSection(el)!.querySelectorAll<HTMLElement & { value: string }>(".vac-grid ms-date-field");
|
||||
expect(dateFields.length, "two date fields").to.equal(2);
|
||||
expect(dateFields[0].value).to.equal("2099-06-10");
|
||||
expect(dateFields[1].value).to.equal("2099-06-20");
|
||||
});
|
||||
|
||||
it("shows the active badge when vacation.is_active is true", async () => {
|
||||
|
||||
+11
@@ -60,6 +60,17 @@ describe("storage-section-card", () => {
|
||||
expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist;
|
||||
});
|
||||
|
||||
it("survives a malformed summary (no by_object) without throwing", async () => {
|
||||
// A generic WS stub answering `{}` used to reach Object.entries(undefined)
|
||||
// in render(); the card must hide itself instead of erroring.
|
||||
const bare = await mount({});
|
||||
expect(bare.shadowRoot!.querySelector("ha-card"), "hidden for {}").to.not.exist;
|
||||
const partial = await mount({ ...SUMMARY, by_object: undefined });
|
||||
await expand(partial);
|
||||
expect(partial.shadowRoot!.querySelector("ha-card"), "card renders").to.exist;
|
||||
expect(partial.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(0);
|
||||
});
|
||||
|
||||
it("navigates to an object when its row is clicked", async () => {
|
||||
const el = await mount();
|
||||
await expand(el);
|
||||
|
||||
+7
-1
@@ -150,11 +150,17 @@ describe("task-detail renderer", () => {
|
||||
promptSkip: () => { skipped++; },
|
||||
}));
|
||||
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
|
||||
(buttons[0] as HTMLElement).click(); // Complete (filled)
|
||||
(buttons[0] as HTMLElement).click(); // Complete (accent/success)
|
||||
(buttons[1] as HTMLElement).click(); // Skip
|
||||
expect(completed).to.not.be.null;
|
||||
expect(completed!.name).to.equal("Filter Wechsel");
|
||||
expect(skipped).to.equal(1);
|
||||
// Same pairing as the task rows and the quick-actions dialog (#150
|
||||
// follow-up): filled green Complete, outlined orange Skip.
|
||||
expect(buttons[0].getAttribute("appearance")).to.equal("accent");
|
||||
expect(buttons[0].getAttribute("variant")).to.equal("success");
|
||||
expect(buttons[1].getAttribute("appearance")).to.equal("outlined");
|
||||
expect(buttons[1].getAttribute("variant")).to.equal("warning");
|
||||
});
|
||||
|
||||
it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => {
|
||||
|
||||
+38
@@ -75,6 +75,44 @@ describe("task-dialog live trigger hint", () => {
|
||||
expect(hint(el)).to.equal(null);
|
||||
});
|
||||
|
||||
it("#156: below > above shows the overlap warning and blocks save (the limits are OR-ed)", async () => {
|
||||
const { hass, sent } = createMockHass({
|
||||
states: { "sensor.low_count": { state: "1", attributes: { unit_of_measurement: "batteries" } } },
|
||||
handlers: { "maintenance_supporter/task/create": () => ({ task_id: "new1" }) },
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
el.openCreate("e1", []);
|
||||
await el.updateComplete;
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
await el.updateComplete;
|
||||
(el as any)._name = "Replace batteries";
|
||||
(el as any)._triggerEntityId = "sensor.low_count";
|
||||
(el as any)._triggerEntityIds = ["sensor.low_count"];
|
||||
(el as any)._triggerType = "threshold";
|
||||
(el as any)._triggerAbove = "0";
|
||||
(el as any)._triggerBelow = "5";
|
||||
await el.updateComplete;
|
||||
const warn = el.shadowRoot!.querySelector(".trigger-live-hint.warn");
|
||||
expect(warn, "overlap warning rendered").to.not.equal(null);
|
||||
expect(warn!.textContent).to.include("can never recover");
|
||||
// The regular hint still lists both limits above the warning.
|
||||
expect(hint(el)).to.include("0 batteries");
|
||||
|
||||
await (el as any)._save();
|
||||
expect(sent.some((m) => m.type === "maintenance_supporter/task/create"), "save blocked").to.be.false;
|
||||
expect(el.shadowRoot!.querySelector(".error")?.textContent).to.include("can never recover");
|
||||
|
||||
// A real band (below < above) or equal limits carry no warning.
|
||||
(el as any)._triggerBelow = "0";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null);
|
||||
(el as any)._triggerAbove = "5";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null);
|
||||
});
|
||||
|
||||
it("editing a delta task uses the since-last-completion wording (baseline is not the current reading)", async () => {
|
||||
const { hass } = createMockHass({
|
||||
states: { "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } } },
|
||||
|
||||
+3
-3
@@ -37,18 +37,18 @@ describe("renderTriggerProgress — delta-mode counter (issue #102)", () => {
|
||||
|
||||
it("uses the exposed delta when present", async () => {
|
||||
const label = await labelOf(row({ trigger_current_delta: 100 }));
|
||||
expect(label).to.contain("100.0 / 15000");
|
||||
expect(label).to.contain("100.0 / 15,000");
|
||||
});
|
||||
|
||||
it("computes the delta from the baseline when only the baseline is exposed", async () => {
|
||||
const label = await labelOf(row({ trigger_baseline_value: 27000 }));
|
||||
expect(label).to.contain("0.0 / 15000");
|
||||
expect(label).to.contain("0.0 / 15,000");
|
||||
});
|
||||
|
||||
it("keeps the raw value for absolute-mode counters", async () => {
|
||||
const label = await labelOf(
|
||||
row({ trigger_config: { type: "counter", trigger_target_value: 30000 } as never }),
|
||||
);
|
||||
expect(label).to.contain("27000.0 / 30000");
|
||||
expect(label).to.contain("27,000.0 / 30,000");
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -12,7 +12,7 @@
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/vacation-section-card.js";
|
||||
import type { MaintenanceVacationSectionCard } from "../components/vacation-section-card";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
import { createMockHass, pickDateField } from "./_test-utils.js";
|
||||
|
||||
const BASE_STATE = {
|
||||
enabled: false,
|
||||
@@ -51,7 +51,7 @@ describe("vacation-section-card", () => {
|
||||
const root = el.shadowRoot!;
|
||||
expect(root.querySelector("ha-card"), "card rendered").to.exist;
|
||||
expect(root.querySelector("ha-switch"), "no enable switch").to.be.null;
|
||||
expect(root.querySelector('input[type="date"]'), "no date inputs").to.be.null;
|
||||
expect(root.querySelector("ms-date-field"), "no date fields").to.be.null;
|
||||
expect(root.querySelector(".actions"), "no action buttons").to.be.null;
|
||||
});
|
||||
|
||||
@@ -71,11 +71,11 @@ describe("vacation-section-card", () => {
|
||||
it("editing dates + Save dispatches start/end/buffer_days", async () => {
|
||||
const { el, sent } = await mount({ isAdmin: true });
|
||||
const root = el.shadowRoot!;
|
||||
const [start, end] = [...root.querySelectorAll<HTMLInputElement>('input[type="date"]')];
|
||||
start.value = "2026-09-01";
|
||||
start.dispatchEvent(new Event("input"));
|
||||
end.value = "2026-09-10";
|
||||
end.dispatchEvent(new Event("input"));
|
||||
const [start, end] = [...root.querySelectorAll("ms-date-field")];
|
||||
expect(end, "two date fields").to.exist;
|
||||
await Promise.all([start, end].map((f) => (f as unknown as { updateComplete: Promise<unknown> }).updateComplete));
|
||||
pickDateField(start, "2026-09-01");
|
||||
pickDateField(end, "2026-09-10");
|
||||
const buffer = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||
buffer.value = "3";
|
||||
buffer.dispatchEvent(new Event("input"));
|
||||
|
||||
Reference in New Issue
Block a user