189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -65,6 +65,7 @@ export function obj(entryId: string, tasks: unknown[], name = "Pool Pump") {
export async function mountPanel(
objects: unknown[],
extraHandlers: Record<string, WsHandler> = {},
opts: { user?: { id: string; is_admin: boolean } } = {},
) {
const { hass, sent, subscriptions } = createMockHass({
handlers: {
@@ -83,7 +84,7 @@ export async function mountPanel(
},
});
// The panel derives write access from hass.user (no user → read-only).
(hass as Record<string, unknown>).user = { id: "admin-1", is_admin: true };
(hass as Record<string, unknown>).user = opts.user ?? { id: "admin-1", is_admin: true };
(hass as Record<string, unknown>).areas = {};
const el = await fixture<HTMLElement & { updateComplete: Promise<unknown> }>(html`
@@ -4,7 +4,7 @@
* The completion dialog is the money path of the whole product; until now it
* was only covered by the lazy-load tripwire. These tests pin the exact
* outgoing WS payload (notes / cost / duration / feedback / checklist_state /
* photo_doc_id) and the error path (server refusal keeps the dialog open).
* photo_doc_ids) and the error path (server refusal keeps the dialog open).
*/
import { expect, fixture, html } from "@open-wc/testing";
@@ -100,6 +100,7 @@ describe("complete-dialog", () => {
expect("duration" in msg).to.be.false;
expect("feedback" in msg).to.be.false;
expect("checklist_state" in msg).to.be.false;
expect("photo_doc_ids" in msg).to.be.false;
expect("photo_doc_id" in msg).to.be.false;
expect("completed_at" in msg).to.be.false;
});
@@ -153,43 +154,149 @@ describe("complete-dialog", () => {
expect(el.shadowRoot!.textContent).to.include("future");
});
it("attaches an uploaded photo as photo_doc_id", async () => {
const { el, sent } = await mount();
// Stub the document-upload endpoint the photo picker posts to.
/** #161: stub the multipart upload route; every call mints the next id. */
function stubUpload(ids: string[]) {
const realFetch = window.fetch;
const uploads: RequestInit[] = [];
let n = 0;
window.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
uploads.push(init!);
return {
ok: true,
status: 200,
json: async () => ({ id: "doc-photo-1", deduped: false }),
} as Response;
const id = ids[n++] ?? `doc-photo-${n}`;
return { ok: true, status: 200, json: async () => ({ id, deduped: false }) } as Response;
}) as typeof window.fetch;
return { uploads, restore: () => { window.fetch = realFetch; } };
}
function pickFiles(el: MaintenanceCompleteDialog, selector: string, names: string[]) {
const fileInput = el.shadowRoot!.querySelector<HTMLInputElement>(selector)!;
const dt = new DataTransfer();
for (const name of names) dt.items.add(new File(["fake-png"], name, { type: "image/png" }));
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event("change"));
}
const CAMERA = '.photo-pick-camera input[type="file"]';
const GALLERY = '.photo-pick-gallery input[type="file"]';
it("falls back to one photo per pick inside the Android Companion app (#161 follow-up)", async () => {
// The Android app's file chooser drops a multi-select on the floor
// (parseResult ignores ClipData) — the gallery picker is single there.
(window as unknown as { externalApp?: unknown }).externalApp = {};
try {
const fileInput = el.shadowRoot!.querySelector<HTMLInputElement>(
'.photo-pick input[type="file"]',
)!;
const dt = new DataTransfer();
dt.items.add(new File(["fake-png"], "done.png", { type: "image/png" }));
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event("change"));
const { el } = await mount();
const gallery = el.shadowRoot!.querySelector<HTMLInputElement>(GALLERY)!;
expect(gallery.multiple, "no multi-select affordance on Android").to.equal(false);
expect(el.shadowRoot!.querySelector(".photo-pick-gallery span")!.textContent!.trim()).to.equal("Choose photo");
expect(el.shadowRoot!.querySelector(".photo-android-hint")!.textContent).to.contain("one photo per pick");
const { uploads, restore } = stubUpload(["p1", "p2"]);
try {
pickFiles(el, GALLERY, ["a.png"]);
await new Promise((r) => setTimeout(r, 10));
pickFiles(el, GALLERY, ["b.png"]);
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
expect(uploads.length, "each single pick is added").to.equal(2);
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(2);
} finally {
restore();
}
} finally {
delete (window as unknown as { externalApp?: unknown }).externalApp;
}
const { el: plain } = await mount();
expect(plain.shadowRoot!.querySelector<HTMLInputElement>(GALLERY)!.multiple, "browsers keep multi-select").to.equal(true);
expect(plain.shadowRoot!.querySelector(".photo-android-hint")).to.equal(null);
});
it("attaches the uploaded photos as photo_doc_ids (#161)", async () => {
const { el, sent } = await mount();
const { uploads, restore } = stubUpload(["doc-photo-1", "doc-photo-2", "doc-photo-3"]);
try {
// The camera picker takes one shot; the gallery picker several at once.
pickFiles(el, CAMERA, ["done.png"]);
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
pickFiles(el, GALLERY, ["a.png", "b.png"]);
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
// Preview replaces the picker once the upload returns an id.
expect(el.shadowRoot!.querySelector(".photo-preview img")).to.exist;
expect(uploads.length).to.equal(1);
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(3);
expect(uploads.length).to.equal(3);
const form = uploads[0].body as FormData;
expect(form.get("entry_id")).to.equal("entry1");
expect(form.get("tags")).to.equal("photo");
// The pickers stay available while there is room under the cap.
expect(el.shadowRoot!.querySelector(CAMERA)).to.exist;
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg.photo_doc_id).to.equal("doc-photo-1");
expect(msg.photo_doc_ids).to.deep.equal(["doc-photo-1", "doc-photo-2", "doc-photo-3"]);
expect("photo_doc_id" in msg).to.be.false;
// Completing keeps the uploads — no delete goes out.
expect(sent.some((m) => m.type === "maintenance_supporter/documents/delete")).to.be.false;
} finally {
restore();
}
});
it("caps a completion at 10 photos and says so (#161)", async () => {
const { el } = await mount();
const { uploads, restore } = stubUpload([]);
try {
pickFiles(el, GALLERY, Array.from({ length: 12 }, (_, i) => `p${i}.png`));
await new Promise((r) => setTimeout(r, 60));
await el.updateComplete;
expect(uploads.length).to.equal(10);
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(10);
// At the cap the pickers give way to the limit note.
expect(el.shadowRoot!.querySelector(CAMERA)).to.equal(null);
expect(el.shadowRoot!.querySelector(".photo-limit")!.textContent).to.include("10");
expect(el.shadowRoot!.querySelector(".error")!.textContent).to.include("10");
} finally {
restore();
}
});
it("removing a tile deletes that upload; cancelling drops the rest (#161)", async () => {
const { el, sent } = await mount();
const { restore } = stubUpload(["doc-photo-1", "doc-photo-2"]);
try {
pickFiles(el, GALLERY, ["a.png", "b.png"]);
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
const removeButtons = el.shadowRoot!.querySelectorAll<HTMLElement>(".photo-remove");
expect(removeButtons.length).to.equal(2);
removeButtons[0].click();
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(1);
let deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete");
expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1"]);
// Cancel: the remaining upload is an orphan nobody references.
const cancel = el.shadowRoot!.querySelector<HTMLElement>(".dialog-actions ha-button")!;
cancel.click();
await el.updateComplete;
deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete");
expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1", "doc-photo-2"]);
expect(sent.some((m) => m.type === "maintenance_supporter/task/complete")).to.be.false;
} finally {
restore();
}
});
it("surfaces a 413 as the too-large message and keeps the picker (#161)", async () => {
const { el } = await mount();
const realFetch = window.fetch;
window.fetch = (async () => ({ ok: false, status: 413, json: async () => ({}) }) as Response) as typeof window.fetch;
try {
pickFiles(el, CAMERA, ["huge.png"]);
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(0);
expect(el.shadowRoot!.querySelector(".error")!.textContent).to.include("large");
expect(el.shadowRoot!.querySelector(CAMERA)).to.exist;
} finally {
window.fetch = realFetch;
}
@@ -213,4 +213,30 @@ describe("documents-section", () => {
label.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(clicked, "Enter triggers the hidden file input").to.be.true;
});
it("offers a local filter from 8 documents up and filters tolerantly (#171)", async () => {
const many = Array.from({ length: 9 }, (_, i) => ({
...FILE_DOC, id: `f${i}`, title: i === 4 ? "Garantie Spülmaschine" : `Rechnung ${i}`, filename: `r${i}.pdf`, tags: i === 4 ? ["warranty"] : ["invoice"],
}));
const { el } = await mount(true, many);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".doc-filter input");
expect(input, "filter box shown").to.exist;
expect(el.shadowRoot!.querySelectorAll(".doc-row").length).to.equal(9);
input!.value = "spuel garant";
input!.dispatchEvent(new Event("input"));
await el.updateComplete;
const rows = [...el.shadowRoot!.querySelectorAll(".doc-row")];
expect(rows.length).to.equal(1);
expect(rows[0].textContent).to.contain("Garantie Spülmaschine");
input!.value = "nichts";
input!.dispatchEvent(new Event("input"));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".doc-empty")!.textContent).to.contain("No matching documents");
expect(el.shadowRoot!.querySelector("h3")!.textContent, "count stays the full count").to.contain("(9)");
});
it("keeps the list uncluttered below 8 documents — no filter box", async () => {
const { el } = await mount();
expect(el.shadowRoot!.querySelector(".doc-filter")).to.be.null;
});
});
@@ -1,4 +1,5 @@
import { expect } from "@open-wc/testing";
import { setViewport } from "@web/test-runner-commands";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
describe("panel shell", () => {
beforeEach(() => {
@@ -156,6 +157,63 @@ describe("panel shell", () => {
expect(textOf(overdueSection)).to.include("Late");
});
it("Today rows name the responsible person, except under the person filter (#169)", async () => {
localStorage.setItem("msp-overview-tab", "today");
const { el } = await mountPanel(
[
obj("e1", [
task({ name: "Assigned", status: "overdue", days_until_due: -1, responsible_user_id: "u-anna" }),
task({ name: "Nobody", status: "overdue", days_until_due: -2 }),
]),
],
{ "maintenance_supporter/users/list": () => ({ users: [{ id: "u-anna", name: "Anna" }] }) },
);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const rowOf = (name: string) =>
[...sr(el).querySelectorAll(".today-row")].find((r) => r.querySelector(".today-task")?.textContent?.trim() === name)!;
expect(rowOf("Assigned").querySelector(".today-person")!.textContent).to.include("Anna");
expect(rowOf("Nobody").querySelector(".today-person")).to.equal(null);
// Filtering by that person makes the chip redundant — every row is hers.
(el as unknown as { _filterUser: string | null })._filterUser = "u-anna";
await el.updateComplete;
expect(sr(el).querySelector(".today-person")).to.equal(null);
});
it("Today person chip stays readable on a phone next to a long object name (#169)", async () => {
localStorage.setItem("msp-overview-tab", "today");
await setViewport({ width: 360, height: 800 });
try {
const { el } = await mountPanel(
[
obj("e1", [task({ name: "Descale and clean the filter thoroughly", status: "overdue", days_until_due: -248, responsible_user_id: "u-max" })],
"Dishwasher in the basement utility room 12345"),
],
{ "maintenance_supporter/users/list": () => ({ users: [{ id: "u-max", name: "Maximiliane Schneider-Hoffmann" }] }) },
);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const line = sr(el).querySelector<HTMLElement>(".today-object")!;
const chip = line.querySelector<HTMLElement>(".today-person")!;
const lineBox = line.getBoundingClientRect();
const chipBox = chip.getBoundingClientRect();
// Narrow: the avatar alone (initials in the member's colour), fully inside the line.
const avatar = chip.querySelector<HTMLElement>(".person-avatar")!;
expect(avatar.textContent!.trim()).to.equal("MS");
expect(getComputedStyle(chip.querySelector(".person-name")!).display, "name hidden on narrow").to.equal("none");
const visible = Math.min(chipBox.right, lineBox.right) - Math.max(chipBox.left, lineBox.left);
expect(visible, "avatar visible inside the line").to.be.greaterThan(16);
expect(chipBox.right, "chip does not overflow the line").to.be.at.most(lineBox.right + 1);
// The object text gave way (ellipsis), not the person.
const text = line.querySelector<HTMLElement>(".today-object-text")!;
expect(text.scrollWidth).to.be.greaterThan(text.clientWidth);
expect(chip.getAttribute("title")).to.equal("Maximiliane Schneider-Hoffmann");
} finally {
await setViewport({ width: 1280, height: 800 });
}
});
it("virtualizes the table above the threshold and moves the window on scroll", async () => {
const many = Array.from({ length: 150 }, (_, i) =>
task({ name: `Bulk ${String(i).padStart(3, "0")}`, days_until_due: (i % 40) + 1 }),
@@ -21,6 +21,7 @@ function mockHass(opts: {
vacationStart?: string | null;
vacationEnd?: string | null;
exemptIds?: string[];
previewRows?: Array<Record<string, unknown>>;
} = {}) {
const settingsResponse = {
...DEFAULT_SETTINGS_RESPONSE,
@@ -51,7 +52,7 @@ function mockHass(opts: {
"maintenance_supporter/vacation/end_now": () => ({
...settingsResponse.vacation, enabled: false, is_active: false,
}),
"maintenance_supporter/vacation/preview": () => ({ rows: [], window_end: null }),
"maintenance_supporter/vacation/preview": () => ({ rows: opts.previewRows ?? [], window_end: null }),
},
});
}
@@ -144,4 +145,42 @@ describe("settings-view vacation section", () => {
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
expect(endNow, "no end-now in default state").to.not.exist;
});
it("the preview hides Skip for a task whose skip rule is off (#150 rule in the preview)", async () => {
// Bug review 2026-09-04: every time-based row offered Skip — the
// backend then rejected it for allow_skip=false tasks.
const row = (task_id: string, extra: Record<string, unknown>) => ({
task_id,
entry_id: "e1",
object_name: "Pool",
task_name: task_id,
kind: "time_based",
confidence: "deterministic",
events: [{ date: "2099-06-12", status: "due_soon" }],
will_suppress: true,
...extra,
});
const { el } = await mount({
vacationStart: "2099-06-10",
vacationEnd: "2099-06-20",
previewRows: [
row("Open", { allow_skip: true }),
row("Locked", { allow_skip: false }),
row("Legacy", {}),
row("Sensor", { kind: "sensor_based", allow_skip: true }),
],
});
const section = vacationSection(el)!;
section.querySelector<HTMLButtonElement>(".vac-preview-toolbar button")!.click();
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const rows = Array.from(section.querySelectorAll(".vac-preview-row"));
expect(rows.length).to.equal(4);
const skipLabel = rows[0].querySelectorAll(".vac-preview-actions button")[1].textContent!.trim();
const hasSkip = (r: Element) =>
Array.from(r.querySelectorAll(".vac-preview-actions button")).some((b) => b.textContent!.trim() === skipLabel);
expect(hasSkip(rows[0]), "allow_skip=true keeps Skip").to.be.true;
expect(hasSkip(rows[1]), "allow_skip=false hides Skip").to.be.false;
expect(hasSkip(rows[2]), "absent field = allowed (older backend)").to.be.true;
expect(hasSkip(rows[3]), "sensor rows never had Skip").to.be.false;
});
});
@@ -125,4 +125,15 @@ describe("storage-section-card", () => {
expect(el.shadowRoot!.querySelector(".obj-name")!.textContent!.trim()).to.equal("01234567");
expect(el.shadowRoot!.querySelector(".obj-row.clickable"), "unknown object not clickable").to.not.exist;
});
it("shows how much of the library the full-text search can see (#171)", async () => {
const el = await mount({ ...SUMMARY, search_index: { total: 12, indexed: 9, no_text: 2, unsupported: 1, pending: 0 } });
await expand(el);
const status = el.shadowRoot!.querySelector(".index-status")!;
expect(status.textContent!.replace(/\s+/g, " ")).to.contain("9 of 12 files indexed");
expect(status.textContent).to.contain("3 without text layer");
const none = await mount({ ...SUMMARY, search_index: { total: 0, indexed: 0, no_text: 0, unsupported: 0, pending: 0 } });
await expand(none);
expect(none.shadowRoot!.querySelector(".index-status"), "no line without files").to.be.null;
});
});
@@ -13,8 +13,8 @@ import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { createMockHass } from "./_test-utils.js";
async function mountCreate(states: Record<string, unknown>): Promise<MaintenanceTaskDialog> {
const { hass } = createMockHass({ states });
async function mountCreate(states: Record<string, unknown>, language = "en"): Promise<MaintenanceTaskDialog> {
const { hass } = createMockHass({ states, language });
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
@@ -59,6 +59,37 @@ describe("task-dialog live trigger hint", () => {
expect(text).to.include("1.5 bar");
});
it("formats the figures in the user's number format (bug review 2026-09-04)", async () => {
// Decimal comma + thousands separators for a de user — like the entity
// card next to the dialog; the hint used raw String(n).
const el = await mountCreate(
{
"sensor.odometer": { state: "100000", attributes: { unit_of_measurement: "km" } },
"sensor.pressure": { state: "1.25", attributes: { unit_of_measurement: "bar" } },
},
"de",
);
(el as any)._triggerEntityId = "sensor.odometer";
(el as any)._triggerEntityIds = ["sensor.odometer"];
(el as any)._triggerType = "counter";
(el as any)._triggerDeltaMode = true;
(el as any)._triggerTargetValue = "15000";
await el.updateComplete;
let text = hint(el)!;
expect(text).to.include("100.000 km");
expect(text).to.include("115.000 km");
expect(text).to.include("15.000 km");
(el as any)._triggerEntityId = "sensor.pressure";
(el as any)._triggerEntityIds = ["sensor.pressure"];
(el as any)._triggerType = "threshold";
(el as any)._triggerAbove = "1.5";
await el.updateComplete;
text = hint(el)!;
expect(text).to.include("1,3 bar"); // 1.25 rounded to one decimal, decimal comma
expect(text).to.include("1,5 bar");
});
it("renders nothing without a bound entity or without targets", async () => {
const el = await mountCreate({
"sensor.pressure": { state: "1.2", attributes: {} },
@@ -137,3 +168,28 @@ describe("task-dialog live trigger hint", () => {
expect(text).to.include("100 h");
});
});
describe("task-dialog state-change latch hint (#167)", () => {
const latchHint = (el: MaintenanceTaskDialog): boolean =>
[...el.shadowRoot!.querySelectorAll(".field-help")].some((n) => /latch/i.test(n.textContent || ""));
it("explains the latch (incl. the from-only recovery) for a single transition with a pattern", async () => {
const el = await mountCreate({ "sensor.dock_error": { state: "ok", attributes: {} } });
(el as any)._triggerEntityId = "sensor.dock_error";
(el as any)._triggerEntityIds = ["sensor.dock_error"];
(el as any)._triggerType = "state_change";
(el as any)._triggerFromState = "ok";
(el as any)._triggerTargetChanges = "1";
await el.updateComplete;
expect(latchHint(el), "from-only, target 1").to.equal(true);
(el as any)._triggerTargetChanges = "3";
await el.updateComplete;
expect(latchHint(el), "a counter is no latch").to.equal(false);
(el as any)._triggerTargetChanges = "";
(el as any)._triggerFromState = "";
await el.updateComplete;
expect(latchHint(el), "no pattern = no latch semantics").to.equal(false);
});
});
@@ -183,4 +183,17 @@ describe("task-documents in part mode", () => {
const { el } = await mountPart([P_LINKED]);
expect(el.shadowRoot!.querySelector(".tdoc-page")).to.not.exist;
});
it("offers a local filter once 8+ documents are linked (#171)", async () => {
const many = Array.from({ length: 8 }, (_, i) => ({ ...LINKED, id: `l${i}`, title: i === 2 ? "Schaltplan" : `Manual ${i}`, filename: `m${i}.pdf` }));
const { el } = await mount(true, many);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".doc-filter input");
expect(input, "filter box shown").to.exist;
input!.value = "schaltpl";
input!.dispatchEvent(new Event("input"));
await el.updateComplete;
const rows = [...el.shadowRoot!.querySelectorAll(".tdoc-row")];
expect(rows.length).to.equal(1);
expect(rows[0].textContent).to.contain("Schaltplan");
});
});