187 files

This commit is contained in:
Home Assistant Version Control
2026-09-03 17:54:35 +00:00
parent 16c9b4d7f4
commit d6413e19b5
187 changed files with 15542 additions and 2765 deletions
@@ -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;
@@ -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);
});
@@ -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");
});
});
@@ -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 () => {
@@ -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);
@@ -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", () => {
@@ -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" } } },
@@ -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");
});
});
@@ -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"));