updated apps
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* <maintenance-adopt-problem-sensors-dialog>: the suggested-spare-part link.
|
||||
*
|
||||
* Pins: a candidate carrying suggested_part_id/name renders the part chip, and
|
||||
* the adopt payload forwards part_id for exactly those candidates.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/adopt-problem-sensors-dialog.js";
|
||||
import type { MaintenanceAdoptProblemSensorsDialog } from "../components/adopt-problem-sensors-dialog";
|
||||
import { type SentMessage, createMockHass } from "./_test-utils.js";
|
||||
|
||||
const SENSORS = [
|
||||
{
|
||||
entity_id: "binary_sensor.toner_low", name: "Toner low", state: "on",
|
||||
device_id: "d1", device_name: "Printer", area_name: "Office",
|
||||
suggested_entry_id: "e1", suggested_object_name: "Printer",
|
||||
suggested_part_id: "part_toner", suggested_part_name: "Toner cartridge",
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.pump_problem", name: "Pump problem", state: "off",
|
||||
device_id: "d2", device_name: "Pump", area_name: null,
|
||||
suggested_entry_id: null, suggested_object_name: "Pump",
|
||||
suggested_part_id: null, suggested_part_name: null,
|
||||
},
|
||||
];
|
||||
|
||||
async function mountOpen(): Promise<{ el: MaintenanceAdoptProblemSensorsDialog; sent: SentMessage[] }> {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/problem_sensors/discover": () => ({ sensors: SENSORS }),
|
||||
"maintenance_supporter/problem_sensors/adopt": () => ({ tasks_created: 2, objects_created: 1, total: 2 }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceAdoptProblemSensorsDialog>(html`
|
||||
<maintenance-adopt-problem-sensors-dialog .hass=${hass}></maintenance-adopt-problem-sensors-dialog>
|
||||
`);
|
||||
await el.open();
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("adopt-problem-sensors dialog: suggested part", () => {
|
||||
it("renders the part chip only for candidates with a suggested part", async () => {
|
||||
const { el } = await mountOpen();
|
||||
const rows = el.shadowRoot!.querySelectorAll(".row");
|
||||
expect(rows.length).to.equal(2);
|
||||
expect(rows[0].querySelector(".row-part")?.textContent).to.include("Toner cartridge");
|
||||
expect(rows[1].querySelector(".row-part")).to.equal(null);
|
||||
});
|
||||
|
||||
it("forwards part_id in the adopt payload for the matched candidate only", async () => {
|
||||
const { el, sent } = await mountOpen();
|
||||
el.shadowRoot!.querySelectorAll<HTMLElement>("ha-button")[1].click(); // Adopt selected
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const adopt = sent.find((m) => m.type === "maintenance_supporter/problem_sensors/adopt")! as {
|
||||
selections: Array<{ entity_id: string; part_id?: string }>;
|
||||
};
|
||||
const byId = Object.fromEntries(adopt.selections.map((s) => [s.entity_id, s]));
|
||||
expect(byId["binary_sensor.toner_low"].part_id).to.equal("part_toner");
|
||||
expect(byId["binary_sensor.pump_problem"].part_id).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Component test for the Lovelace card's saved-view scope (v2.26 — the last
|
||||
* open piece of roadmap item 2).
|
||||
*
|
||||
* Pins:
|
||||
* - `view_id` in the card config applies the view's status/user/label
|
||||
* filters ON TOP of the card's own filters (AND semantics, unlike the
|
||||
* panel where applying a view replaces the filter state)
|
||||
* - the `current_user` sentinel resolves against hass.user client-side
|
||||
* - a deleted/unknown view id degrades to "no view filter", never an
|
||||
* inexplicably empty card (same fallback as backend notification routing)
|
||||
*/
|
||||
|
||||
import { expect, fixture, html, waitUntil } from "@open-wc/testing";
|
||||
import "../maintenance-card.js";
|
||||
import type { MaintenanceSupporterCard } from "../maintenance-card";
|
||||
|
||||
const T = (
|
||||
id: string, name: string, status: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) => ({ id, name, status, days_until_due: 1, type: "service", ...extra });
|
||||
const O = (entry_id: string, name: string, tasks: unknown[]) => ({
|
||||
entry_id, object: { id: entry_id, name }, tasks,
|
||||
});
|
||||
|
||||
function mockObjects() {
|
||||
return [
|
||||
O("e1", "Garden shed", [
|
||||
T("t1", "Sharpen mower blades", "overdue", { labels: ["garden"], responsible_user_id: "u1" }),
|
||||
T("t2", "Oil hedge trimmer", "due_soon", { labels: ["garden"], responsible_user_id: "u2" }),
|
||||
]),
|
||||
O("e2", "Kitchen", [
|
||||
T("t3", "Descale kettle", "overdue", { labels: ["kitchen"], responsible_user_id: "u1" }),
|
||||
T("t4", "Clean extractor", "ok", { labels: [], responsible_user_id: null }),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
const VIEWS = [
|
||||
{ id: "vgarden", name: "Garden", filters: { status: "", user_id: null, label: "garden", archived: false, sort_mode: "due_date", group_by: "none" } },
|
||||
{ id: "vmine", name: "Mine", filters: { status: "", user_id: "current_user", label: null, archived: false, sort_mode: "due_date", group_by: "none" } },
|
||||
{ id: "voverdue", name: "Overdue only", filters: { status: "overdue", user_id: null, label: null, archived: false, sort_mode: "due_date", group_by: "none" } },
|
||||
];
|
||||
|
||||
function mockHass() {
|
||||
return {
|
||||
language: "en",
|
||||
user: { id: "u1", name: "Tester", is_admin: true, is_owner: true },
|
||||
connection: {
|
||||
sendMessagePromise: async (msg: { type: string }) => {
|
||||
if (msg.type === "maintenance_supporter/objects") return { objects: mockObjects() };
|
||||
if (msg.type === "maintenance_supporter/views/list") return { views: VIEWS };
|
||||
return { overdue: 0, due_soon: 0, triggered: 0, ok: 0, total: 0 };
|
||||
},
|
||||
subscribeMessage: async () => () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(config: Record<string, unknown> = {}): Promise<MaintenanceSupporterCard> {
|
||||
const el = await fixture<MaintenanceSupporterCard>(
|
||||
html`<maintenance-supporter-card .hass=${mockHass() as never}></maintenance-supporter-card>`
|
||||
);
|
||||
el.setConfig({ type: "custom:maintenance-supporter-card", show_actions: false, ...config } as never);
|
||||
await waitUntil(
|
||||
() => el.shadowRoot!.querySelectorAll(".task-name, .empty-card").length > 0,
|
||||
"card renders",
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
const names = (el: MaintenanceSupporterCard) =>
|
||||
[...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || "");
|
||||
|
||||
describe("maintenance-card saved-view scope", () => {
|
||||
it("applies the view's label filter", async () => {
|
||||
const el = await mount({ view_id: "vgarden" });
|
||||
await waitUntil(() => names(el).length === 2, "view filter applied", { timeout: 2000 });
|
||||
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Oil hedge trimmer"]);
|
||||
});
|
||||
|
||||
it("resolves the current_user sentinel against hass.user", async () => {
|
||||
const el = await mount({ view_id: "vmine" });
|
||||
await waitUntil(() => names(el).length === 2, "user filter applied", { timeout: 2000 });
|
||||
// u1 owns t1 + t3; t2 is u2's, t4 unassigned.
|
||||
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]);
|
||||
});
|
||||
|
||||
it("ANDs the view with the card's own filters", async () => {
|
||||
const el = await mount({ view_id: "vgarden", filter_status: ["overdue"] });
|
||||
await waitUntil(() => names(el).length === 1, "combined filter applied", { timeout: 2000 });
|
||||
expect(names(el)).to.deep.equal(["Sharpen mower blades"]);
|
||||
});
|
||||
|
||||
it("applies the view's own status filter", async () => {
|
||||
const el = await mount({ view_id: "voverdue" });
|
||||
await waitUntil(() => names(el).length === 2, "status filter applied", { timeout: 2000 });
|
||||
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]);
|
||||
});
|
||||
|
||||
it("a deleted view id degrades to no view filter, not an empty card", async () => {
|
||||
const el = await mount({ view_id: "deleted_view" });
|
||||
await waitUntil(() => names(el).length === 4, "fallback shows all", { timeout: 2000 });
|
||||
expect(names(el)).to.have.length(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Spare-parts section: rows render the stock badge / identifiers / storage
|
||||
* location, the low state is flagged, and the add form only appears for
|
||||
* writers.
|
||||
*/
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/parts-section";
|
||||
import type { MaintenancePartsSection } from "../components/parts-section";
|
||||
import type { MaintenancePart } from "../types";
|
||||
|
||||
const PARTS: MaintenancePart[] = [
|
||||
{
|
||||
id: "p1",
|
||||
name: "HEPA-Filter",
|
||||
mpn: "00754869",
|
||||
vendor: "Bosch",
|
||||
storage_location: "Keller Regal B",
|
||||
stock: 1,
|
||||
reorder_threshold: 1,
|
||||
is_low: true,
|
||||
unit: "pcs",
|
||||
shopping_url: "https://example.com/buy",
|
||||
},
|
||||
{ id: "p2", name: "Brush", stock: null, is_low: false },
|
||||
];
|
||||
|
||||
async function mount(canWrite: boolean): Promise<MaintenancePartsSection> {
|
||||
const el = await fixture<MaintenancePartsSection>(html`
|
||||
<maintenance-parts-section
|
||||
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
|
||||
.entryId=${"e1"}
|
||||
.parts=${PARTS}
|
||||
.canWrite=${canWrite}
|
||||
></maintenance-parts-section>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("parts-section", () => {
|
||||
it("renders a row per part with stock badge, identifiers and location", async () => {
|
||||
const el = await mount(false);
|
||||
const rows = el.shadowRoot!.querySelectorAll(".part-row");
|
||||
expect(rows.length).to.equal(2);
|
||||
const first = rows[0] as HTMLElement;
|
||||
expect(first.classList.contains("low")).to.be.true;
|
||||
expect(first.querySelector(".stock-badge")!.textContent).to.include("1");
|
||||
expect(first.querySelector(".part-meta")!.textContent).to.include("MPN: 00754869");
|
||||
expect(first.querySelector(".part-meta")!.textContent).to.include("Keller Regal B");
|
||||
// Catalog-only part (untracked stock) shows no badge.
|
||||
expect((rows[1] as HTMLElement).querySelector(".stock-badge")).to.equal(null);
|
||||
// Shopping link resolves on the name.
|
||||
const link = first.querySelector(".part-name a") as HTMLAnchorElement;
|
||||
expect(link.href).to.equal("https://example.com/buy");
|
||||
});
|
||||
|
||||
it("gates editing on canWrite", async () => {
|
||||
const reader = await mount(false);
|
||||
// Read-only users keep the (read-action) documents paperclip — one per
|
||||
// part — but no edit/delete/restock buttons.
|
||||
const readerBtns = [...reader.shadowRoot!.querySelectorAll("ha-icon-button")];
|
||||
expect(readerBtns.length).to.equal(reader.shadowRoot!.querySelectorAll(".part-row").length);
|
||||
expect(readerBtns.every((b) => b.querySelector('ha-icon[icon="mdi:paperclip"]'))).to.be.true;
|
||||
const writer = await mount(true);
|
||||
expect(writer.shadowRoot!.querySelectorAll("ha-icon-button").length).to.be.greaterThan(0);
|
||||
// Add button opens the inline form with native inputs (dialog-input trap).
|
||||
(writer.shadowRoot!.querySelector(".section-head ha-button") as HTMLElement).click();
|
||||
await writer.updateComplete;
|
||||
expect(writer.shadowRoot!.querySelector(".part-form")).to.not.equal(null);
|
||||
expect(writer.shadowRoot!.querySelectorAll(".part-form input").length).to.be.greaterThan(5);
|
||||
});
|
||||
|
||||
it("does not render a non-http(s) shopping_url as a link (XSS guard)", async () => {
|
||||
const el = await fixture<MaintenancePartsSection>(html`
|
||||
<maintenance-parts-section
|
||||
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
|
||||
.entryId=${"e1"}
|
||||
.parts=${[
|
||||
{ id: "p1", name: "Evil", stock: null, is_low: false, shopping_url: "javascript:alert(1)" },
|
||||
{ id: "p2", name: "Good", stock: null, is_low: false, shopping_url: "https://ok.example/x" },
|
||||
] as MaintenancePart[]}
|
||||
.canWrite=${false}
|
||||
></maintenance-parts-section>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
const rows = el.shadowRoot!.querySelectorAll(".part-row");
|
||||
// Row 0 (javascript:) → plain text, NO anchor. Row 1 (https) → anchor.
|
||||
expect(rows[0].querySelector(".part-name a"), "javascript: url must not become a link").to.equal(null);
|
||||
expect(rows[0].querySelector(".part-name")!.textContent!.trim()).to.equal("Evil");
|
||||
expect(rows[1].querySelector(".part-name a"), "https url stays a link").to.not.equal(null);
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component tests for the saved-views dialog (v2.24).
|
||||
*
|
||||
* Pins:
|
||||
* - Save sends the CURRENT filters under the typed name and emits the
|
||||
* server's returned list via `saved-views-changed`.
|
||||
* - Delete sends the view id and emits the trimmed list.
|
||||
* - The name input is a native <input> (the <ha-textfield> panel-context trap).
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/saved-views-dialog.js";
|
||||
import type { MaintenanceSavedViewsDialog } from "../components/saved-views-dialog";
|
||||
import type { SavedView, SavedViewFilters } from "../types";
|
||||
|
||||
function makeHass(sent: unknown[]) {
|
||||
return {
|
||||
language: "en",
|
||||
connection: {
|
||||
sendMessagePromise: async (msg: Record<string, unknown>) => {
|
||||
sent.push(msg);
|
||||
if (msg.type === "maintenance_supporter/views/save") {
|
||||
return { views: [{ id: "new1", name: msg.name, filters: msg.filters }] };
|
||||
}
|
||||
return { views: [] }; // delete -> empty
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FILTERS: SavedViewFilters = {
|
||||
status: "overdue",
|
||||
user_id: "current_user",
|
||||
archived: true,
|
||||
sort_mode: "area",
|
||||
group_by: "user",
|
||||
};
|
||||
|
||||
const EXISTING: SavedView[] = [
|
||||
{ id: "v1", name: "Kitchen overdue", filters: { ...FILTERS } },
|
||||
];
|
||||
|
||||
async function mount(sent: unknown[]) {
|
||||
const el = await fixture<MaintenanceSavedViewsDialog>(html`
|
||||
<maintenance-saved-views-dialog .hass=${makeHass(sent)}></maintenance-saved-views-dialog>
|
||||
`);
|
||||
await el.open(FILTERS, EXISTING);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("saved-views dialog", () => {
|
||||
it("lists existing views and uses a native input for the name", async () => {
|
||||
const el = await mount([]);
|
||||
const names = [...el.shadowRoot!.querySelectorAll(".row-name")].map((n) => n.textContent?.trim());
|
||||
expect(names).to.deep.equal(["Kitchen overdue"]);
|
||||
const input = el.shadowRoot!.querySelector(".name-input");
|
||||
expect(input?.tagName).to.equal("INPUT");
|
||||
});
|
||||
|
||||
it("saves the current filters under the typed name and emits the new list", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const el = await mount(sent);
|
||||
let emitted: SavedView[] | null = null;
|
||||
el.addEventListener("saved-views-changed", (e) => {
|
||||
emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views;
|
||||
});
|
||||
|
||||
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".name-input")!;
|
||||
input.value = "My view";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await el.updateComplete;
|
||||
|
||||
const saveBtn = [...el.shadowRoot!.querySelectorAll("ha-button")].find((b) =>
|
||||
(b.textContent || "").toLowerCase().includes("save"),
|
||||
)!;
|
||||
(saveBtn as HTMLElement).click();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const saveMsg = (sent as Record<string, unknown>[]).find(
|
||||
(m) => m.type === "maintenance_supporter/views/save",
|
||||
)!;
|
||||
expect(saveMsg.name).to.equal("My view");
|
||||
expect(saveMsg.filters).to.deep.equal(FILTERS);
|
||||
expect(emitted, "emits server list").to.not.be.null;
|
||||
expect(emitted![0].name).to.equal("My view");
|
||||
});
|
||||
|
||||
it("deletes a view by id and emits the trimmed list", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const el = await mount(sent);
|
||||
let emitted: SavedView[] | null = null;
|
||||
el.addEventListener("saved-views-changed", (e) => {
|
||||
emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views;
|
||||
});
|
||||
|
||||
const delBtn = el.shadowRoot!.querySelector<HTMLElement>(".row ha-icon-button")!;
|
||||
delBtn.click();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const delMsg = (sent as Record<string, unknown>[]).find(
|
||||
(m) => m.type === "maintenance_supporter/views/delete",
|
||||
)!;
|
||||
expect(delMsg.view_id).to.equal("v1");
|
||||
expect(emitted).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Contrast tripwire for status badges (dark-mode & a11y QA, v2.24).
|
||||
*
|
||||
* The roadmap claimed status colours were "routed through theme tokens with a
|
||||
* tripwire blocking bare colours" — but no such test existed, and the badges
|
||||
* used white text on light backgrounds (green/orange/grey) at 2.2–2.8:1, below
|
||||
* the 3:1 WCAG floor for UI components. This renders each badge with the REAL
|
||||
* sharedStyles (fallback hex applies with no HA theme loaded) and asserts the
|
||||
* computed text-on-background contrast clears 3:1 — so reverting a light badge
|
||||
* to white text fails the build.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import { LitElement, css } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { sharedStyles } from "../styles";
|
||||
import { panelStyles } from "../panel-styles";
|
||||
|
||||
const STATUSES = ["ok", "due_soon", "overdue", "triggered", "done", "archived", "paused"];
|
||||
// The task-detail view uses a SEPARATE `.status-chip` set in panel-styles.ts.
|
||||
const CHIP_STATUSES = ["ok", "warning", "overdue", "done"];
|
||||
|
||||
@customElement("badge-contrast-probe")
|
||||
class BadgeContrastProbe extends LitElement {
|
||||
static styles = [sharedStyles, css`:host { display: block; }`];
|
||||
render() {
|
||||
return html`${STATUSES.map(
|
||||
(s) => html`<span class="status-badge ${s}" data-s=${s}>${s}</span>`,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("chip-contrast-probe")
|
||||
class ChipContrastProbe extends LitElement {
|
||||
static styles = [panelStyles, css`:host { display: block; }`];
|
||||
render() {
|
||||
return html`${CHIP_STATUSES.map(
|
||||
(s) => html`<span class="status-chip ${s}" data-s=${s}>${s}</span>`,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRgb(v: string): [number, number, number] {
|
||||
const m = v.match(/rgba?\(([^)]+)\)/);
|
||||
if (!m) throw new Error("not rgb: " + v);
|
||||
const [r, g, b] = m[1].split(",").map((x) => parseFloat(x));
|
||||
return [r, g, b];
|
||||
}
|
||||
function relLum([r, g, b]: [number, number, number]): number {
|
||||
const lin = (c: number) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
function contrast(fg: string, bg: string): number {
|
||||
const a = relLum(parseRgb(fg));
|
||||
const b = relLum(parseRgb(bg));
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
describe("status badge contrast (WCAG UI 3:1)", () => {
|
||||
it("every status badge clears 3:1 text-on-background", async () => {
|
||||
const el = await fixture<BadgeContrastProbe>(html`<badge-contrast-probe></badge-contrast-probe>`);
|
||||
await el.updateComplete;
|
||||
for (const s of STATUSES) {
|
||||
const badge = el.shadowRoot!.querySelector<HTMLElement>(`.status-badge[data-s="${s}"]`)!;
|
||||
const cs = getComputedStyle(badge);
|
||||
const ratio = contrast(cs.color, cs.backgroundColor);
|
||||
expect(ratio, `${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every task-detail status chip clears 3:1 text-on-background", async () => {
|
||||
const el = await fixture<ChipContrastProbe>(html`<chip-contrast-probe></chip-contrast-probe>`);
|
||||
await el.updateComplete;
|
||||
for (const s of CHIP_STATUSES) {
|
||||
const chip = el.shadowRoot!.querySelector<HTMLElement>(`.status-chip[data-s="${s}"]`)!;
|
||||
const cs = getComputedStyle(chip);
|
||||
const ratio = contrast(cs.color, cs.backgroundColor);
|
||||
expect(ratio, `chip ${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* <maintenance-task-dialog>: the live "what happens next" trigger hint.
|
||||
*
|
||||
* The sensor-based trigger form reads the bound entity's CURRENT state and
|
||||
* spells out the semantics (a delta counter counts from the current reading,
|
||||
* not from zero, and restarts after each completion). Pins: the hint renders
|
||||
* with the live value + computed due point, updates per trigger type, and
|
||||
* stays absent when no entity/state is available.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
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 });
|
||||
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;
|
||||
return el;
|
||||
}
|
||||
|
||||
const hint = (el: MaintenanceTaskDialog): string | null =>
|
||||
el.shadowRoot!.querySelector(".trigger-live-hint")?.textContent?.trim() ?? null;
|
||||
|
||||
describe("task-dialog live trigger hint", () => {
|
||||
it("delta counter: shows current reading, computed due point, and restart semantics", async () => {
|
||||
const el = await mountCreate({
|
||||
"sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } },
|
||||
});
|
||||
(el as any)._triggerEntityId = "sensor.pump_hours";
|
||||
(el as any)._triggerEntityIds = ["sensor.pump_hours"];
|
||||
(el as any)._triggerType = "counter";
|
||||
(el as any)._triggerDeltaMode = true;
|
||||
(el as any)._triggerTargetValue = "100";
|
||||
await el.updateComplete;
|
||||
const text = hint(el)!;
|
||||
expect(text, "hint rendered").to.not.equal(null);
|
||||
expect(text).to.include("660 h"); // current reading
|
||||
expect(text).to.include("760 h"); // computed due point (660 + 100)
|
||||
});
|
||||
|
||||
it("threshold: shows current value and the above-target", async () => {
|
||||
const el = await mountCreate({
|
||||
"sensor.pressure": { state: "1.2", attributes: { unit_of_measurement: "bar" } },
|
||||
});
|
||||
(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;
|
||||
const text = hint(el)!;
|
||||
expect(text).to.include("1.2 bar");
|
||||
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: {} },
|
||||
});
|
||||
(el as any)._triggerType = "threshold";
|
||||
(el as any)._triggerAbove = "1.5"; // target set, but NO entity
|
||||
await el.updateComplete;
|
||||
expect(hint(el)).to.equal(null);
|
||||
|
||||
(el as any)._triggerEntityId = "sensor.pressure";
|
||||
(el as any)._triggerEntityIds = ["sensor.pressure"];
|
||||
(el as any)._triggerAbove = ""; // entity set, but no target
|
||||
await el.updateComplete;
|
||||
expect(hint(el)).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" } } },
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.openEdit("e1", {
|
||||
id: "t1", name: "Service", type: "custom",
|
||||
schedule_type: "sensor_based", warning_days: 7, enabled: true,
|
||||
trigger_config: {
|
||||
type: "counter", entity_id: "sensor.pump_hours", entity_ids: ["sensor.pump_hours"],
|
||||
trigger_target_value: 100, trigger_delta_mode: true,
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
const text = hint(el)!;
|
||||
expect(text, "hint rendered in edit mode").to.not.equal(null);
|
||||
// Must NOT claim the count starts at the current reading (the stored
|
||||
// baseline is the last completion, not "now") — no computed 760 h.
|
||||
expect(text).to.not.include("760");
|
||||
expect(text).to.include("100 h");
|
||||
});
|
||||
});
|
||||
@@ -123,3 +123,48 @@ describe("task-documents", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── part mode (v2.26): same component, linking via part_ids ─────────────────
|
||||
|
||||
const P_LINKED = { id: "d1", kind: "file", title: "Datasheet", filename: "d.pdf", mime: "application/pdf", size: 100, tags: [], task_ids: [], part_ids: ["p1"] };
|
||||
const P_AVAIL = { id: "d2", kind: "weblink", title: "Spec page", url: "https://x/spec", tags: [], task_ids: [], part_ids: [] };
|
||||
|
||||
async function mountPart(docs: unknown[] = [P_LINKED, P_AVAIL]) {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/list": () => ({ documents: docs }),
|
||||
"maintenance_supporter/documents/update": () => ({ id: "d1" }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDocuments>(html`
|
||||
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .partId=${"p1"} .canWrite=${true}></maintenance-task-documents>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-documents in part mode", () => {
|
||||
it("filters by part_ids and links via part_ids (never task_ids)", async () => {
|
||||
const { el, sent } = await mountPart();
|
||||
const rows = el.shadowRoot!.querySelectorAll(".tdoc-row");
|
||||
expect(rows.length).to.equal(1);
|
||||
expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Datasheet");
|
||||
|
||||
const select = el.shadowRoot!.querySelector<HTMLSelectElement>(".tdoc-select")!;
|
||||
select.value = "d2";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
el.shadowRoot!.querySelector<HTMLButtonElement>(".tdoc-btn")!.click();
|
||||
await el.updateComplete;
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d2");
|
||||
expect(msg, "link WS sent").to.exist;
|
||||
expect(msg!.part_ids).to.deep.equal(["p1"]);
|
||||
expect(msg!.task_ids, "task_ids untouched in part mode").to.be.undefined;
|
||||
});
|
||||
|
||||
it("offers no per-task page input for a linked PDF in part mode", async () => {
|
||||
const { el } = await mountPart([P_LINKED]);
|
||||
expect(el.shadowRoot!.querySelector(".tdoc-page")).to.not.exist;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user