New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,105 @@
/** Shared mount helper + data factories for full-panel tests.
*
* Mounting <maintenance-supporter-panel> needs a hass mock with `user` set
* (no user → the panel renders read-only "operator" mode) plus handlers for
* the five _loadData calls. Used by panel-shell.test.ts (bulk / palette /
* Today / virtual table) and panel-deeplink.test.ts (QR scan routing).
*/
import { fixture, html } from "@open-wc/testing";
import "../maintenance-panel.js";
import { createMockHass, type WsHandler } from "./_test-utils.js";
let taskSeq = 0;
export function resetTaskSeq(): void {
taskSeq = 0;
}
export function task(over: Record<string, unknown> = {}) {
taskSeq++;
return {
id: `t${taskSeq}`,
name: `Task ${String(taskSeq).padStart(3, "0")}`,
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
status: "ok",
days_until_due: 10,
next_due: "2026-07-15",
last_performed: null,
trigger_active: false,
trigger_current_value: null,
trigger_config: null,
times_performed: 0,
total_cost: 0,
average_duration: null,
history: [],
checklist: [],
labels: [],
priority: "normal",
enabled: true,
archived: false,
is_done: false,
responsible_user_id: null,
nfc_tag_id: null,
entity_slug: null,
...over,
};
}
export function obj(entryId: string, tasks: unknown[], name = "Pool Pump") {
return {
entry_id: entryId,
object_id: `obj_${entryId}`,
object: {
id: `obj_${entryId}`, name, area_id: null, manufacturer: null,
model: null, serial_number: null, task_ids: [],
},
tasks,
document_count: 0,
};
}
export async function mountPanel(
objects: unknown[],
extraHandlers: Record<string, WsHandler> = {},
) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({ objects }),
"maintenance_supporter/statistics": () => ({
total_objects: objects.length, total_tasks: 0,
overdue: 0, due_soon: 0, triggered: 0, ok: 0,
}),
"maintenance_supporter/budget_status": () => ({}),
"maintenance_supporter/groups": () => ({ groups: {} }),
"maintenance_supporter/documents/list": () => ({ documents: [] }),
"maintenance_supporter/task/complete": () => ({ success: true }),
"maintenance_supporter/task/archive": () => ({ success: true }),
"maintenance_supporter/task/unarchive": () => ({ success: true }),
...extraHandlers,
},
});
// 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>).areas = {};
const el = await fixture<HTMLElement & { updateComplete: Promise<unknown> }>(html`
<maintenance-supporter-panel
.hass=${hass}
style="display:block; height: 600px;"
></maintenance-supporter-panel>
`);
// _loadData is async; give it a beat, then settle renders.
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
return { el, sent };
}
export function sr(el: HTMLElement): ShadowRoot {
return el.shadowRoot!;
}
@@ -0,0 +1,171 @@
/**
* Shared test utilities for the panel/card component test suites.
*
* Centralises the things that were previously duplicated across the per-file
* `mockHass()` helpers in settings-view-vacation.test.ts /
* settings-view-print-qr.test.ts / task-dialog-completion-actions.test.ts:
*
* - DEFAULT_FEATURES — the AdvancedFeatures shape with everything OFF
* - DEFAULT_SETTINGS_RESPONSE — what the backend's
* `maintenance_supporter/settings` WS handler returns out of the box
* - createMockHass(...) — returns a stub `{hass, sent, serviceCalls}`
* with sendMessagePromise/callService captures and a small handler
* map for the most common WS endpoints. Per-suite overrides can be
* added via the `handlers` option.
*
* Underscore-prefixed filename so web-test-runner's `__tests__/**\/*.test.ts`
* glob skips it (it's a helper, not a suite).
*/
export interface SentMessage {
type: string;
[key: string]: unknown;
}
export interface ServiceCall {
domain: string;
service: string;
data?: Record<string, unknown>;
/** v2.3.x — separate target arg (matches HA's callService(d, s, data, target)
* signature + the production action_listener.py path). Older tests asserted
* on data.entity_id; that pattern is now legacy — assert on target.entity_id. */
target?: Record<string, unknown>;
}
/** Same shape as `frontend-src/types.ts::AdvancedFeatures`. */
export interface MockFeatures {
adaptive: boolean;
predictions: boolean;
seasonal: boolean;
environmental: boolean;
budget: boolean;
groups: boolean;
checklists: boolean;
schedule_time: boolean;
completion_actions: boolean;
}
export const DEFAULT_FEATURES: MockFeatures = {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
};
/**
* Default response for `maintenance_supporter/settings` — mirrors the shape
* that `_build_full_settings()` in `websocket/dashboard.py` produces with
* defaults. Override sub-objects via spread when a test needs a specific value:
*
* const settings = { ...DEFAULT_SETTINGS_RESPONSE, vacation: {...DEFAULT_SETTINGS_RESPONSE.vacation, enabled: true} };
*/
export const DEFAULT_SETTINGS_RESPONSE = {
features: { ...DEFAULT_FEATURES },
admin_panel_user_ids: [] as string[],
operator_write_enabled: false,
general: {
default_warning_days: 7,
notifications_enabled: false,
notify_service: "",
notify_targets: [] as string[],
panel_enabled: false,
},
notifications: {
due_soon_enabled: true, due_soon_interval_hours: 24,
overdue_enabled: true, overdue_interval_hours: 12,
triggered_enabled: true, triggered_interval_hours: 0,
quiet_hours_enabled: true, quiet_hours_start: "22:00", quiet_hours_end: "08:00",
max_per_day: 0, bundling_enabled: false, bundle_threshold: 2,
title_style: "default",
},
actions: {
complete_enabled: false, skip_enabled: false,
snooze_enabled: false, snooze_duration_hours: 4,
},
budget: {
monthly: 0, yearly: 0, alerts_enabled: false,
alert_threshold_pct: 80, currency: "EUR", currency_symbol: "€",
},
vacation: {
enabled: false, start: null as string | null, end: null as string | null,
buffer_days: 3, exempt_task_ids: [] as string[],
is_active: false, window_end: null as string | null,
},
};
export type WsHandler = (msg: SentMessage) => Promise<unknown> | unknown;
export interface CreateMockHassResult {
hass: {
language: string;
connection: { sendMessagePromise: (msg: SentMessage) => Promise<unknown> };
callService: (
domain: string, service: string,
data?: Record<string, unknown>, target?: Record<string, unknown>,
) => Promise<void>;
services?: Record<string, Record<string, unknown>>;
states?: Record<string, unknown>;
};
sent: SentMessage[];
serviceCalls: ServiceCall[];
}
export interface CreateMockHassOptions {
/** Override the default settings response (deep-merge not done — pass full shape). */
settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE;
/** Per-WS-type handlers — return a value or Promise. Wins over built-in defaults. */
handlers?: Record<string, WsHandler>;
/** Optional `hass.services` registry (for ha-service-picker / schema-driven forms). */
services?: Record<string, Record<string, unknown>>;
/** Optional `hass.states` (entity_id → state) — e.g. for notify-entity pickers. */
states?: Record<string, unknown>;
/** Override `hass.language`. Defaults to "en". */
language?: string;
}
/**
* Build a stub `hass` object suitable for mounting Lit components in
* @open-wc/testing fixtures. Captures all outgoing WS messages in `sent`
* and all service calls in `serviceCalls` so tests can assert on them.
*
* Built-in handlers (overridable via `opts.handlers`):
* - maintenance_supporter/settings → DEFAULT_SETTINGS_RESPONSE (or the override)
* - maintenance_supporter/users/list → {users: []}
* - maintenance_supporter/objects → {objects: []}
* - maintenance_supporter/tags/list → {tags: []}
* - default for anything else → {}
*/
export function createMockHass(opts: CreateMockHassOptions = {}): CreateMockHassResult {
const sent: SentMessage[] = [];
const serviceCalls: ServiceCall[] = [];
const settings = opts.settingsResponse ?? DEFAULT_SETTINGS_RESPONSE;
const sendMessagePromise = async (msg: SentMessage): Promise<unknown> => {
sent.push(msg);
const override = opts.handlers?.[msg.type];
if (override) return await override(msg);
if (msg.type === "maintenance_supporter/settings") return settings;
if (msg.type === "maintenance_supporter/users/list") return { users: [] };
if (msg.type === "maintenance_supporter/objects") return { objects: [] };
if (msg.type === "maintenance_supporter/tags/list") return { tags: [] };
return {};
};
const callService = async (
domain: string, service: string,
data?: Record<string, unknown>, target?: Record<string, unknown>,
): Promise<void> => {
serviceCalls.push({ domain, service, data, target });
};
return {
hass: {
language: opts.language ?? "en",
connection: { sendMessagePromise },
callService,
services: opts.services,
states: opts.states,
},
sent,
serviceCalls,
};
}
@@ -0,0 +1,465 @@
/**
* Pure-function tests for the Calendar tab's bucketing + recurring projection
* helper (v1.5.0).
*
* Pins:
* - tasks with next_due in the window land on the right day
* - tasks with next_due before the window are excluded (unless overdue)
* - overdue / triggered tasks bucket on "today" (windowStart)
* - time-based tasks project up to MAX_OCCURRENCES_PER_TASK occurrences
* - sensor-triggered tasks DO NOT get projected occurrences
* - user filter restricts to tasks with matching responsible_user_id
* - status sort within a day: overdue < triggered < due_soon < ok
* - disabled tasks never produce events
*/
import { expect } from "@open-wc/testing";
import {
buildCalendarBuckets,
buildPastBuckets,
buildPastWindowDates,
isoDateLocal,
MAX_OCCURRENCES_PER_TASK,
} from "../helpers/calendar-bucket";
const TODAY = new Date(2026, 4, 1); // 2026-05-01 local
const TODAY_ISO = "2026-05-01";
function addDays(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
date.setDate(date.getDate() + days);
return isoDateLocal(date);
}
function task(over: Partial<any> = {}) {
return {
id: "t1",
name: "Filter Replacement",
enabled: true,
schedule_type: "time_based",
interval_days: 30,
status: "ok",
next_due: addDays(TODAY_ISO, 5),
days_until_due: 5,
history: [],
responsible_user_id: null,
...over,
};
}
function obj(name: string, tasks: any[]) {
return {
entry_id: `entry-${name.toLowerCase().replace(/\s+/g, "-")}`,
object: { id: `o-${name}`, name, area_id: null, manufacturer: null,
model: null, serial_number: null, installation_date: null },
tasks,
} as any;
}
describe("buildCalendarBuckets", () => {
it("returns one bucket per day in the window", () => {
const buckets = buildCalendarBuckets([], TODAY, 7);
expect(buckets).to.have.length(7);
expect(buckets[0].date).to.equal(TODAY_ISO);
expect(buckets[6].date).to.equal(addDays(TODAY_ISO, 6));
});
it("buckets a single task on its next_due day", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 3) })])],
TODAY, 7
);
expect(buckets[3].events).to.have.length(1);
expect(buckets[3].events[0].task_name).to.equal("Filter Replacement");
expect(buckets[3].events[0].projected).to.equal(false);
});
it("excludes tasks whose next_due is before the window and not overdue", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, -10), status: "ok" })])],
TODAY, 7
);
const total = buckets.reduce((s, b) => s + b.events.length, 0);
expect(total).to.equal(0);
});
it("buckets overdue tasks on today regardless of next_due", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
next_due: addDays(TODAY_ISO, -15),
status: "overdue",
days_until_due: -15,
interval_days: 90, // 90d > 7d window → no projection within window
})])],
TODAY, 7
);
expect(buckets[0].events).to.have.length(1);
expect(buckets[0].events[0].status).to.equal("overdue");
expect(buckets[0].events[0].projected).to.equal(false);
});
it("projects time-based recurring occurrences within the window", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, 2),
interval_days: 7,
schedule_type: "time_based",
})])],
TODAY, 30
);
// Window covers days 0..29. From next_due=+2 with step=7: +2, +9, +16, +23.
// Next would be +30 which is outside the window, so 4 events total.
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(4);
expect(events[0].projected).to.equal(false);
expect(events.slice(1).every((e) => e.projected)).to.equal(true);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
expect(events[3].date).to.equal(addDays(TODAY_ISO, 23));
});
it("caps projection at MAX_OCCURRENCES_PER_TASK", () => {
// 1-day interval with 30-day window would otherwise produce 30+ events
const buckets = buildCalendarBuckets(
[obj("Daily", [task({
next_due: TODAY_ISO,
interval_days: 1,
schedule_type: "time_based",
days_until_due: 0,
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(MAX_OCCURRENCES_PER_TASK);
});
it("a months/years interval does not project at daily steps (issue #59)", () => {
// interval_days=1 + unit=years → one occurrence per ~year, so a 30-day
// window holds only the single next_due, NOT 5 daily-spaced projections.
const buckets = buildCalendarBuckets(
[obj("Boiler", [task({
next_due: addDays(TODAY_ISO, 3),
days_until_due: 3,
interval_days: 1,
interval_unit: "years",
schedule_type: "time_based",
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(1);
expect(events[0].projected).to.equal(false);
});
it("projects a weekly (unit=weeks) task at ~7-day steps (#59)", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, 2),
days_until_due: 2,
interval_days: 1,
interval_unit: "weeks",
schedule_type: "time_based",
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
// +2, +9, +16, +23 — same as interval_days:7, NOT daily spam.
expect(events.length).to.equal(4);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
});
it("projects a monthly (unit=months) task at ~30-day steps (#59)", () => {
const buckets = buildCalendarBuckets(
[obj("Filter", [task({
next_due: addDays(TODAY_ISO, 3),
days_until_due: 3,
interval_days: 1,
interval_unit: "months",
schedule_type: "time_based",
})])],
TODAY, 70
);
const events = buckets.flatMap((b) => b.events);
// round(30.4368) = 30 → +3, +33, +63 within a 70-day window.
expect(events.length).to.equal(3);
expect(events[0].date).to.equal(addDays(TODAY_ISO, 3));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 33));
});
it("does NOT project sensor-triggered tasks beyond next_due", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
schedule_type: "sensor_based",
interval_days: 7, // ignored — sensor tasks don't project
next_due: addDays(TODAY_ISO, 3),
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].projected).to.equal(false);
});
it("respects the user filter via responsible_user_id", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-none", responsible_user_id: null, next_due: addDays(TODAY_ISO, 1) }),
])],
TODAY, 7, "alice"
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].task_id).to.equal("t-alice");
});
it("includes all users when filter is null", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
])],
TODAY, 7, null
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(2);
});
it("orders within a day: overdue < triggered < due_soon < ok", () => {
const due = addDays(TODAY_ISO, 0);
const buckets = buildCalendarBuckets(
[obj("HVAC", [
task({ id: "t-ok", status: "ok", name: "OK Task", next_due: due, interval_days: 9999 }),
task({ id: "t-overdue", status: "overdue", name: "Overdue Task",
next_due: due, days_until_due: -5, interval_days: 9999 }),
task({ id: "t-triggered", status: "triggered", name: "Triggered Task",
schedule_type: "sensor_based", next_due: due, interval_days: null }),
task({ id: "t-due_soon", status: "due_soon", name: "Due Soon Task",
next_due: due, interval_days: 9999 }),
])],
TODAY, 7
);
const todayEvents = buckets[0].events;
expect(todayEvents.map((e) => e.status)).to.deep.equal([
"overdue", "triggered", "due_soon", "ok",
]);
});
it("excludes disabled tasks", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ enabled: false, next_due: addDays(TODAY_ISO, 1) })])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(0);
});
it("buckets first occurrence + projected ones for a 14-day window task", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({ next_due: addDays(TODAY_ISO, 1), interval_days: 14 })])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.equal(3); // +1, +15, +29
expect(events[0].date).to.equal(addDays(TODAY_ISO, 1));
expect(events[1].date).to.equal(addDays(TODAY_ISO, 15));
expect(events[2].date).to.equal(addDays(TODAY_ISO, 29));
});
// v1.5.1: source indicator + prediction confidence
it("flags adaptive_enabled when adaptive_config.enabled is true", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({
next_due: addDays(TODAY_ISO, 5),
adaptive_config: { enabled: true },
})])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].adaptive_enabled).to.equal(true);
expect(events[0].prediction_confidence).to.equal(null);
});
it("propagates threshold_prediction_confidence for sensor-based tasks", () => {
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
schedule_type: "sensor_based",
next_due: addDays(TODAY_ISO, 4),
threshold_prediction_confidence: "high",
interval_days: null,
})])],
TODAY, 14
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].schedule_type).to.equal("sensor_based");
expect(events[0].prediction_confidence).to.equal("high");
expect(events[0].adaptive_enabled).to.equal(false);
});
it("downgrades status of projected occurrences from an overdue task to ok", () => {
// The May 7 projection of an overdue task should NOT inherit "overdue"
// — the projection is the assumption that the user completes today, so
// the projected slot is hypothetical and starts fresh.
const buckets = buildCalendarBuckets(
[obj("Pool", [task({
next_due: addDays(TODAY_ISO, -10),
status: "overdue",
days_until_due: -10,
interval_days: 7,
})])],
TODAY, 30
);
const events = buckets.flatMap((b) => b.events);
expect(events.length).to.be.greaterThan(1);
expect(events[0].status).to.equal("overdue"); // first occurrence on today
expect(events[0].projected).to.equal(false);
// All projected occurrences should read "ok"
for (const e of events.slice(1)) {
expect(e.projected).to.equal(true);
expect(e.status).to.equal("ok");
expect(e.days_until_due).to.equal(null);
}
});
it("defaults source fields to (false, null) when no metadata is present", () => {
const buckets = buildCalendarBuckets(
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 2) })])],
TODAY, 7
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].adaptive_enabled).to.equal(false);
expect(events[0].prediction_confidence).to.equal(null);
});
});
// ── v2.2.0 — past-window bucketer ────────────────────────────────────────
function histEntry(daysAgo: number, type = "completed", over: Partial<any> = {}) {
const d = new Date(TODAY);
d.setDate(d.getDate() - daysAgo);
return {
timestamp: `${isoDateLocal(d)}T08:30:00`,
type,
...over,
};
}
function pastTask(history: any[], over: Partial<any> = {}) {
return {
...task({ history }),
...over,
};
}
describe("buildPastWindowDates", () => {
it("returns N consecutive ISO dates ending on today", () => {
const dates = buildPastWindowDates(TODAY, 7);
expect(dates).to.have.length(7);
expect(dates[6]).to.equal(TODAY_ISO);
expect(dates[0]).to.equal(addDays(TODAY_ISO, -6));
});
});
describe("buildPastBuckets", () => {
it("returns one bucket per day in the past window", () => {
const buckets = buildPastBuckets([], TODAY, 30);
expect(buckets).to.have.length(30);
expect(buckets[29].date).to.equal(TODAY_ISO);
expect(buckets[0].date).to.equal(addDays(TODAY_ISO, -29));
});
it("buckets a completion entry on its actual date", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([histEntry(5)])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].date).to.equal(addDays(TODAY_ISO, -5));
expect(events[0].history_timestamp).to.match(/^[0-9-]{10}T08:30:00$/);
expect(events[0].history_type).to.equal("completed");
});
it("excludes entries outside the window", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(5), // inside 30-day window
histEntry(40), // outside
])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
});
it("preserves cost / notes / duration from history entries", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(3, "completed", { cost: 42.5, notes: "n1", duration: 60 }),
])])],
TODAY, 30,
);
const ev = buckets.flatMap((b) => b.events)[0];
expect(ev.history_cost).to.equal(42.5);
expect(ev.history_notes).to.equal("n1");
expect(ev.history_duration).to.equal(60);
});
it("maps history types to status correctly", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([
histEntry(1, "completed"),
histEntry(2, "skipped"),
histEntry(3, "triggered"),
])])],
TODAY, 30,
);
const events = buckets.flatMap((b) => b.events);
const byType = Object.fromEntries(events.map((e) => [e.history_type, e.status]));
expect(byType.completed).to.equal("ok");
expect(byType.skipped).to.equal("due_soon");
expect(byType.triggered).to.equal("triggered");
});
it("respects user filter", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [
pastTask([histEntry(5)], { id: "a", responsible_user_id: "u1" }),
pastTask([histEntry(5)], { id: "b", responsible_user_id: "u2" }),
])],
TODAY, 30, "u1",
);
const events = buckets.flatMap((b) => b.events);
expect(events).to.have.length(1);
expect(events[0].task_id).to.equal("a");
});
it("sorts within day by type rank then by name", () => {
const buckets = buildPastBuckets(
[obj("HVAC", [
pastTask([histEntry(5, "triggered")], { id: "z", name: "Z task" }),
pastTask([histEntry(5, "completed")], { id: "a", name: "A task" }),
pastTask([histEntry(5, "completed")], { id: "m", name: "M task" }),
])],
TODAY, 30,
);
const dayEvents = buckets.find((b) => b.date === addDays(TODAY_ISO, -5))!.events;
expect(dayEvents.map((e) => e.task_id)).to.deep.equal(["a", "m", "z"]);
});
it("history_timestamp survives so the edit-history WS can use it", () => {
const ts = "2026-04-25T14:00:00";
const buckets = buildPastBuckets(
[obj("HVAC", [pastTask([{ timestamp: ts, type: "completed" }])])],
TODAY, 30,
);
const ev = buckets.flatMap((b) => b.events)[0];
expect(ev.history_timestamp).to.equal(ts);
});
});
@@ -0,0 +1,120 @@
/**
* Tests for the standalone <maintenance-supporter-calendar-card> (audit #10).
*
* The bucketing math is deep-tested in calendar-bucket.test.ts; this covers
* the card behaviours on top of it:
* - window chips re-bucket the events (an event beyond the window disappears)
* - projected recurrences render with the projected class; the real
* next-due event does not
* - clicking an event fires the ll-custom open-task payload the panel and
* dialog-mount listen for
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../maintenance-calendar-card.js";
import { createMockHass } from "./_test-utils.js";
type CardEl = HTMLElement & { updateComplete: Promise<unknown> };
function isoInDays(n: number): string {
const d = new Date();
d.setDate(d.getDate() + n);
const p = (x: number) => String(x).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
function task(over: Record<string, unknown> = {}) {
return {
id: "t1",
name: "Filter",
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
status: "ok",
days_until_due: 5,
next_due: isoInDays(5),
trigger_active: false,
history: [],
enabled: true,
archived: false,
responsible_user_id: null,
...over,
};
}
async function mount(tasks: unknown[]) {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({
objects: [{
entry_id: "e1",
object: { id: "o1", name: "Pool Pump", area_id: null, task_ids: [] },
tasks,
}],
}),
"maintenance_supporter/statistics": () => ({}),
},
});
const el = await fixture<CardEl>(html`
<maintenance-supporter-calendar-card .hass=${hass}></maintenance-supporter-calendar-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el };
}
function eventTitles(el: CardEl): string[] {
return [...el.shadowRoot!.querySelectorAll(".cal-event-title")]
.map((e) => e.textContent?.trim() || "");
}
describe("calendar-card", () => {
it("window chips re-bucket: a 20-days-out event survives +30d but not +7d", async () => {
const { el } = await mount([
task({ id: "near", name: "Near", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
task({ id: "far", name: "Far", days_until_due: 20, next_due: isoInDays(20), interval_days: 400 }),
]);
// Default +30d window shows both.
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.true;
// Click the +7d chip → the 20-days-out event drops off.
const chip = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".cal-window-chip")]
.find((c) => c.textContent?.trim() === "+7d")!;
chip.click();
await el.updateComplete;
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.false;
});
it("projected recurrences carry the projected class; the real event does not", async () => {
// 10-day interval inside a 30-day window → 1 real + projected occurrences.
const { el } = await mount([
task({ id: "rec", name: "Recurring", days_until_due: 3, next_due: isoInDays(3), interval_days: 10 }),
]);
const real = [...el.shadowRoot!.querySelectorAll(".cal-event:not(.cal-event-projected)")];
const projected = [...el.shadowRoot!.querySelectorAll(".cal-event.cal-event-projected")];
expect(real.length).to.equal(1);
expect(projected.length).to.be.greaterThan(0);
});
it("clicking an event fires the ll-custom open-task payload", async () => {
const { el } = await mount([
task({ id: "t42", name: "Clicky", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
]);
let detail: Record<string, unknown> | null = null;
el.addEventListener("ll-custom", (e) => {
detail = (e as CustomEvent<Record<string, unknown>>).detail;
});
el.shadowRoot!.querySelector<HTMLElement>(".cal-event")!.click();
expect(detail, "ll-custom fired").to.not.be.null;
expect(detail!.type).to.equal("maintenance-supporter:open-task");
expect(detail!.entry_id).to.equal("e1");
expect(detail!.task_id).to.equal("t42");
});
});
@@ -0,0 +1,86 @@
/**
* Component test for the Lovelace card task sort (forum thread /995556 #7,
* reported by brunkj — v2.3.7).
*
* Pins:
* - Tasks sort by status first (overdue, triggered, due_soon, ok)
* - Within a status, soonest-due-first (the bug: previously kept WS/creation
* order, so a task due in 3 days could sit above one due in 1 day)
* - Tasks without a numeric due date sort last within their status
*/
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, days: number | null) => ({
id, name, status, days_until_due: days, type: "service",
});
const O = (entry_id: string, name: string, tasks: unknown[]) => ({
entry_id, object: { id: entry_id, name }, tasks,
});
// Creation order deliberately NOT in due-date order, to prove the sort
// no longer falls back to insertion order for same-status ties.
function mockObjects() {
return [
O("e1", "Aqua Pool", [T("t1", "Check chlorine", "due_soon", 3)]),
O("e2", "Hot Tub", [T("t2", "Check pH", "due_soon", 1)]),
O("e3", "Furnace", [
T("t3", "Replace filter", "overdue", -2),
T("t4", "Deep service", "overdue", -10),
]),
O("e4", "Garage", [T("t5", "Manual check", "due_soon", null)]),
O("e5", "Lamp", [T("t6", "Dust", "ok", 40)]),
];
}
function mockHass() {
return {
language: "en",
connection: {
sendMessagePromise: async (msg: { type: string }) =>
msg.type === "maintenance_supporter/objects"
? { objects: mockObjects() }
: { 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").length > 0,
"task rows render",
{ timeout: 2000 }
);
await el.updateComplete;
return el;
}
const names = (el: MaintenanceSupporterCard) =>
[...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || "");
describe("maintenance-card sort (forum #7 — due-date tiebreaker)", () => {
it("orders by status, then soonest-due first within a status", async () => {
const el = await mount();
expect(names(el)).to.deep.equal([
"Deep service", // overdue -10 (most overdue first)
"Replace filter", // overdue -2
"Check pH", // due_soon 1
"Check chlorine", // due_soon 3
"Manual check", // due_soon null -> last within due_soon
"Dust", // ok 40
]);
});
it("reported case: due-in-1-day sorts before due-in-3-days (same status)", async () => {
const el = await mount({ filter_status: ["due_soon"] });
const n = names(el);
expect(n.indexOf("Check pH")).to.be.lessThan(n.indexOf("Check chlorine"));
});
});
@@ -0,0 +1,169 @@
/**
* Behavioural tests for <maintenance-complete-dialog> (audit gap #3).
*
* 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).
*/
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";
type MountOpts = {
checklist?: string[];
adaptiveEnabled?: boolean;
completeHandler?: () => unknown;
};
async function mount(opts: MountOpts = {}) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/complete":
opts.completeHandler ?? (() => ({ success: true })),
},
});
const el = await fixture<MaintenanceCompleteDialog>(html`
<maintenance-complete-dialog
.hass=${hass}
.entryId=${"entry1"}
.taskId=${"task1"}
.taskName=${"Filter Wechsel"}
.lang=${"en"}
.checklist=${opts.checklist ?? []}
.adaptiveEnabled=${opts.adaptiveEnabled ?? false}
></maintenance-complete-dialog>
`);
el.open();
await el.updateComplete;
return { el, sent };
}
function setInput(el: MaintenanceCompleteDialog, index: number, value: string) {
const input = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][index];
input.value = value;
input.dispatchEvent(new Event("input"));
}
function clickComplete(el: MaintenanceCompleteDialog) {
const buttons = [...el.shadowRoot!.querySelectorAll(".dialog-actions ha-button")];
(buttons[buttons.length - 1] as HTMLElement).click();
}
describe("complete-dialog", () => {
it("submits notes, cost, duration, feedback and checklist_state in the payload", async () => {
const { el, sent } = await mount({
checklist: ["Step A", "Step B"],
adaptiveEnabled: true,
});
setInput(el, 0, "oil changed");
setInput(el, 1, "12.5");
setInput(el, 2, "30");
// Tick the second checklist step (click the checkbox; the event bubbles
// to the row's toggle handler exactly once).
const boxes = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".checklist-item input")];
boxes[1].click();
// Pick the "not needed" feedback option.
const fb = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".feedback-btn")];
fb[1].click();
await el.updateComplete;
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg, "task/complete sent").to.exist;
expect(msg.entry_id).to.equal("entry1");
expect(msg.task_id).to.equal("task1");
expect(msg.notes).to.equal("oil changed");
expect(msg.cost).to.equal(12.5);
expect(msg.duration).to.equal(30);
expect(msg.feedback).to.equal("not_needed");
expect(msg.checklist_state).to.deep.equal({ "1": true });
// Dialog closed on success.
await el.updateComplete;
expect(el.shadowRoot!.querySelector("ha-dialog")).to.be.null;
});
it("omits optional fields that were left empty", async () => {
const { el, sent } = await mount();
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg).to.exist;
expect("notes" in msg).to.be.false;
expect("cost" in msg).to.be.false;
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_id" in msg).to.be.false;
});
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.
const realFetch = window.fetch;
const uploads: RequestInit[] = [];
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;
}) as typeof window.fetch;
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"));
await new Promise((r) => setTimeout(r, 10));
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);
const form = uploads[0].body as FormData;
expect(form.get("entry_id")).to.equal("entry1");
expect(form.get("tags")).to.equal("photo");
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");
} finally {
window.fetch = realFetch;
}
});
it("shows the server error and stays open on refusal (e.g. too_early)", async () => {
const { el, sent } = await mount({
completeHandler: () => {
throw { code: "too_early", message: "Task can only be completed closer to its due date" };
},
});
let completedEvent = false;
el.addEventListener("task-completed", () => (completedEvent = true));
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(sent.find((m) => m.type === "maintenance_supporter/task/complete")).to.exist;
// Dialog stays open with a visible error; no completion event fired.
expect(el.shadowRoot!.querySelector("ha-dialog")).to.exist;
const err = el.shadowRoot!.querySelector(".error");
expect(err, "error banner rendered").to.exist;
expect((err!.textContent || "").length).to.be.greaterThan(0);
expect(completedEvent).to.be.false;
});
});
@@ -0,0 +1,164 @@
/**
* Tripwire: NO dialog mounted via dialog-mount may use HA's lazy-loaded
* elements (`ha-textfield`, `ha-textarea`, `ha-entity-picker`) in its
* shadow DOM. These elements aren't reliably registered in the panel-
* custom or Lovelace contexts, render as HTMLUnknownElement with
* offsetHeight=0, and look like an empty form to the user.
*
* History:
* - #50: complete-dialog notes/cost/duration invisible
* → Fix: native <input> with field-input class
* - #50 follow-up: task-dialog target-entity invisible
* → Fix: <ha-form> with entity selector schema
* - #46 follow-up: object-dialog name/manufacturer/model/serial/url/notes
* invisible (only ha-area-picker rendered → user could "only change
* the area")
* → Fix: <ms-textfield> wrapper + native <textarea> for notes
*
* Allowed elements (verified to lazy-load reliably or wrappable):
* - ha-area-picker, ha-form, ha-service-picker, ha-icon, ha-svg-icon,
* ha-button, ha-dialog, ha-icon-button, ha-switch, mwc-button,
* mwc-icon-button, ha-list-item, mwc-list-item
*
* If you need a textfield, use <ms-textfield> (components/ms-textfield.ts).
* If you need an entity picker, use <ha-form> with
* `selector: { entity: {...} }` schema — ha-form lazy-loads its child
* pickers internally + IS itself reliably registered.
*
* This test mounts each dialog component and walks its shadow DOM
* looking for any banned tag. If you add a new dialog, import + mount
* it here too.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/object-dialog.js";
import "../components/task-dialog.js";
import "../components/group-dialog.js";
import "../components/complete-dialog.js";
import "../components/history-edit-dialog.js";
import "../components/qr-dialog.js";
import "../components/seasonal-overrides-dialog.js";
import "../components/object-quick-actions-dialog.js";
import "../components/task-quick-actions-dialog.js";
import "../components/confirm-dialog.js";
import { createMockHass } from "./_test-utils.js";
const BANNED_TAGS = ["ha-textfield", "ha-textarea", "ha-entity-picker"];
function findBannedTags(root: ShadowRoot | Element): string[] {
const banned: string[] = [];
for (const tag of BANNED_TAGS) {
const matches = root.querySelectorAll(tag);
matches.forEach((el) => {
banned.push(`<${tag}> at depth ${depthOf(el, root)} (label="${el.getAttribute("label") || ""}")`);
});
}
return banned;
}
function depthOf(el: Element, root: ShadowRoot | Element): number {
let depth = 0;
let cur: ParentNode | null = el.parentNode;
while (cur && cur !== root) {
depth++;
cur = cur.parentNode;
}
return depth;
}
describe("dialog tripwire: no lazy-loaded HA elements", () => {
it("object-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & { hass: unknown; openEdit: (e: string, o: unknown) => void }>(
html`<maintenance-object-dialog .hass=${hass}></maintenance-object-dialog>`,
);
el.openEdit("entry_x", {
id: "obj_1", name: "Test", area_id: "garage",
manufacturer: "ACME", model: "X1", serial_number: "SN1",
installation_date: "2025-01-01", documentation_url: "https://x.test/",
notes: "test notes",
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `object-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("task-dialog with all feature flags on (worst case = most fields rendered)", async () => {
const { hass } = createMockHass({
services: { button: { press: {} } },
});
const el = await fixture<HTMLElement & {
hass: unknown; openEdit: (e: string, t: unknown) => Promise<void>;
checklistsEnabled: boolean; scheduleTimeEnabled: boolean; completionActionsEnabled: boolean;
}>(
html`<maintenance-task-dialog
.hass=${hass}
.checklistsEnabled=${true}
.scheduleTimeEnabled=${true}
.completionActionsEnabled=${true}
></maintenance-task-dialog>`,
);
await el.openEdit("entry_x", {
id: "t1", name: "Test Task", type: "custom", schedule_type: "time_based",
interval_days: 30, warning_days: 7, enabled: true,
checklist: ["step 1"],
schedule_time: "09:00",
on_complete_action: { service: "button.press", target: { entity_id: "button.x" } },
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
// Expand <details> sections so action UI is in the DOM
el.shadowRoot!.querySelectorAll<HTMLDetailsElement>("details").forEach((d) => { d.open = true; });
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `task-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("group-dialog", async () => {
const { hass } = createMockHass({
handlers: { "maintenance_supporter/objects": () => ({ objects: [] }) },
});
const el = await fixture<HTMLElement & { hass: unknown; openCreate: () => void }>(
html`<maintenance-group-dialog .hass=${hass}></maintenance-group-dialog>`,
);
el.openCreate();
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `group-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("complete-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & {
hass: unknown; entryId: string; taskId: string; taskName: string; open: () => void;
}>(
html`<maintenance-complete-dialog .hass=${hass}></maintenance-complete-dialog>`,
);
el.entryId = "entry_x"; el.taskId = "t1"; el.taskName = "Test";
el.open();
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `complete-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
it("history-edit-dialog", async () => {
const { hass } = createMockHass();
const el = await fixture<HTMLElement & {
hass: unknown; openEdit: (d: unknown) => void;
}>(
html`<maintenance-history-edit-dialog .hass=${hass}></maintenance-history-edit-dialog>`,
);
el.openEdit({
entry_id: "e", task_id: "t", original_timestamp: "2025-01-01T00:00:00",
type: "completed", timestamp: "2025-01-01T00:00:00",
notes: null, cost: null, duration: null, completed_by: null,
});
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
const banned = findBannedTags(el.shadowRoot!);
expect(banned, `history-edit-dialog has banned lazy-load elements: ${banned.join(", ")}`)
.to.have.lengthOf(0);
});
});
@@ -0,0 +1,216 @@
/**
* Lit component tests for <maintenance-documents-section>.
*
* Covers the WS-driven paths (list render, add-link, delete) and the write
* gate. File upload goes through fetch() to the authenticated view and is
* verified live (live_docs.py), not here.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/documents-section.js";
import type { MaintenanceDocumentsSection } from "../components/documents-section";
import { createMockHass } from "./_test-utils.js";
const FILE_DOC = {
id: "d1", kind: "file", title: "Manual", filename: "m.pdf",
mime: "application/pdf", size: 2048, tags: ["manual"], added_at: "2026-01-01T00:00:00",
};
const LINK_DOC = {
id: "d2", kind: "weblink", title: "Online", url: "https://example.com/x",
tags: [], added_at: "2026-01-01T00:00:00",
};
async function mount(canWrite = true, docs: unknown[] = [FILE_DOC, LINK_DOC]) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: docs }),
"maintenance_supporter/documents/add_link": () => ({ id: "new", kind: "weblink", url: "https://x", tags: [] }),
"maintenance_supporter/documents/delete": () => ({ success: true, bytes_freed: 0 }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${canWrite}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el, sent };
}
describe("documents-section", () => {
it("lists documents with a count", async () => {
const { el } = await mount();
const rows = el.shadowRoot!.querySelectorAll(".doc-row");
expect(rows.length).to.equal(2);
expect(el.shadowRoot!.querySelector("h3")!.textContent).to.contain("2");
});
it("offers all six categories when writable", async () => {
const { el } = await mount(true);
const opts = el.shadowRoot!.querySelectorAll(".cat-select option");
expect(opts.length).to.equal(6);
});
it("adds a web-link via WS", async () => {
const { el, sent } = await mount();
const toggle = [...el.shadowRoot!.querySelectorAll("button")].find((b) =>
/link/i.test(b.textContent || ""),
);
toggle!.click();
await el.updateComplete;
const urlInput = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="url"]')!;
urlInput.value = "https://example.com/manual.pdf";
urlInput.dispatchEvent(new Event("input"));
await el.updateComplete;
el.shadowRoot!.querySelector<HTMLButtonElement>(".link-form .btn.primary")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/add_link");
expect(msg, "add_link WS sent").to.exist;
expect(msg!.url).to.equal("https://example.com/manual.pdf");
});
it("deletes a document via WS after confirmation", async () => {
const orig = window.confirm;
window.confirm = () => true;
try {
const { el, sent } = await mount();
el.shadowRoot!.querySelector<HTMLButtonElement>(".icon-btn.danger")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/delete");
expect(msg, "delete WS sent").to.exist;
expect(msg!.doc_id).to.equal("d1");
} finally {
window.confirm = orig;
}
});
it("hides write controls when not writable", async () => {
const { el } = await mount(false);
expect(el.shadowRoot!.querySelector(".cat-select"), "no category select").to.not.exist;
expect(el.shadowRoot!.querySelector(".icon-btn.danger"), "no delete button").to.not.exist;
// read-only still lists documents and offers open/download
expect(el.shadowRoot!.querySelectorAll(".doc-row").length).to.equal(2);
});
it("shows an image thumbnail and opens it in a lightbox", async () => {
const IMG = {
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
};
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const thumb = el.shadowRoot!.querySelector<HTMLImageElement>(".doc-thumb");
expect(thumb, "thumbnail rendered").to.exist;
expect(thumb!.src).to.contain("authSig");
thumb!.click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".lightbox"), "lightbox open").to.exist;
});
it("opens a document by clicking its title row, not just the icons", async () => {
const IMG = {
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
};
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
},
});
const el = await fixture<MaintenanceDocumentsSection>(html`
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
`);
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const info = el.shadowRoot!.querySelector<HTMLElement>(".doc-info");
expect(info, "title row present").to.exist;
expect(info!.getAttribute("role"), "title row is a button").to.equal("button");
info!.click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".lightbox"), "clicking the title opens the preview").to.exist;
});
it("edits a document's title/category via WS", async () => {
const { el, sent } = await mount();
const editBtn = [...el.shadowRoot!.querySelectorAll(".doc-row-actions .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:pencil"]'),
) as HTMLButtonElement;
editBtn.click();
await el.updateComplete;
const title = el.shadowRoot!.querySelector<HTMLInputElement>(".edit-title");
expect(title, "edit form open").to.exist;
title!.value = "Renamed manual";
title!.dispatchEvent(new Event("input"));
await el.updateComplete;
const saveBtn = [...el.shadowRoot!.querySelectorAll(".doc-row.editing .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:check"]'),
) as HTMLButtonElement;
saveBtn.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update");
expect(msg, "update WS sent").to.exist;
expect(msg!.title).to.equal("Renamed manual");
expect(msg!.doc_id).to.equal("d1");
});
it("uploads multiple files and honors the camera category override", async () => {
const { el } = await mount(true, [FILE_DOC]);
(el.hass as unknown as { auth: unknown }).auth = { data: { access_token: "tok" } };
const tags: string[] = [];
const origFetch = window.fetch;
window.fetch = (async (_url: string, opts: { body: FormData }) => {
tags.push(opts.body.get("tags") as string);
return new Response(JSON.stringify({ id: "n", deduped: false }), { status: 200 });
}) as unknown as typeof window.fetch;
const up = (el as unknown as { _uploadFiles: (f: File[], c?: string) => Promise<void> })._uploadFiles;
try {
const f1 = new File(["a"], "a.pdf", { type: "application/pdf" });
const f2 = new File(["b"], "b.pdf", { type: "application/pdf" });
await up.call(el, [f1, f2]); // one POST per file, default category
expect(tags).to.deep.equal(["manual", "manual"]);
tags.length = 0;
await up.call(el, [f1], "photo"); // camera override
expect(tags).to.deep.equal(["photo"]);
} finally {
window.fetch = origFetch;
}
});
it("opens the file picker via keyboard on the upload label (a11y)", async () => {
const { el } = await mount();
const label = [...el.shadowRoot!.querySelectorAll("label.btn")].find(
(l) => l.querySelector('input[type="file"][multiple]'),
) as HTMLElement;
expect(label, "upload label is focusable").to.exist;
expect(label.getAttribute("tabindex")).to.equal("0");
expect(label.getAttribute("role")).to.equal("button");
const input = label.querySelector<HTMLInputElement>('input[type="file"]')!;
let clicked = false;
input.click = () => { clicked = true; };
label.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(clicked, "Enter triggers the hidden file input").to.be.true;
});
});
@@ -0,0 +1,61 @@
/**
* downloadTextFile must produce a Companion-app-safe download: a `target=_blank`
* anchor with the right `download` name, appended to the DOM, and the blob URL
* must NOT be revoked synchronously (an immediate revoke cancels the async
* download in the HA Companion app's WebView).
*/
import { expect } from "@open-wc/testing";
import { downloadTextFile } from "../helpers/download";
describe("downloadTextFile — Companion-app safe download", () => {
let created: number;
let revoked: number;
let captured: { download: string; target: string; href: string } | null;
let origCreate: typeof URL.createObjectURL;
let origRevoke: typeof URL.revokeObjectURL;
let origAppend: typeof document.body.appendChild;
beforeEach(() => {
created = 0;
revoked = 0;
captured = null;
origCreate = URL.createObjectURL;
origRevoke = URL.revokeObjectURL;
origAppend = document.body.appendChild.bind(document.body);
URL.createObjectURL = () => { created++; return "blob:fake-url"; };
URL.revokeObjectURL = () => { revoked++; };
document.body.appendChild = ((node: Node) => {
if (node instanceof HTMLAnchorElement) {
captured = { download: node.download, target: node.target, href: node.href };
node.dispatchEvent = () => true; // suppress real navigation in the test
}
return origAppend(node as never);
}) as typeof document.body.appendChild;
});
afterEach(() => {
URL.createObjectURL = origCreate;
URL.revokeObjectURL = origRevoke;
document.body.appendChild = origAppend;
});
it("uses a target=_blank anchor with the given filename, appended to the DOM", () => {
downloadTextFile("a,b\n1,2", "objects.csv", "text/csv");
expect(created).to.equal(1);
expect(captured).to.not.equal(null);
expect(captured!.download).to.equal("objects.csv");
expect(captured!.target).to.equal("_blank");
expect(captured!.href).to.equal("blob:fake-url");
});
it("does NOT revoke the blob URL synchronously", () => {
downloadTextFile("x", "x.csv", "text/csv");
expect(revoked).to.equal(0);
});
it("cleans up the temporary anchor", () => {
const before = document.body.querySelectorAll("a").length;
downloadTextFile("x", "x.csv", "text/csv");
expect(document.body.querySelectorAll("a").length).to.equal(before);
});
});
@@ -0,0 +1,39 @@
/** Tests for the chart outlier filter (IQR fence). */
import { expect } from "@open-wc/testing";
import { filterOutliers } from "../renderers/sparkline.js";
import type { ChartPoint } from "../components/trigger-chart";
const pts = (vals: number[]): ChartPoint[] =>
vals.map((val, i) => ({ ts: i * 1000, val }));
describe("filterOutliers", () => {
it("drops a wild glitch reading (pressure 1.53 → 100)", () => {
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 100, 1.7, 2.3, 1.5]);
const out = filterOutliers(input);
expect(out.map((p) => p.val)).to.not.include(100);
expect(out.length).to.equal(input.length - 1);
});
it("keeps a normal spread untouched", () => {
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 1.7, 2.3, 1.5, 2.0]);
expect(filterOutliers(input).length).to.equal(input.length);
});
it("no-ops on short series (< 4 points)", () => {
const input = pts([1, 100, 2]);
expect(filterOutliers(input).length).to.equal(3);
});
it("never strips below a drawable series", () => {
// Two extreme values, everything else identical → IQR fence could nuke both
// ends, but we must keep at least 2 points.
const input = pts([5, 5, 5, 5, 5, 5, 999, -999]);
expect(filterOutliers(input).length).to.be.greaterThan(1);
});
it("returns the series unchanged when all values are identical (IQR 0)", () => {
const input = pts([3, 3, 3, 3, 3]);
expect(filterOutliers(input).length).to.equal(5);
});
});
@@ -0,0 +1,66 @@
/** Unit tests for formatRecurrence — the single recurrence label for every
* schedule kind (Phase 4 calendar kinds). Weekday names come from Intl. */
import { expect } from "@open-wc/testing";
import { formatRecurrence, setLocale } from "../styles";
import de from "../locales/de.json";
describe("formatRecurrence", () => {
// German strings are runtime-loaded (not bundled); seed the real table so the
// localized-ordinal case exercises actual German, not the EN fallback.
before(() => setLocale("de", de as Record<string, string>));
it("interval → '6 Months'", () => {
expect(formatRecurrence({ schedule: { kind: "interval", every: 6, unit: "months" } }, "en")).to.equal("6 Months");
});
it("nth_weekday → '1st Saturday'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "en")).to.equal("1st Saturday");
});
it("nth_weekday last → 'Last Saturday'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: -1, weekday: 5 } }, "en")).to.equal("Last Saturday");
});
it("weekdays → 'Mon & Thu'", () => {
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [0, 3] } }, "en")).to.equal("Mon & Thu");
});
it("day_of_month → 'Day 15'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: 15 } }, "en")).to.equal("Day 15");
});
it("(#83) day -1 → 'Last day of month'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1 } }, "en"))
.to.equal("Last day of month");
});
it("(#83) day -1 + business → 'Last business day'", () => {
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1, business: true } }, "en"))
.to.equal("Last business day");
});
it("(#83) negative offset renders as a Nd suffix", () => {
expect(formatRecurrence(
{ schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 } }, "en",
)).to.equal("Last business day 2d");
});
it("(#83) positive offset on nth_weekday", () => {
expect(formatRecurrence(
{ schedule: { kind: "nth_weekday", nth: 1, weekday: 5, offset: 2 } }, "en",
)).to.equal("1st Saturday +2d");
});
it("manual → 'Manual'", () => {
expect(formatRecurrence({ schedule: { kind: "manual" } }, "en")).to.equal("Manual");
});
it("one_time → due date", () => {
expect(formatRecurrence({ schedule: { kind: "one_time" }, due_date: "2026-09-01" }, "en")).to.contain("2026");
});
it("legacy flat interval (no nested schedule) → '30 Days'", () => {
expect(formatRecurrence({ interval_days: 30, interval_unit: "days", schedule_type: "time_based" }, "en")).to.equal("30 Days");
});
it("German nth_weekday → '1. Samstag'", () => {
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "de")).to.equal("1. Samstag");
});
it("empty weekdays → em dash", () => {
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [] } }, "en")).to.equal("—");
});
it("sensor_based (no schedule) → 'Sensor-based', not em dash", () => {
expect(
formatRecurrence({ schedule_type: "sensor_based", interval_days: null }, "en"),
).to.equal("Sensor-based");
});
});
@@ -0,0 +1,109 @@
/**
* Component tests for the group-dialog task list (#40 sort fix, v1.0.53).
*
* Pins:
* - Object sections render alphabetically (not in `.objects` array order)
* - Tasks within each object render alphabetically
* - Toggling a checkbox updates the internal Set with the right
* "entry_id:task_id" composite key
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/group-dialog.js";
import type { MaintenanceGroupDialog } from "../components/group-dialog";
function mockObjects() {
// Deliberately given in non-alphabetical creation order.
return [
{
entry_id: "e3",
object: { id: "o3", name: "Zenith Compressor" },
tasks: [
{ id: "t31", name: "Tighten bolts" },
{ id: "t32", name: "Air filter swap" },
],
},
{
entry_id: "e1",
object: { id: "o1", name: "Aqua Pool" },
tasks: [
{ id: "t11", name: "pH check" },
{ id: "t12", name: "Brush walls" },
{ id: "t13", name: "Add chlorine" },
],
},
{
entry_id: "e2",
object: { id: "o2", name: "Mid Garage" },
tasks: [
{ id: "t21", name: "Sweep floor" },
],
},
];
}
async function mount() {
const hass = { language: "en", connection: { sendMessagePromise: async () => ({}) } };
const el = await fixture<MaintenanceGroupDialog>(html`
<maintenance-group-dialog .hass=${hass} .objects=${mockObjects()}></maintenance-group-dialog>
`);
el.openCreate();
await el.updateComplete;
return el;
}
describe("group-dialog task list (#40 alphabetical sort)", () => {
it("renders object sections alphabetically by object name", async () => {
const el = await mount();
const sections = el.shadowRoot!.querySelectorAll(".object-block .object-name");
const names = [...sections].map(n => n.textContent?.trim() || "");
expect(names, "object sections sorted A→Z").to.deep.equal([
"Aqua Pool",
"Mid Garage",
"Zenith Compressor",
]);
});
it("renders tasks within each object alphabetically", async () => {
const el = await mount();
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
// Aqua Pool block (first after sort) should have tasks A→Z
const aquaTasks = blocks[0].querySelectorAll(".task-row span");
const taskNames = [...aquaTasks].map(s => s.textContent?.trim() || "");
expect(taskNames, "Aqua Pool tasks A→Z").to.deep.equal([
"Add chlorine",
"Brush walls",
"pH check",
]);
});
it("toggles a checkbox and stores the composite entry:task key", async () => {
const el = await mount();
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
const firstCheckbox = blocks[0].querySelector<HTMLInputElement>(".task-row input[type=checkbox]")!;
firstCheckbox.checked = true;
firstCheckbox.dispatchEvent(new Event("change"));
await el.updateComplete;
// First sorted object is Aqua Pool (e1); first sorted task is "Add chlorine" (t13)
// Internal _selected uses entry_id:task_id. Inspect via the rendered count.
const countEl = el.shadowRoot!.querySelector(".selected-count");
expect(countEl?.textContent || "").to.match(/1\b/);
});
it("checkbox state survives object re-render via property update", async () => {
const el = await mount();
const blocks1 = el.shadowRoot!.querySelectorAll(".object-block");
const cb = blocks1[0].querySelector<HTMLInputElement>(".task-row input")!;
cb.checked = true;
cb.dispatchEvent(new Event("change"));
await el.updateComplete;
// Force a re-render with the same objects (ordering invariant).
el.objects = [...mockObjects()];
await el.updateComplete;
const cbAgain = el.shadowRoot!.querySelectorAll(".object-block")[0]
.querySelector<HTMLInputElement>(".task-row input")!;
expect(cbAgain.checked, "checked state preserved across re-render").to.be.true;
});
});
@@ -0,0 +1,63 @@
/**
* i18n runtime-loader guard.
*
* The panel/card UI strings used to be one big inline `TRANSLATIONS` object in
* styles.ts. They now live in `frontend-src/locales/<lang>.json`: only English
* is bundled (imported by styles.ts) as the always-available fallback; the other
* 12 languages are fetched at runtime from `/maintenance_supporter_locales/` —
* so a translation edit needs no bundle rebuild (the stale-bundle pitfall fix).
*
* Cross-locale KEY PARITY is guarded in Python (`tests/test_i18n.py` reads every
* `frontend-src/locales/*.json`). This browser test guards the runtime LOADER
* behaviour that replaced the inline tables: bundled-EN, English fallback, and
* the no-fetch fast paths of ensureLocale/isLocaleLoaded.
*/
import { expect } from "@open-wc/testing";
import { t, isLocaleLoaded, ensureLocale } from "../styles";
describe("i18n runtime loader", () => {
it("serves bundled English synchronously", () => {
// A key known to exist in en.json resolves with no fetch and isn't the key.
expect(t("loading", "en")).to.be.a("string").and.not.equal("loading");
});
it("falls back to English for an unloaded language, then to the key", () => {
// German isn't bundled; before load an EN-present key falls back to EN…
expect(t("loading", "de")).to.equal(t("loading", "en"));
// …and an unknown key returns the key itself.
expect(t("__nonexistent_key__", "de")).to.equal("__nonexistent_key__");
});
it("treats English (and its regional variants) as always loaded", () => {
expect(isLocaleLoaded("en")).to.be.true;
expect(isLocaleLoaded("en-GB")).to.be.true; // normalises to "en"
expect(isLocaleLoaded("de")).to.be.false; // not fetched yet
});
it("ensureLocale resolves immediately for English and unsupported langs", async () => {
await ensureLocale("en"); // bundled → no-op
await ensureLocale("xx"); // unsupported → no fetch, stays on English
expect(isLocaleLoaded("xx")).to.be.false;
});
it("shares the locale store across bundle copies via window (German-panel/English-dialog regression)", () => {
// maintenance-card.js is loaded on EVERY page (extra_module_url) and its
// custom-element definitions win first — so a dialog inside the panel
// executes the CARD bundle's copy of styles.ts. If each copy had its own
// module-scoped store, the panel's locale load would never reach the
// dialog: German panel, English "Edit Task" dialog (the v2.17.0 bug).
// Guard: the store is window-scoped, so writing to the global (as another
// bundle copy would) is immediately visible to this copy's t().
const g = (window as unknown as {
__msLocales?: { store: Record<string, Record<string, string>> };
}).__msLocales;
expect(g, "window.__msLocales must back the locale store").to.exist;
g!.store.pt = { loading: "A carregar (from another bundle copy)" };
try {
expect(t("loading", "pt")).to.equal("A carregar (from another bundle copy)");
expect(isLocaleLoaded("pt")).to.be.true;
} finally {
delete g!.store.pt;
}
});
});
@@ -0,0 +1,47 @@
/**
* Unit-aware interval math (issue #59) — progress bars + calendar projection
* must treat weeks/months/years as their real day-span, not the raw count.
*/
import { expect } from "@open-wc/testing";
import { intervalSpanDays, daysProgress } from "../helpers/interval";
describe("intervalSpanDays (#59)", () => {
it("days = raw count", () => expect(intervalSpanDays(7, "days")).to.equal(7));
it("weeks × 7", () => expect(intervalSpanDays(2, "weeks")).to.equal(14));
it("months ≈ 30.44/mo", () =>
expect(intervalSpanDays(3, "months")).to.be.closeTo(91.3, 0.5));
it("years ≈ 365.25", () =>
expect(intervalSpanDays(1, "years")).to.be.closeTo(365.25, 0.01));
it("missing unit defaults to days", () =>
expect(intervalSpanDays(5)).to.equal(5));
it("zero / null → 0", () => {
expect(intervalSpanDays(0, "years")).to.equal(0);
expect(intervalSpanDays(null, "years")).to.equal(0);
});
});
describe("daysProgress (#59)", () => {
it("yearly task halfway → ~50% (pre-fix clamped to 0)", () => {
const p = daysProgress(1, 182, "years");
expect(p.pct).to.be.closeTo(50, 1);
expect(p.overflow).to.equal(false);
});
it("yearly task overdue → 100% + overflow", () => {
const p = daysProgress(1, -10, "years");
expect(p.pct).to.equal(100);
expect(p.overflow).to.equal(true);
});
it("days unit unchanged: just performed → 0%", () =>
expect(daysProgress(30, 30, "days").pct).to.equal(0));
it("days unit: due today → 100%", () =>
expect(daysProgress(30, 0, "days").pct).to.equal(100));
it("monthly task: 1 of ~30 days elapsed → small %", () => {
const p = daysProgress(1, 29, "months");
expect(p.pct).to.be.greaterThan(0);
expect(p.pct).to.be.lessThan(15);
});
it("no interval / null countdown → 0", () => {
expect(daysProgress(null, 5, "days").pct).to.equal(0);
expect(daysProgress(30, null, "days").pct).to.equal(0);
});
});
@@ -0,0 +1,85 @@
/**
* Tripwire: the strategy's document-level `ll-custom` handler must accept BOTH
* payload shapes that reach it in production.
*
* 1. HA's `tap_action: { action: "fire-dom-event", ll_custom: {...} }` path
* (the empty-state "Add object" button) dispatches `ll-custom` with the
* WHOLE action config as the event detail — our payload nests under
* `.ll_custom`. This is the shape that broke in issue #69: the handler
* read `detail.type` (undefined) and silently did nothing.
*
* 2. Direct dispatchers — the calendar card and the panel — put the payload
* at the TOP level of the detail (`{ type, entry_id, task_id }`).
*
* Both must open the create-object dialog. The pre-#69 verifier only exercised
* shape 2, which is why the regression shipped. If you change how the handler
* reads the payload, keep both green.
*/
import { expect } from "@open-wc/testing";
import { createMockHass } from "./_test-utils.js";
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
// A fake <home-assistant> with a mock hass so dialog-mount's getHass() succeeds
// and openCreateObjectDialog() returns true (no deep-link fallback / no URL
// mutation of the test page).
let haRoot: HTMLElement & { hass?: unknown };
before(async () => {
const { hass } = createMockHass();
haRoot = document.createElement("home-assistant") as HTMLElement & { hass?: unknown };
haRoot.hass = hass;
document.body.appendChild(haRoot);
// Import for its side effect: registers the document-level ll-custom handler.
await import("../maintenance-dashboard-strategy.js");
});
after(() => {
haRoot?.remove();
});
afterEach(() => {
document.body.querySelector(OBJECT_DIALOG_TAG)?.remove();
});
async function fireAndWait(detail: unknown): Promise<HTMLElement | null> {
document.dispatchEvent(
new CustomEvent("ll-custom", { detail, bubbles: true, composed: true }),
);
// handler imports dialog-mount.ts dynamically, then mounts the dialog
for (let i = 0; i < 40; i++) {
const dlg = document.body.querySelector<HTMLElement>(OBJECT_DIALOG_TAG);
if (dlg) return dlg;
await tick(25);
}
return null;
}
describe("ll-custom handler payload shape (#69)", () => {
it("opens the object dialog for HA's nested fire-dom-event shape", async () => {
const dlg = await fireAndWait({
action: "fire-dom-event",
ll_custom: { type: "maintenance-supporter:add-object" },
});
expect(dlg, "nested ll_custom payload must reach the add-object handler").to.not.equal(null);
});
it("opens the object dialog for the top-level (calendar/panel) shape", async () => {
const dlg = await fireAndWait({ type: "maintenance-supporter:add-object" });
expect(dlg, "top-level payload must still work (calendar card / panel)").to.not.equal(null);
});
it("ignores ll-custom events for other namespaces", async () => {
document.dispatchEvent(
new CustomEvent("ll-custom", {
detail: { action: "fire-dom-event", ll_custom: { type: "browser_mod:foo" } },
bubbles: true,
composed: true,
}),
);
await tick(150);
expect(document.body.querySelector(OBJECT_DIALOG_TAG)).to.equal(null);
});
});
@@ -0,0 +1,54 @@
/**
* Objects-table column catalog + sanitiser (#67). Mirrors the backend
* sanitise so the panel and Settings UI agree on what's valid.
*/
import { expect } from "@open-wc/testing";
import {
sanitizeColumns,
DEFAULT_OBJECTS_TABLE_COLUMNS,
KNOWN_OBJECT_COLUMNS,
OBJECT_COLUMNS,
} from "../helpers/object-columns";
describe("sanitizeColumns (#67)", () => {
it("non-array → defaults", () => {
expect(sanitizeColumns(undefined)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns(null)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns("name")).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
});
it("empty / all-unknown → defaults", () => {
expect(sanitizeColumns([])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
expect(sanitizeColumns(["nope", 7, null])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
});
it("drops unknown keys, preserves caller order", () => {
expect(sanitizeColumns(["name", "bogus", "warranty_expiry"]))
.to.deep.equal(["name", "warranty_expiry"]);
});
it("dedupes", () => {
expect(sanitizeColumns(["name", "name", "model"]))
.to.deep.equal(["name", "model"]);
});
it("prepends the required name column when missing", () => {
expect(sanitizeColumns(["warranty_expiry", "model"]))
.to.deep.equal(["name", "warranty_expiry", "model"]);
});
it("accepts a full custom subset unchanged", () => {
const cols = ["name", "warranty_expiry", "actions"];
expect(sanitizeColumns(cols)).to.deep.equal(cols);
});
it("catalog is internally consistent", () => {
expect(KNOWN_OBJECT_COLUMNS.length).to.equal(OBJECT_COLUMNS.length);
for (const c of OBJECT_COLUMNS) {
expect(c.labelKey, c.key).to.be.a("string").and.not.equal("");
}
for (const k of DEFAULT_OBJECTS_TABLE_COLUMNS) {
expect(KNOWN_OBJECT_COLUMNS, k).to.include(k);
}
});
});
@@ -0,0 +1,111 @@
/**
* QR deep-link routing tests (audit gap #9).
*
* A scanned QR code lands on the panel URL with ?entry_id&task_id&action=…
* — until now only the URL *building* was tested, never the handling. These
* tests pin the scan-to-complete story at the routing layer:
* - action=complete opens the pre-targeted complete dialog on the task view
* - action=quick_complete fires task/quick_complete silently
* - a no_defaults refusal falls back to the normal complete dialog
* - params are consumed once (cleaned from the URL)
* - an unknown entry_id lands safely on the overview
*/
import { expect } from "@open-wc/testing";
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
function setDeepLink(query: string) {
history.replaceState(null, "", `${window.location.pathname}?${query}`);
}
async function settleRaf(el: { updateComplete: Promise<unknown> }) {
// Deep-link dialog opens behind a requestAnimationFrame.
await new Promise((r) => requestAnimationFrame(() => r(null)));
await new Promise((r) => setTimeout(r, 20));
await (el as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
}
function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null {
return sr(el).querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
}
describe("panel deep links (QR scan routing)", () => {
beforeEach(() => {
resetTaskSeq();
localStorage.clear();
localStorage.setItem("msp-overview-tab", "dashboard");
});
afterEach(() => {
localStorage.clear();
history.replaceState(null, "", window.location.pathname);
});
it("?action=complete lands on the task and opens the pre-targeted dialog", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=complete");
const { el } = await mountPanel([
obj("e1", [task({ name: "Scan Me" })]),
]);
await settleRaf(el);
// Landed on the task detail…
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent).to.include("Scan Me");
// …with the complete dialog open and targeted at the scanned task.
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "complete dialog open").to.exist;
expect(dlg.entryId).to.equal("e1");
expect(dlg.taskId).to.equal("t1");
expect(dlg.taskName).to.equal("Scan Me");
// Params are consumed once: the URL is cleaned.
expect(window.location.search).to.equal("");
});
it("?action=quick_complete fires task/quick_complete silently", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "Quick" })])],
{ "maintenance_supporter/task/quick_complete": () => ({ success: true, via: "quick" }) },
);
await settleRaf(el);
const quick = sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete");
expect(quick.length).to.equal(1);
expect(quick[0].entry_id).to.equal("e1");
expect(quick[0].task_id).to.equal("t1");
// Silent path: no dialog opened.
const dlg = completeDialog(el);
expect(dlg?.shadowRoot?.querySelector("ha-dialog") ?? null).to.be.null;
});
it("quick_complete without defaults falls back to the complete dialog", async () => {
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
const { el, sent } = await mountPanel(
[obj("e1", [task({ name: "No Defaults" })])],
{
"maintenance_supporter/task/quick_complete": () => {
throw { code: "no_defaults", message: "open the dialog instead" };
},
},
);
await settleRaf(el);
await settleRaf(el); // fallback opens after the rejected promise settles
expect(
sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete").length,
).to.equal(1);
const dlg = completeDialog(el)!;
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
expect(dlg.taskName).to.equal("No Defaults");
});
it("an unknown entry_id lands safely on the overview", async () => {
setDeepLink("entry_id=does-not-exist&task_id=t1&action=complete");
const { el } = await mountPanel([obj("e1", [task({ name: "Real" })])]);
await settleRaf(el);
expect(sr(el).querySelector(".task-header")).to.be.null;
expect(sr(el).querySelector(".task-table"), "overview dashboard rendered").to.exist;
expect(window.location.search).to.equal("");
});
});
@@ -0,0 +1,188 @@
import { expect } from "@open-wc/testing";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
describe("panel shell", () => {
beforeEach(() => {
resetTaskSeq();
localStorage.clear();
localStorage.setItem("msp-overview-tab", "dashboard");
});
afterEach(() => localStorage.clear());
it("bulk select-all covers visible rows only and bulk Complete sends one call per selection", async () => {
const { el, sent } = await mountPanel([
obj("e1", [
task({ name: "Active A" }),
task({ name: "Active B" }),
task({ name: "Active C" }),
task({ name: "Gone", archived: true }),
]),
]);
// Enter bulk mode.
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
await el.updateComplete;
expect(sr(el).querySelector(".bulk-bar"), "bulk bar visible").to.exist;
// Select all → exactly the 3 visible (non-archived) rows get checkboxes.
sr(el).querySelector<HTMLInputElement>(".bulk-selectall input")!.click();
await el.updateComplete;
const checked = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")]
.filter((c) => c.checked);
expect(checked.length).to.equal(3);
// Bulk Complete → one task/complete per selected task, none for archived.
const completeBtn = sr(el).querySelector<HTMLElement>(".bulk-actions ha-button")!;
completeBtn.click();
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const completes = sent.filter((m) => m.type === "maintenance_supporter/task/complete");
expect(completes.length).to.equal(3);
expect(new Set(completes.map((m) => m.task_id))).to.deep.equal(
new Set(["t1", "t2", "t3"]),
);
// Bulk mode exits after the action.
expect(sr(el).querySelector(".bulk-bar")).to.be.null;
});
it("bulk Archive sends task/archive for the manually selected rows only", async () => {
const { el, sent } = await mountPanel([
obj("e1", [task({ name: "One" }), task({ name: "Two" }), task({ name: "Three" })]),
]);
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
await el.updateComplete;
// Tick rows 1 and 3 via their row checkboxes.
const boxes = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")];
boxes[0].click();
boxes[2].click();
await el.updateComplete;
// Second bulk action button is Archive.
const actions = [...sr(el).querySelectorAll<HTMLElement>(".bulk-actions ha-button")];
actions[1].click();
await new Promise((r) => setTimeout(r, 40));
await el.updateComplete;
const archives = sent.filter((m) => m.type === "maintenance_supporter/task/archive");
expect(archives.length).to.equal(2);
expect(new Set(archives.map((m) => m.task_id))).to.deep.equal(new Set(["t1", "t3"]));
expect(sent.filter((m) => m.type === "maintenance_supporter/task/complete").length)
.to.equal(0);
});
it("'/' opens the command palette, filters, and navigates to the task", async () => {
const { el } = await mountPanel([
obj("e1", [task({ name: "Filter Wechsel" }), task({ name: "Pumpe prüfen" })]),
]);
// Ctrl+K must NOT open it — that's HA's own global-search hotkey and the
// panel used to shadow it (changed in 2.18.1).
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
await el.updateComplete;
expect(sr(el).querySelector(".palette-input"), "Ctrl+K stays HA's").to.be.null;
window.dispatchEvent(new KeyboardEvent("keydown", { key: "/" }));
await el.updateComplete;
const input = sr(el).querySelector<HTMLInputElement>(".palette-input");
expect(input, "palette opened").to.exist;
input!.value = "Filter";
input!.dispatchEvent(new Event("input"));
await el.updateComplete;
const results = [...sr(el).querySelectorAll(".palette-results .palette-label")]
.map((r) => r.textContent?.trim());
expect(results).to.include("Filter Wechsel");
expect(results).to.not.include("Pumpe prüfen");
// Click the task result → task detail renders.
const hit = [...sr(el).querySelectorAll<HTMLElement>(".palette-results > *")]
.find((r) => /Filter Wechsel/.test(r.textContent || ""))!;
hit.click();
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
expect(sr(el).querySelector(".palette-input"), "palette closed").to.be.null;
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent)
.to.include("Filter Wechsel");
});
it("'/' while typing in a text field does not open the palette", async () => {
const { el } = await mountPanel([
obj("e1", [task({ name: "Filter Wechsel" })]),
]);
const field = document.createElement("input");
document.body.appendChild(field);
try {
field.dispatchEvent(
new KeyboardEvent("keydown", { key: "/", bubbles: true, composed: true }),
);
await el.updateComplete;
expect(sr(el).querySelector(".palette-input")).to.be.null;
} finally {
field.remove();
}
});
it("Today view buckets overdue / due-today / this-week and hides later tasks", async () => {
localStorage.setItem("msp-overview-tab", "today");
const { el } = await mountPanel([
obj("e1", [
task({ name: "Late", status: "overdue", days_until_due: -3 }),
task({ name: "Now", status: "due_soon", days_until_due: 0 }),
task({ name: "Soon", status: "due_soon", days_until_due: 3 }),
task({ name: "Later", status: "ok", days_until_due: 20 }),
]),
]);
const view = sr(el).querySelector(".today-view");
expect(view, "today view rendered").to.exist;
const sections = [...sr(el).querySelectorAll(".today-section")];
const byHeader = (re: RegExp) =>
sections.find((s) => re.test(s.querySelector(".today-section-header")!.textContent || ""));
const textOf = (s: Element | undefined) =>
[...(s?.querySelectorAll(".today-task") || [])].map((t2) => t2.textContent?.trim());
const all = sections.flatMap((s) => textOf(s));
expect(all).to.include("Late");
expect(all).to.include("Now");
expect(all).to.include("Soon");
expect(all).to.not.include("Later");
// Overdue section leads with the late task.
const overdueSection = byHeader(/overdue|überfällig/i);
expect(textOf(overdueSection)).to.include("Late");
});
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 }),
);
const { el } = await mountPanel([obj("e1", many)]);
const table = sr(el).querySelector(".task-table");
expect(table, "table rendered").to.exist;
expect(table!.classList.contains("virtual"), "virtual mode active").to.be.true;
const domRows = () =>
[...sr(el).querySelectorAll(".task-table .task-row:not(.virt-sizer)")];
expect(domRows().length).to.be.lessThan(120);
expect(domRows().length).to.be.greaterThan(5);
// Scroll the content container → the window shifts and a top spacer grows.
const content = sr(el).querySelector<HTMLElement>(".content")!;
content.scrollTop = 3000;
content.dispatchEvent(new Event("scroll"));
await new Promise((r) => requestAnimationFrame(() => r(null)));
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const spacer = sr(el).querySelector<HTMLElement>(".task-table .virt-spacer");
expect(spacer, "top spacer present after scroll").to.exist;
expect(parseInt(spacer!.style.height, 10)).to.be.greaterThan(0);
const firstName = domRows()[0]?.querySelector(".task-name")?.textContent?.trim();
expect(firstName).to.not.equal("Bulk 000");
});
});
@@ -0,0 +1,97 @@
/**
* Lit component tests for the notify-target picker (datalist) in the general
* section of <maintenance-settings-view>.
*
* The pickable-target LIST is computed server-side by build_notify_targets and
* arrives as `settings.general.notify_targets` (merging legacy notify services +
* notify entities, minus the generic send_message, plus the saved value). The
* panel's only job is to render that list into a <datalist> while keeping the
* input free-text. The merge logic itself is covered by the Python tests for
* build_notify_targets — here we only assert the panel mirrors what it's given.
* See settings-view.ts::_renderGeneral.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import {
DEFAULT_FEATURES,
DEFAULT_SETTINGS_RESPONSE,
createMockHass,
} from "./_test-utils.js";
async function mount(notifyTargets?: string[]): Promise<MaintenanceSettingsView> {
const { hass } = createMockHass({
settingsResponse: {
...DEFAULT_SETTINGS_RESPONSE,
general: {
...DEFAULT_SETTINGS_RESPONSE.general,
notifications_enabled: true,
notify_service: "notify.mobile_app_phone",
...(notifyTargets !== undefined ? { notify_targets: notifyTargets } : {}),
},
},
});
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
// _loadSettings is kicked off by updated() after first render.
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
return el;
}
function options(el: MaintenanceSettingsView): string[] {
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
return Array.from(list?.querySelectorAll("option") ?? []).map((o) => o.value);
}
describe("settings-view notify-target picker", () => {
it("renders the server-provided notify_targets verbatim as datalist options", async () => {
const el = await mount([
"notify.mobile_app_phone",
"notify.all_devices_group",
"notify.file",
]);
const opts = options(el);
expect(opts).to.include("notify.mobile_app_phone");
expect(opts).to.include("notify.all_devices_group");
expect(opts).to.include("notify.file");
expect(opts).to.have.lengthOf(3);
});
it("degrades to an empty datalist when no notify targets are provided", async () => {
const el = await mount([]);
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
expect(list, "datalist still present").to.exist;
expect(list!.querySelectorAll("option").length, "no options").to.equal(0);
// Free-text input still renders so notifications can be configured.
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
'input[list="ms-notify-services"]',
);
expect(input, "free-text input still present").to.exist;
});
it("degrades gracefully when an older backend omits notify_targets", async () => {
// notify_targets absent entirely (undefined) → no options, no crash.
const { hass } = createMockHass({
settingsResponse: {
...DEFAULT_SETTINGS_RESPONSE,
general: {
default_warning_days: 7,
notifications_enabled: true,
notify_service: "notify.mobile_app_phone",
panel_enabled: false,
// notify_targets deliberately omitted
} as unknown as (typeof DEFAULT_SETTINGS_RESPONSE)["general"],
},
});
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
expect(options(el), "no options when omitted").to.have.lengthOf(0);
});
});
@@ -0,0 +1,182 @@
/**
* Lit component tests for the Print QR section of <maintenance-settings-view>.
*
* Covers v1.1.0 batch-QR-print UI: load-objects flow, action chip toggles,
* generate button enable/disable, results rendering.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import { DEFAULT_FEATURES, createMockHass } from "./_test-utils.js";
function mockHass(opts: {
objects?: Array<{ entry_id: string; name: string; task_count: number }>;
batchResult?: Array<{ task_id: string; entry_id: string; object_name: string; task_name: string; action: string; svg: string }>;
} = {}) {
const objects = opts.objects ?? [
{ entry_id: "e1", name: "Pool Pump", task_count: 3 },
{ entry_id: "e2", name: "HVAC", task_count: 2 },
];
return createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({
objects: objects.map(o => ({
entry_id: o.entry_id,
object: { name: o.name },
tasks: Array.from({ length: o.task_count }, (_, i) => ({
id: `${o.entry_id}_t${i}`, name: `Task ${i}`,
})),
})),
}),
"maintenance_supporter/qr/batch_generate": () => ({
qrs: opts.batchResult ?? [
{
entry_id: "e1", task_id: "e1_t0",
object_name: "Pool Pump", task_name: "Task 0",
action: "view", svg: "<svg><rect/></svg>",
},
],
total: 1,
}),
},
});
}
async function mount(opts = {}) {
const { hass, sent } = mockHass(opts);
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
await new Promise(r => setTimeout(r, 50));
await el.updateComplete;
return { el, sent };
}
function qrSection(el: MaintenanceSettingsView): HTMLElement | null {
return el.shadowRoot?.querySelector(".qr-print-section") || null;
}
describe("settings-view print QR section", () => {
it("renders the section with a Load objects button by default", async () => {
const { el } = await mount();
const section = qrSection(el);
expect(section, "section exists").to.exist;
const buttons = section!.querySelectorAll("button");
const loadBtn = [...buttons].find(b => /load/i.test(b.textContent || ""));
expect(loadBtn, "Load objects button present").to.exist;
});
it("loads objects on click and renders the filter panel", async () => {
const { el, sent } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const objCalled = sent.some(m => m.type === "maintenance_supporter/objects");
expect(objCalled, "objects WS called").to.be.true;
const filterPanel = qrSection(el)!.querySelector(".qr-filter-panel");
expect(filterPanel, "filter panel rendered after load").to.exist;
const objectRows = qrSection(el)!.querySelectorAll(".qr-object-row");
expect(objectRows.length, "object rows match mock count").to.equal(2);
});
it("toggles an action chip and updates active class", async () => {
const { el } = await mount();
// Trigger load to render the chips
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const chips = qrSection(el)!.querySelectorAll<HTMLElement>(".qr-action-chip");
expect(chips.length, "three action chips rendered").to.equal(3);
// Default: only "view" active
const viewChip = [...chips].find(c => /view|anzeigen/i.test(c.textContent || ""))!;
expect(viewChip.classList.contains("active"), "view chip active by default").to.be.true;
// Toggle "complete" on
const completeChip = [...chips].find(c => /complete|erledigen/i.test(c.textContent || ""))!;
const completeInput = completeChip.querySelector<HTMLInputElement>("input")!;
completeInput.checked = true;
completeInput.dispatchEvent(new Event("change"));
await el.updateComplete;
expect(completeChip.classList.contains("active"), "complete chip flipped active").to.be.true;
});
it("disables Generate button when no actions selected", async () => {
const { el } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
// Toggle the default "view" off
const viewChipInput = [...qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input")]
.find(i => i.checked)!;
viewChipInput.checked = false;
viewChipInput.dispatchEvent(new Event("change"));
await el.updateComplete;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
expect(genBtn.disabled, "Generate disabled when 0 actions").to.be.true;
});
it("renders a batch result row after Generate", async () => {
const { el, sent } = await mount();
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
genBtn.click();
await new Promise(r => setTimeout(r, 50));
await el.updateComplete;
const batchCalled = sent.some(m => m.type === "maintenance_supporter/qr/batch_generate");
expect(batchCalled, "batch_generate WS called").to.be.true;
const cells = qrSection(el)!.querySelectorAll(".qr-print-cell");
expect(cells.length, "one result cell rendered").to.equal(1);
const labelObj = cells[0].querySelector(".qr-label-obj");
expect(labelObj?.textContent).to.equal("Pool Pump");
});
it("warns over-limit when filter would produce > 200 QRs", async () => {
// 100 objects with 3 actions = 300 QRs → over the 200 cap.
const manyObjects = Array.from({ length: 101 }, (_, i) => ({
entry_id: `e${i}`, name: `Obj ${i}`, task_count: 1,
}));
const { el } = await mount({ objects: manyObjects });
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
.find(b => /load/i.test(b.textContent || ""))!;
loadBtn.click();
await new Promise(r => setTimeout(r, 30));
await el.updateComplete;
// Toggle on all 3 actions
const chips = qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input");
for (const chip of chips) {
if (!chip.checked) {
chip.checked = true;
chip.dispatchEvent(new Event("change"));
}
}
await el.updateComplete;
const estimate = qrSection(el)!.querySelector(".qr-estimate");
expect(estimate?.classList.contains("error"), "estimate shows error class").to.be.true;
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
expect(genBtn.disabled, "Generate disabled at over-limit").to.be.true;
});
});
@@ -0,0 +1,147 @@
/**
* Lit component tests for the vacation section of <maintenance-settings-view>.
*
* Mounts the component with a mocked `hass` (just a connection stub that
* captures sendMessagePromise calls) and asserts on rendered output +
* outgoing WS messages. No HA shell, no shadow-DOM-deep-piercing —
* runs in real Chromium via @web/test-runner.
*/
import { expect, fixture, html, oneEvent } from "@open-wc/testing";
import "../components/settings-view.js";
import type { MaintenanceSettingsView } from "../components/settings-view";
import {
DEFAULT_FEATURES,
DEFAULT_SETTINGS_RESPONSE,
createMockHass,
} from "./_test-utils.js";
function mockHass(opts: {
vacationActive?: boolean;
vacationStart?: string | null;
vacationEnd?: string | null;
exemptIds?: string[];
} = {}) {
const settingsResponse = {
...DEFAULT_SETTINGS_RESPONSE,
vacation: {
...DEFAULT_SETTINGS_RESPONSE.vacation,
enabled: opts.vacationActive ?? false,
start: opts.vacationStart ?? null,
end: opts.vacationEnd ?? null,
exempt_task_ids: opts.exemptIds ?? [],
is_active: opts.vacationActive ?? false,
window_end: opts.vacationEnd ?? null,
},
};
return createMockHass({
settingsResponse,
handlers: {
"maintenance_supporter/vacation/update": (msg) => ({
// Echo the patch merged onto current vacation state — what the
// real backend does.
...settingsResponse.vacation,
...(msg.enabled !== undefined ? { enabled: msg.enabled, is_active: msg.enabled } : {}),
...(msg.start !== undefined ? { start: msg.start } : {}),
...(msg.end !== undefined ? { end: msg.end } : {}),
...(msg.buffer_days !== undefined ? { buffer_days: msg.buffer_days } : {}),
...(msg.exempt_task_ids !== undefined ? { exempt_task_ids: msg.exempt_task_ids } : {}),
}),
"maintenance_supporter/vacation/end_now": () => ({
...settingsResponse.vacation, enabled: false, is_active: false,
}),
"maintenance_supporter/vacation/preview": () => ({ rows: [], window_end: null }),
},
});
}
async function mount(opts = {}) {
const { hass, sent } = mockHass(opts);
const el = await fixture<MaintenanceSettingsView>(html`
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
`);
// Wait for _loadSettings to complete — it's kicked off by updated()
// which fires after first render.
await new Promise((r) => setTimeout(r, 50));
await el.updateComplete;
return { el, sent };
}
function vacationSection(el: MaintenanceSettingsView): HTMLElement | null {
return el.shadowRoot?.querySelector(".vacation-section") || null;
}
describe("settings-view vacation section", () => {
it("renders the vacation section with title and disabled toggle by default", async () => {
const { el } = await mount();
const section = vacationSection(el);
expect(section, "vacation section present").to.exist;
const h3 = section!.querySelector("h3");
expect(h3?.textContent || "").to.match(/vacation|urlaub/i);
const toggle = section!.querySelector<HTMLInputElement>(".vac-toggle input");
expect(toggle, "enable toggle present").to.exist;
expect(toggle!.checked, "toggle off by default").to.be.false;
});
it("hydrates dates from settings response", async () => {
const { el } = await mount({
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");
});
it("shows the active badge when vacation.is_active is true", async () => {
const { el } = await mount({
vacationActive: true,
vacationStart: "2099-06-10",
vacationEnd: "2099-06-20",
});
const badge = vacationSection(el)!.querySelector(".vac-badge.active");
expect(badge, "active badge rendered").to.exist;
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
expect(endNow, "end-now button rendered").to.exist;
});
it("dispatches vacation/update when the enable toggle is clicked", async () => {
const { el, sent } = await mount();
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 30));
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update");
expect(update, "update message sent").to.exist;
expect(update!.enabled, "enabled=true in payload").to.equal(true);
});
it("dispatches vacation/update with new buffer_days when number changes", async () => {
const { el, sent } = await mount();
const buffer = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=number]")[0];
expect(buffer, "buffer input present").to.exist;
buffer.value = "7";
buffer.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 30));
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update" && m.buffer_days === 7);
expect(update, "update with buffer_days=7 sent").to.exist;
});
it("emits settings-changed when vacation toggles (so the panel re-evaluates the Vacation tab)", async () => {
const { el } = await mount();
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
toggle.checked = true;
const evtPromise = oneEvent(el, "settings-changed");
toggle.dispatchEvent(new Event("change"));
const evt = await evtPromise;
expect(evt.type).to.equal("settings-changed");
});
it("does not show end-now button when vacation is disabled and not stale", async () => {
const { el } = await mount();
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
expect(endNow, "no end-now in default state").to.not.exist;
});
});
@@ -0,0 +1,117 @@
/** Lit component tests for <maintenance-storage-section-card>. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/storage-section-card.js";
import type { MaintenanceStorageSectionCard } from "../components/storage-section-card";
import { createMockHass } from "./_test-utils.js";
const SUMMARY = {
total_bytes: 13, dedup_savings_bytes: 10, file_count: 3, link_count: 1, document_count: 4,
by_object: {
objA: { bytes: 10, files: 2, links: 0 },
objB: { bytes: 3, files: 1, links: 1 },
},
};
const OBJECTS = [
{ entry_id: "e1", object: { id: "objA", name: "Pool Pump" } },
{ entry_id: "e2", object: { id: "objB", name: "HVAC" } },
];
async function mount(summary: unknown = SUMMARY, objects: unknown = OBJECTS) {
const { hass } = createMockHass({
handlers: { "maintenance_supporter/documents/storage": () => summary },
});
const el = await fixture<MaintenanceStorageSectionCard>(html`
<maintenance-storage-section-card .hass=${hass} .objects=${objects}></maintenance-storage-section-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return el;
}
async function expand(el: MaintenanceStorageSectionCard) {
el.shadowRoot!.querySelector<HTMLButtonElement>(".toggle")!.click();
await el.updateComplete;
}
describe("storage-section-card", () => {
it("is collapsed by default and expands on toggle", async () => {
const el = await mount();
expect(el.shadowRoot!.querySelector(".body"), "collapsed by default").to.not.exist;
// the headline total stays visible in the collapsed header
expect(el.shadowRoot!.querySelector(".header-summary"), "summary in header").to.exist;
await expand(el);
expect(el.shadowRoot!.querySelector(".body"), "expands on toggle").to.exist;
expect(el.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(2);
});
it("renders total, dedup saving and per-object rows sorted by size", async () => {
const el = await mount();
await expand(el);
const rows = el.shadowRoot!.querySelectorAll(".obj-row");
expect(rows.length).to.equal(2);
expect(rows[0].querySelector(".obj-name")!.textContent).to.contain("Pool Pump"); // 10 B first
expect(rows[1].querySelector(".obj-name")!.textContent).to.contain("HVAC");
expect(el.shadowRoot!.querySelector(".stat-value.saved"), "dedup saving shown").to.exist;
});
it("self-hides when there are no documents", async () => {
const el = await mount({ ...SUMMARY, document_count: 0, by_object: {} });
expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist;
});
it("navigates to an object when its row is clicked", async () => {
const el = await mount();
await expand(el);
let openedEntry = "";
el.addEventListener("open-object", (e) => { openedEntry = (e as CustomEvent).detail.entry_id; });
const row = el.shadowRoot!.querySelector<HTMLElement>(".obj-row.clickable");
expect(row, "object row is clickable").to.exist;
expect(row!.getAttribute("role")).to.equal("button");
row!.click();
expect(openedEntry, "open-object dispatched with the entry id").to.equal("e1"); // Pool Pump / objA, largest
});
it("searches documents and renders results with the object name", async () => {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/storage": () => SUMMARY,
"maintenance_supporter/documents/search": () => ({
results: [
{ id: "d1", entry_id: "e1", object_name: "Pool Pump", kind: "file", title: "Manual", filename: "m.pdf", size: 100, tags: ["manual"] },
],
}),
},
});
const el = await fixture<MaintenanceStorageSectionCard>(html`
<maintenance-storage-section-card .hass=${hass} .objects=${OBJECTS}></maintenance-storage-section-card>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
await expand(el);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".doc-search input")!;
input.value = "manual";
input.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 320)); // debounce (250 ms)
await el.updateComplete;
const results = el.shadowRoot!.querySelectorAll(".result-row");
expect(results.length).to.equal(1);
expect(results[0].querySelector(".result-title")!.textContent).to.contain("Manual");
expect(results[0].querySelector(".result-obj")!.textContent).to.contain("Pool Pump");
});
it("falls back to a short id and stays non-clickable when the object is unknown", async () => {
const el = await mount(
{
...SUMMARY, document_count: 1, file_count: 1, link_count: 0,
by_object: { "0123456789abcdef": { bytes: 5, files: 1, links: 0 } },
},
[],
);
await expand(el);
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;
});
});
@@ -0,0 +1,211 @@
/** Tests for the extracted task-detail renderers (renderers/task-detail).
*
* The cluster moved out of maintenance-panel.ts as free functions + a
* TaskDetailContext of ~20 panel-owned callbacks. These tests pin the
* behaviour the extraction must preserve:
* - header renders name / object breadcrumb / status chip / actions
* - Complete / Skip route to the panel callbacks with the task
* - operator mode hides archive + the more-menu (read-only surface)
* - the more-menu items (edit/duplicate/reset/snooze/delete) fire callbacks
* - tab bar switches via setActiveTab; history tab renders the timeline
* - KPI bar shows warning days + currency; user badge resolves names
*/
import { expect } from "@open-wc/testing";
import { render } from "lit";
import {
renderTaskDetail,
renderUserBadge,
type TaskDetailContext,
} from "../renderers/task-detail.js";
import type { MaintenanceTask } from "../types";
import { createMockHass } from "./_test-utils.js";
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
return {
id: "t1",
name: "Filter Wechsel",
type: "cleaning",
enabled: true,
status: "due_soon",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
days_until_due: 3,
next_due: "2026-07-10",
last_performed: "2026-06-10",
times_performed: 2,
total_cost: 50,
average_duration: 20,
history: [],
checklist: [],
is_done: false,
archived: false,
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
const { hass } = createMockHass({
handler: () => ({ documents: [] }),
});
return {
lang: "en",
hass: hass as TaskDetailContext["hass"],
entryId: "entry1",
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
activeTab: "overview",
features: {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
},
currencySymbol: "€",
collapsedSections: new Set(),
costDurationToggle: "both",
suggestionDismissed: false,
sparkline: {
lang: "en",
detailStatsData: new Map(),
hasStatsService: false,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
},
history: {
lang: "en",
hass: hass as TaskDetailContext["hass"],
filter: null,
search: "",
currencySymbol: "€",
setFilter: () => undefined,
setSearch: () => undefined,
openEdit: () => undefined,
},
getUserName: () => null,
setActiveTab: () => undefined,
toggleSection: () => undefined,
setCostDurationToggle: () => undefined,
showTaskView: () => undefined,
showObject: () => undefined,
toggleMoreMenu: () => undefined,
closeMoreMenu: () => undefined,
openEdit: () => undefined,
openComplete: () => undefined,
promptSkip: () => undefined,
toggleArchive: () => undefined,
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
applySuggestion: () => undefined,
reanalyze: () => undefined,
dismissSuggestion: () => undefined,
openSeasonalOverrides: () => undefined,
...overrides,
};
}
function mount(t: MaintenanceTask, c: TaskDetailContext): HTMLElement {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderTaskDetail(t, c), host);
return host;
}
describe("task-detail renderer", () => {
afterEach(() => {
document.body.querySelectorAll("div").forEach((el) => {
if (el.parentElement === document.body) el.remove();
});
});
it("renders header with task name, object breadcrumb, and status chip", () => {
const host = mount(task(), ctx());
expect(host.querySelector(".task-name-breadcrumb")!.textContent).to.include("Filter Wechsel");
expect(host.querySelector(".object-name-breadcrumb")!.textContent).to.include("Pool Pump");
const chip = host.querySelector(".status-chip")!;
expect(chip.classList.contains("warning")).to.be.true;
});
it("Complete routes to openComplete with the task; Skip to promptSkip", () => {
let completed: MaintenanceTask | null = null;
let skipped = 0;
const host = mount(task(), ctx({
openComplete: (tk) => { completed = tk; },
promptSkip: () => { skipped++; },
}));
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
(buttons[0] as HTMLElement).click(); // Complete (filled)
(buttons[1] as HTMLElement).click(); // Skip
expect(completed).to.not.be.null;
expect(completed!.name).to.equal("Filter Wechsel");
expect(skipped).to.equal(1);
});
it("operator mode hides archive button and the more-menu", () => {
const host = mount(task(), ctx({ isOperator: true }));
expect(host.querySelector(".more-menu-wrapper")).to.be.null;
// Only Complete / Skip / QR remain.
const labels = [...host.querySelectorAll(".task-header-actions ha-button")]
.map((b) => b.textContent || "");
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
});
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
const calls: string[] = [];
const host = mount(task(), ctx({
moreMenuOpen: true,
closeMoreMenu: () => calls.push("close"),
deleteTask: () => calls.push("delete"),
snoozeTask: () => calls.push("snooze"),
printWorksheet: () => calls.push("worksheet"),
}));
const items = [...host.querySelectorAll(".popup-menu-item")];
expect(items.length).to.equal(6);
(items[3] as HTMLElement).click(); // snooze
(items[4] as HTMLElement).click(); // work sheet (v2.21)
(items[5] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
});
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
let tab = "";
const host = mount(task(), ctx({ setActiveTab: (t2) => { tab = t2; } }));
const tabs = [...host.querySelectorAll(".tab-bar .tab")];
(tabs[1] as HTMLElement).click();
expect(tab).to.equal("history");
const host2 = mount(task({
history: [{ timestamp: "2026-06-10T10:00:00+00:00", type: "completed", notes: "done" }],
}), ctx({ activeTab: "history" }));
expect(host2.querySelector(".history-timeline")).to.not.be.null;
expect(host2.querySelector(".kpi-bar")).to.be.null;
});
it("KPI bar shows warning days and currency symbol", () => {
const host = mount(task(), ctx({ currencySymbol: "$" }));
const kpi = host.querySelector(".kpi-bar")!;
expect(kpi.textContent).to.include("7");
expect(kpi.textContent).to.include("$");
});
it("renderUserBadge resolves the name (and hides when unknown)", () => {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderUserBadge(task({ responsible_user_id: "u1" }) , (id) => (id === "u1" ? "Ingmar" : null)), host);
expect(host.textContent).to.include("Ingmar");
render(renderUserBadge(task({ responsible_user_id: "u2" }), () => null), host);
expect(host.textContent!.trim()).to.equal("");
});
});
@@ -0,0 +1,126 @@
/**
* Lit component tests for the Phase 4 calendar recurrence kinds in
* <maintenance-task-dialog>: weekdays / nth_weekday / day_of_month.
*
* Pins the UI side of the WS contract (backend: test_schedule.py +
* test_ws_roundtrip.py): hydration from the nested `schedule`, the outgoing
* `schedule` payload shape, and that the per-kind field groups render.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog calendar kinds (Phase 4)", () => {
it("hydrates nth_weekday from the nested schedule on openEdit", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Smoke alarm", type: "custom",
schedule_type: "nth_weekday", warning_days: 7, enabled: true,
schedule: { kind: "nth_weekday", nth: 1, weekday: 5 },
} as any);
await el.updateComplete;
expect((el as any)._scheduleType).to.equal("nth_weekday");
expect((el as any)._nth).to.equal("1");
expect((el as any)._nthWeekday).to.equal("5");
});
it("hydrates weekdays from the nested schedule", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Floors", type: "custom",
schedule_type: "weekdays", warning_days: 7, enabled: true,
schedule: { kind: "weekdays", weekdays: [0, 3] },
} as any);
await el.updateComplete;
expect((el as any)._scheduleType).to.equal("weekdays");
expect((el as any)._weekdays).to.deep.equal([0, 3]);
});
it("create sends the nested schedule for nth_weekday", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Smoke alarm";
(el as any)._scheduleType = "nth_weekday";
(el as any)._nth = "1";
(el as any)._nthWeekday = "5";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
expect(msg.schedule).to.deep.equal({ kind: "nth_weekday", nth: 1, weekday: 5 });
});
it("create sends a sorted weekdays schedule", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Floors";
(el as any)._scheduleType = "weekdays";
(el as any)._weekdays = [3, 0];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "weekdays", weekdays: [0, 3] });
});
it("create sends day_of_month", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Rent";
(el as any)._scheduleType = "day_of_month";
(el as any)._domDay = "15";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15 });
});
it("renders 7 weekday chips for the weekdays kind", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "weekdays";
await el.updateComplete;
const chips = el.shadowRoot!.querySelectorAll(".weekday-chip");
expect(chips.length).to.equal(7);
});
it("(#83) create sends last business day with -2 offset", async () => {
const { el, sent } = await mountDialog();
(el as any)._name = "EOM";
(el as any)._scheduleType = "day_of_month";
(el as any)._domLastDay = true;
(el as any)._domBusiness = true;
(el as any)._calOffset = "-2";
await (el as any)._save();
const msg = sent.find((m: any) => m.type === "maintenance_supporter/task/create");
expect(msg.schedule).to.deep.equal({
kind: "day_of_month", day: -1, business: true, offset: -2,
});
});
it("(#83) edit hydrates last-day/business/offset from the stored schedule", async () => {
const { el } = await mountDialog();
(el as any).openEdit("e1", {
id: "t1", name: "EOM", type: "custom", enabled: true,
warning_days: 7, history: [],
schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 },
schedule_type: "day_of_month",
});
await el.updateComplete;
expect((el as any)._domLastDay).to.equal(true);
expect((el as any)._domBusiness).to.equal(true);
expect((el as any)._calOffset).to.equal("-2");
});
});
@@ -0,0 +1,280 @@
/**
* Lit component tests for the v1.3.0 completion-actions sections of
* <maintenance-task-dialog>: on_complete_action + quick_complete_defaults.
*
* The two sections are gated behind `completionActionsEnabled`. Save-payload
* shape is what the WS contract pins on the backend (test_completion_actions.py
* + test_ws_roundtrip.py); these tests pin the UI side: gating, hydration of
* existing values, and outgoing payload shape.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import {
type CreateMockHassResult,
type SentMessage,
type ServiceCall,
createMockHass,
} from "./_test-utils.js";
// v1.3.1: minimal service registry so the schema-driven data form can
// resolve `light.toggle` to a known field set.
const SERVICES_FIXTURE = {
light: {
toggle: {
fields: {
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
transition: { selector: { number: { min: 0, max: 60 } }, required: false },
},
},
turn_on: {
fields: {
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
},
},
},
button: { press: {} }, // no fields → fallback JSON textfield
};
function mockHass(): CreateMockHassResult {
return createMockHass({
services: SERVICES_FIXTURE,
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "newtask123" }),
"maintenance_supporter/task/update": () => ({}),
},
});
}
async function mountDialog(opts: { completionActions?: boolean } = {}): Promise<{
el: MaintenanceTaskDialog;
sent: SentMessage[];
serviceCalls: ServiceCall[];
}> {
const { hass, sent, serviceCalls } = mockHass();
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${opts.completionActions ?? false}
></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent, serviceCalls };
}
function caSections(el: MaintenanceTaskDialog): NodeListOf<HTMLElement> {
return el.shadowRoot!.querySelectorAll<HTMLElement>("details.ca-section");
}
describe("task-dialog completion-actions sections", () => {
it("hides both sections when feature flag is off", async () => {
const { el } = await mountDialog({ completionActions: false });
await el.openCreate("entry_x");
await el.updateComplete;
expect(caSections(el).length, "no .ca-section when gated off").to.equal(0);
});
it("renders both sections when feature flag is on", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
await el.updateComplete;
const sections = caSections(el);
expect(sections.length, "two collapsible sections present").to.equal(2);
expect(sections[0].querySelector("summary")?.textContent || "")
.to.match(/action|aktion/i);
expect(sections[1].querySelector("summary")?.textContent || "")
.to.match(/quick|schnell/i);
});
it("hydrates on_complete_action from an existing task on openEdit", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openEdit("entry_x", {
id: "t1",
name: "Edit hydration",
type: "custom",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
enabled: true,
on_complete_action: {
service: "light.turn_on",
target: { entity_id: "light.workshop" },
data: { brightness: 200 },
},
quick_complete_defaults: {
notes: "Quick note",
cost: 4.5,
duration: 10,
feedback: "needed",
},
} as any);
await el.updateComplete;
// v1.3.1: service field is now an <ha-service-picker> (not a textfield).
const servicePicker = el.shadowRoot!
.querySelector<HTMLElement & { value: string }>(
"details.ca-section ha-service-picker",
);
expect(servicePicker?.value, "service picker hydrated").to.equal("light.turn_on");
// Internal state pins the parsed data dict — ha-form gets it via .data prop.
expect((el as any)._actionData.brightness, "data hydrated").to.equal(200);
// Quick-complete fields are in the second details panel.
// v2.3.x: ha-textfield → ms-textfield to dodge HA's lazy-load (issues #46/#50).
const sections = caSections(el);
const qcInputs = sections[1].querySelectorAll<HTMLElement & { value: string }>(
"ms-textfield",
);
expect(qcInputs[0]?.value, "qc notes").to.equal("Quick note");
expect(qcInputs[1]?.value, "qc cost").to.equal("4.5");
expect(qcInputs[2]?.value, "qc duration").to.equal("10");
const qcSelect = sections[1].querySelector<HTMLSelectElement>("select.qc-feedback");
expect(qcSelect?.value, "qc feedback").to.equal("needed");
});
it("Test button validates without firing the action (no callService side effect)", async () => {
// User feedback: *"setzt Test auch den zustand? dann würde beim
// testen bereits so getan als ob eine Wartung ausgeführt wurde —
// das ist nicht gut"*. The Test button must validate the
// configuration without actually firing the service-call (which
// for input_button.press / counter.increment / etc would have real
// side-effects, e.g. resetting a vacuum's dirty-time sensor).
const { hass, serviceCalls } = createMockHass({ services: SERVICES_FIXTURE });
(hass as any).states = { "light.workshop": { entity_id: "light.workshop", state: "off" } };
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${true}
></maintenance-task-dialog>
`);
await el.openCreate("entry_x");
(el as any)._actionService = "light.toggle";
(el as any)._actionTargetEntity = "light.workshop";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect(serviceCalls.length, "no real service-call dispatched (validate-only)").to.equal(0);
expect((el as any)._actionTestResult).to.equal("ok");
});
it("Test button blocks save + shows error on service/entity domain-mismatch", async () => {
// Issue #50 follow-up: user picked button.press service + input_button.*
// entity. HA's service-call would silently log "Referenced entities ...
// missing or not currently available" — looks like success but never
// fires. Up-front check catches the mismatch.
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "button.press";
(el as any)._actionTargetEntity = "input_button.foo";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
expect((el as any)._actionTestResult).to.equal("error");
const errMsg = (el as any)._actionTestError as string;
expect(errMsg, "error message names both domains")
.to.match(/button\.\*.*input_button\.\*|input_button\.\*.*button\.\*/);
});
it("Test button allows cross-domain services (homeassistant.turn_on, scene.*)", async () => {
const { hass } = createMockHass({
services: { homeassistant: { turn_on: {} }, light: { toggle: {} } },
});
// Override hass.states so the entity-exists check passes
(hass as any).states = { "light.kitchen": { entity_id: "light.kitchen", state: "off" } };
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog
.hass=${hass}
?completion-actions-enabled=${true}
></maintenance-task-dialog>
`);
await el.openCreate("entry_x");
(el as any)._actionService = "homeassistant.turn_on";
(el as any)._actionTargetEntity = "light.kitchen";
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect((el as any)._actionTestResult).to.equal("ok");
expect((el as any)._actionTestError).to.equal("");
});
it("Test button errors when service is not registered in hass.services", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "imaginary.nope"; // not in SERVICES_FIXTURE
await el.updateComplete;
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
testBtn!.click();
await new Promise((r) => setTimeout(r, 30));
expect((el as any)._actionTestResult).to.equal("error");
expect((el as any)._actionTestError).to.match(/not registered|nicht/i);
});
it("renders ha-form when the picked service has a schema", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "light.toggle";
await el.updateComplete;
const actionSection = caSections(el)[0]!;
// Two ha-form elements expected: the entity picker (always) + the
// data-form (only when service has a schema, like light.toggle does).
expect(
actionSection.querySelector("ha-form.ca-data-form"),
"data ha-form rendered for schemaed service",
).to.exist;
expect(
actionSection.querySelector("ms-textfield"),
"no JSON fallback textfield when schema present",
).to.not.exist;
});
it("falls back to JSON textfield when the service has no schema", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
(el as any)._actionService = "button.press"; // services.button.press has no fields
await el.updateComplete;
const actionSection = caSections(el)[0]!;
// v2.3.x: there's now an entity-picker ha-form (always present) PLUS
// the optional data-form ha-form (only when service has a schema).
// Disambiguate via the .ca-data-form class — the bare ha-form is the
// entity picker and is always present.
expect(
actionSection.querySelector("ha-form.ca-data-form"),
"no DATA ha-form when service has no fields",
).to.not.exist;
expect(
actionSection.querySelector("ms-textfield"),
"JSON fallback textfield rendered (now ms-textfield)",
).to.exist;
});
it("Test button is disabled when service is empty", async () => {
const { el } = await mountDialog({ completionActions: true });
await el.openCreate("entry_x");
await el.updateComplete;
const btn = el.shadowRoot!.querySelector<HTMLButtonElement>(
"details.ca-section .ca-test-row button",
);
expect(btn?.disabled, "test button disabled with no service").to.be.true;
});
});
@@ -0,0 +1,97 @@
/**
* <maintenance-task-dialog>: the compound trigger editor (panel↔config-flow
* parity). Hydration from a compound trigger_config into per-condition drafts,
* and the outgoing WS payload building the {type:compound, compound_logic,
* conditions[]} shape the backend expects.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog compound trigger editor (parity)", () => {
it("hydrates compound_logic + conditions from trigger_config", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "AC service", type: "custom",
schedule_type: "sensor_based", warning_days: 7, enabled: true,
trigger_config: {
type: "compound",
compound_logic: "OR",
conditions: [
{ entity_id: "sensor.hours", entity_ids: ["sensor.hours"], type: "runtime", trigger_runtime_hours: 500 },
{ entity_id: "sensor.dust", entity_ids: ["sensor.dust"], type: "threshold", trigger_above: 80 },
],
},
} as any);
await el.updateComplete;
expect((el as any)._triggerType).to.equal("compound");
expect((el as any)._compoundLogic).to.equal("OR");
const conds = (el as any)._compoundConditions;
expect(conds).to.have.length(2);
expect(conds[0].type).to.equal("runtime");
expect(conds[0].runtimeHours).to.equal("500");
expect(conds[0].entityIds).to.equal("sensor.hours");
expect(conds[1].type).to.equal("threshold");
expect(conds[1].above).to.equal("80");
});
it("builds a compound trigger_config on save", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "AC service";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerType = "compound";
(el as any)._compoundLogic = "AND";
(el as any)._compoundConditions = [
{ entityIds: "sensor.hours", type: "runtime", above: "", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "500" },
{ entityIds: "sensor.dust, sensor.dust2", type: "threshold", above: "80", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
const tc = msg.trigger_config;
expect(tc.type).to.equal("compound");
expect(tc.compound_logic).to.equal("AND");
expect(tc.conditions).to.have.length(2);
expect(tc.conditions[0]).to.deep.include({ type: "runtime", trigger_runtime_hours: 500 });
expect(tc.conditions[0].entity_ids).to.deep.equal(["sensor.hours"]);
expect(tc.conditions[1]).to.deep.include({ type: "threshold", trigger_above: 80 });
expect(tc.conditions[1].entity_ids).to.deep.equal(["sensor.dust", "sensor.dust2"]);
});
it("drops conditions with no entity, and clears the trigger if all empty on edit", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "x", type: "custom", schedule_type: "sensor_based",
warning_days: 7, enabled: true,
trigger_config: { type: "compound", compound_logic: "AND", conditions: [] },
} as any);
(el as any)._triggerType = "compound";
(el as any)._compoundConditions = [
{ entityIds: " ", type: "threshold", above: "1", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
expect(msg, "update message sent").to.exist;
expect(msg.trigger_config).to.equal(null);
});
});
@@ -0,0 +1,86 @@
/**
* Lit component test for #42 — `interval_days` hydration on edit.
*
* Repro: a sensor_based task whose user has cleared the optional safety
* interval is persisted as `interval_days: null` on the backend. Before
* the fix, opening the edit dialog re-hydrated the field as "30" because
* of the `?.toString() || "30"` fallback in openEdit, silently restoring
* a value the user had explicitly removed.
*
* The test pins both branches of the corrected ternary:
* - null → empty string
* - explicit number → string of that number
*/
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 mountDialog(): Promise<MaintenanceTaskDialog> {
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return el;
}
describe("task-dialog interval_days hydration (#42 regression)", () => {
it("hydrates a cleared safety interval as empty string, not '30'", async () => {
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Sensor task without safety net",
type: "custom",
schedule_type: "sensor_based",
interval_days: null, // user has cleared the safety interval
warning_days: 7,
enabled: true,
trigger_config: {
type: "threshold",
entity_id: "sensor.x",
trigger_above: 100,
},
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "should be empty when persisted as null").to.equal("");
});
it("hydrates an explicit interval value to its string form", async () => {
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Time-based task with 90-day interval",
type: "custom",
schedule_type: "time_based",
interval_days: 90,
warning_days: 7,
enabled: true,
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "should preserve the persisted number").to.equal("90");
});
it("hydrates a missing interval_days field as empty string", async () => {
// The backend may send the field as undefined when not set on a manual task.
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Manual task",
type: "custom",
schedule_type: "manual",
warning_days: 7,
enabled: true,
} as any);
await el.updateComplete;
expect((el as any)._intervalDays, "undefined should hydrate to empty").to.equal("");
});
});
@@ -0,0 +1,87 @@
/**
* <maintenance-task-dialog>: the auto_complete_on_recovery flag (#53) —
* hydration from trigger_config, checkbox rendering, and the outgoing
* WS payload (set only when true; absence means off).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog auto-complete-on-recovery (#53)", () => {
it("hydrates the flag from trigger_config on openEdit", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Refill salt", type: "custom",
schedule_type: "sensor_based", warning_days: 7, enabled: true,
trigger_config: {
type: "threshold", entity_id: "sensor.salt",
trigger_below: 20, auto_complete_on_recovery: true,
},
} as any);
await el.updateComplete;
expect((el as any)._autoCompleteOnRecovery).to.be.true;
});
it("sends the flag in trigger_config only when enabled", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Refill salt";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
(el as any)._triggerType = "threshold";
(el as any)._triggerBelow = "20";
(el as any)._autoCompleteOnRecovery = true;
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg, "create message sent").to.exist;
expect(msg.trigger_config.auto_complete_on_recovery).to.be.true;
});
it("omits the flag when off (absence means off)", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Refill salt";
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
(el as any)._triggerType = "threshold";
(el as any)._triggerBelow = "20";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect("auto_complete_on_recovery" in msg.trigger_config).to.be.false;
});
it("renders the checkbox in the sensor trigger section", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerEntityId = "sensor.salt";
(el as any)._triggerEntityIds = ["sensor.salt"];
await el.updateComplete;
const checkboxes = [...el.shadowRoot!.querySelectorAll('input[type="checkbox"]')];
// delta-mode checkbox only shows for counter type; the recovery checkbox
// shows for every sensor trigger type.
expect(checkboxes.length).to.be.greaterThan(0);
const label = [...el.shadowRoot!.querySelectorAll("label")].find((l) =>
/auto-complete|recover/i.test(l.textContent || ""),
);
expect(label, "recovery checkbox label present").to.exist;
});
});
@@ -0,0 +1,125 @@
/** Lit component tests for <maintenance-task-documents>. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-documents.js";
import type { MaintenanceTaskDocuments } from "../components/task-documents";
import { createMockHass } from "./_test-utils.js";
const LINKED = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
const AVAIL = { id: "d2", kind: "file", title: "Invoice", filename: "i.pdf", mime: "application/pdf", size: 50, tags: ["invoice"], task_ids: [] };
async function mount(canWrite = true, docs: unknown[] = [LINKED, 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"} .taskId=${"t1"} .canWrite=${canWrite}></maintenance-task-documents>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el, sent };
}
describe("task-documents", () => {
it("lists only documents linked to the task", async () => {
const { el } = await mount();
const rows = el.shadowRoot!.querySelectorAll(".tdoc-row");
expect(rows.length).to.equal(1);
expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Manual");
const opts = el.shadowRoot!.querySelectorAll(".tdoc-select option");
expect([...opts].some((o) => o.textContent!.includes("Invoice")), "unlinked doc offered").to.be.true;
});
it("links an available document to the task via WS", async () => {
const { el, sent } = await mount();
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!.task_ids).to.deep.equal(["t1"]);
});
it("unlinks a linked document via WS", async () => {
const { el, sent } = await mount();
const unlink = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:link-variant-off"]'),
) as HTMLButtonElement;
unlink.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
expect(msg, "unlink WS sent").to.exist;
expect(msg!.task_ids).to.deep.equal([]);
});
it("hides entirely when the object has no documents", async () => {
const { el } = await mount(true, []);
expect(el.shadowRoot!.querySelector(".task-docs"), "hidden with no docs").to.not.exist;
});
it("sets a per-task jump-to page for a linked PDF via WS", async () => {
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
const { el, sent } = await mount(true, [PDF]);
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".tdoc-page");
expect(input, "page field renders for a linked PDF").to.exist;
input!.value = "7";
input!.dispatchEvent(new Event("change"));
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
expect(msg, "page update WS sent").to.exist;
expect(msg!.task_pages).to.deep.equal({ t1: 7 });
});
it("opens a paged PDF at its page via the #page fragment", async () => {
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"], task_pages: { t1: 12 } };
const { hass } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: [PDF] }),
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/d1?authSig=x" }),
},
});
const el = await fixture<MaintenanceTaskDocuments>(html`
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .taskId=${"t1"} .canWrite=${true}></maintenance-task-documents>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
const win = { location: { href: "" }, close() {} };
const orig = window.open;
window.open = (() => win as unknown as Window) as typeof window.open;
try {
const eye = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
(b) => b.querySelector('ha-icon[icon="mdi:eye-outline"]'),
) as HTMLButtonElement;
eye.click();
await new Promise((r) => setTimeout(r, 20));
expect(win.location.href).to.contain("#page=12");
} finally {
window.open = orig;
}
});
it("opens a document by clicking its title row (not just the eye icon)", async () => {
const LINK = { id: "d3", kind: "weblink", title: "Online Manual", url: "https://x/m.pdf", tags: [], task_ids: ["t1"] };
const orig = window.open;
let openedUrl = "";
window.open = ((u: string) => { openedUrl = u; return null; }) as typeof window.open;
try {
const { el } = await mount(true, [LINK]);
const info = el.shadowRoot!.querySelector<HTMLElement>(".tdoc-row .tdoc-info")!;
expect(info, "title row present").to.exist;
expect(info.getAttribute("role"), "title row is a button").to.equal("button");
info.click();
await el.updateComplete;
expect(openedUrl, "clicking the title opens the link").to.equal("https://x/m.pdf");
} finally {
window.open = orig;
}
});
});
@@ -0,0 +1,120 @@
/** Tests for <maintenance-trigger-chart> + the shared chart utils. */
import { expect, fixture, html } from "@open-wc/testing";
import "../components/trigger-chart.js";
import type { MaintenanceTriggerChart } from "../components/trigger-chart";
import { niceTicks, fmtNum } from "../renderers/chart-utils.js";
const DAY = 86400000;
const NOW = Date.now();
const POINTS = Array.from({ length: 10 }, (_, i) => ({
ts: NOW - (9 - i) * DAY,
val: 50 + i * 3, // 50 … 77
}));
async function mount(props: Partial<MaintenanceTriggerChart> = {}) {
const el = await fixture<MaintenanceTriggerChart>(html`
<maintenance-trigger-chart
.points=${props.points ?? POINTS}
.events=${props.events ?? []}
.unit=${"%"}
.lang=${"en"}
.thresholdBelow=${props.thresholdBelow ?? null}
.thresholdAbove=${props.thresholdAbove ?? null}
.targetValue=${props.targetValue ?? null}
.forceZero=${props.forceZero ?? false}
.rangeDays=${props.rangeDays ?? 30}
></maintenance-trigger-chart>
`);
await new Promise((r) => setTimeout(r, 40)); // let the ResizeObserver fire
await el.updateComplete;
return el;
}
describe("trigger-chart", () => {
it("renders round y-ticks with gridlines at full width", async () => {
const el = await mount();
const svg = el.shadowRoot!.querySelector("svg")!;
expect(svg, "svg rendered").to.exist;
// width follows the host container, not a fixed 300
expect(Number(svg.getAttribute("width"))).to.be.greaterThan(300);
const labels = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
// nice ticks over ~[48,79] land on round steps (50/60/70/80-ish)
expect(labels.some((l) => /^(50|60|70|80)$/.test(l || "")), `round tick in ${labels}`).to.be.true;
});
it("shades the danger zone and recolors in-zone line segments", async () => {
const el = await mount({ thresholdBelow: 60 });
const rects = el.shadowRoot!.querySelectorAll('rect[fill="var(--error-color, #f44336)"]');
expect(rects.length, "zone shading rect").to.be.greaterThan(0);
const redLine = el.shadowRoot!.querySelector('polyline[stroke="var(--error-color, #f44336)"]');
expect(redLine, "in-zone line overlay (clip-path)").to.exist;
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
expect(label).to.contain("60");
});
it("draws a target line for counter progress", async () => {
const el = await mount({ targetValue: 100, forceZero: true });
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
expect(label).to.contain("100");
// forceZero pulls the domain floor to 0
const ticks = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
expect(ticks).to.include("0");
});
it("emits range-change from the range chips", async () => {
const el = await mount({ rangeDays: 30 });
let got = 0;
el.addEventListener("range-change", (e) => { got = (e as CustomEvent).detail.days; });
const chips = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".range-chip:not(.outlier-chip)")];
expect(chips.length).to.equal(4);
chips.find((c) => c.textContent!.trim() === "90d")!.click();
expect(got).to.equal(90);
});
it("emits outlier-toggle from the filter chip", async () => {
const el = await mount({ rangeDays: 30 });
let hide: boolean | null = null;
el.addEventListener("outlier-toggle", (e) => { hide = (e as CustomEvent).detail.hide; });
const chip = el.shadowRoot!.querySelector<HTMLButtonElement>(".outlier-chip");
expect(chip, "outlier chip present").to.exist;
chip!.click();
expect(hide).to.equal(true);
});
it("shows a crosshair value chip on pointer move", async () => {
const el = await mount();
const svg = el.shadowRoot!.querySelector("svg")!;
const r = svg.getBoundingClientRect();
svg.dispatchEvent(new PointerEvent("pointermove", { clientX: r.left + r.width / 2, clientY: r.top + 40, bubbles: true }));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".hover-chip"), "crosshair chip").to.exist;
svg.dispatchEvent(new PointerEvent("pointerleave", { bubbles: true }));
await el.updateComplete;
expect(el.shadowRoot!.querySelector(".hover-chip"), "chip clears on leave").to.not.exist;
});
it("renders completion markers in the bottom lane", async () => {
const el = await mount({ events: [{ ts: NOW - 4 * DAY, type: "completed" }] });
const marks = el.shadowRoot!.querySelectorAll('rect[fill="var(--success-color, #4caf50)"]');
expect(marks.length).to.equal(1);
});
});
describe("chart-utils", () => {
it("niceTicks produces round inclusive bounds", () => {
const { ticks, niceMin, niceMax } = niceTicks(53, 77, 4);
expect(niceMin).to.be.at.most(53);
expect(niceMax).to.be.at.least(77);
for (const t of ticks) expect(t % 10 === 0 || t % 5 === 0, `tick ${t} round`).to.be.true;
});
it("fmtNum is compact and consistent", () => {
expect(fmtNum(88000)).to.equal("88k");
expect(fmtNum(1500)).to.equal("1.5k");
expect(fmtNum(-8007.1)).to.equal("-8k");
expect(fmtNum(730)).to.equal("730");
expect(fmtNum(7.53)).to.equal("7.5");
expect(fmtNum(0)).to.equal("0");
});
});
@@ -0,0 +1,106 @@
/** Tests for the trigger-section renderer (progress header + stats-fallback note). */
import { expect } from "@open-wc/testing";
import { render } from "lit";
import { renderTriggerSection, type SparklineContext } from "../renderers/sparkline.js";
import type { MaintenanceTask, StatisticsPoint } from "../types";
function ctx(overrides: Partial<SparklineContext> = {}): SparklineContext {
return {
lang: "en",
detailStatsData: new Map<string, StatisticsPoint[]>(),
hasStatsService: true,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
...overrides,
};
}
function task(overrides: Record<string, unknown>): MaintenanceTask {
return {
id: "t1",
name: "Task",
history: [],
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function mount(t: MaintenanceTask, c: SparklineContext): HTMLElement {
const host = document.createElement("div");
document.body.appendChild(host);
render(renderTriggerSection(t, c), host);
return host;
}
describe("trigger-section", () => {
afterEach(() => {
document.body.querySelectorAll("div").forEach((d) => d.remove());
});
it("shows a progress header for state_change tasks (changes vs target)", () => {
const host = mount(
task({
trigger_config: { type: "state_change", entity_id: "input_boolean.wm", trigger_target_changes: 8 },
trigger_current_value: 5,
}),
ctx(),
);
const main = host.querySelector(".counter-progress-main");
expect(main, "progress header rendered").to.exist;
expect(main!.textContent).to.contain("5");
expect(main!.textContent).to.contain("8");
expect(host.querySelector(".counter-progress-pct")!.textContent).to.contain("63");
});
it("shows a progress header for runtime tasks (hours vs target)", () => {
const host = mount(
task({
trigger_config: { type: "runtime", entity_id: "input_boolean.comp", trigger_runtime_hours: 500 },
trigger_current_value: 400,
}),
ctx(),
);
const pct = host.querySelector(".counter-progress-pct");
expect(pct, "progress header rendered").to.exist;
expect(pct!.textContent).to.contain("80");
expect(pct!.classList.contains("near"), "80% renders in the warning tier").to.be.true;
});
it("notes the statistics fallback when the entity has no long-term stats", () => {
const history = [1, 2, 3].map((i) => ({
timestamp: new Date(Date.now() - i * 86400000).toISOString(),
type: "completed",
trigger_value: i * 10,
}));
const host = mount(
task({
trigger_config: { type: "threshold", entity_id: "sensor.no_stats", trigger_below: 5 },
trigger_current_value: 42,
history,
}),
// Stats were fetched and came back EMPTY → fallback note expected.
ctx({ detailStatsData: new Map([["sensor.no_stats", []]]) }),
);
expect(host.querySelector(".chart-note"), "fallback note shown").to.exist;
});
it("shows no fallback note when real statistics exist", () => {
const stats: StatisticsPoint[] = Array.from({ length: 5 }, (_, i) => ({
ts: Date.now() - (5 - i) * 86400000,
val: 50 + i,
}));
const host = mount(
task({
trigger_config: { type: "threshold", entity_id: "sensor.with_stats", trigger_below: 5 },
trigger_current_value: 55,
}),
ctx({ detailStatsData: new Map([["sensor.with_stats", stats]]) }),
);
expect(host.querySelector(".chart-note"), "no note with real stats").to.not.exist;
expect(host.querySelector("maintenance-trigger-chart"), "chart rendered").to.exist;
});
});
@@ -0,0 +1,107 @@
/**
* Tests for <maintenance-vacation-section-card> (audit gap #10).
*
* An interactive, admin-gated Lovelace card that edits vacation mode from any
* dashboard. Pins:
* - non-admin users get a read-only card (no switch, no date inputs, no save)
* - the enable toggle dispatches vacation/update {enabled}
* - editing dates + Save dispatches vacation/update with start/end/buffer
* - the status pill mirrors the server state (active / scheduled / inactive)
*/
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";
const BASE_STATE = {
enabled: false,
is_active: false,
start: "2026-08-01",
end: "2026-08-14",
buffer_days: 7,
exempt_task_ids: [],
};
async function mount(opts: {
isAdmin?: boolean;
state?: Record<string, unknown>;
} = {}) {
const state = { ...BASE_STATE, ...(opts.state ?? {}) };
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/vacation/state": () => state,
"maintenance_supporter/vacation/update": (msg) => ({ ...state, ...msg }),
"maintenance_supporter/vacation/end_now": () => ({ ...state, enabled: false, is_active: false }),
},
});
(hass as Record<string, unknown>).user = { id: "u1", is_admin: opts.isAdmin ?? true };
const el = await fixture<MaintenanceVacationSectionCard>(html`
<maintenance-vacation-section-card .hass=${hass}></maintenance-vacation-section-card>
`);
await new Promise((r) => setTimeout(r, 20));
await el.updateComplete;
return { el, sent };
}
describe("vacation-section-card", () => {
it("hides every edit control for non-admin users", async () => {
const { el } = await mount({ isAdmin: false });
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(".actions"), "no action buttons").to.be.null;
});
it("admin toggle dispatches vacation/update {enabled: true}", async () => {
const { el, sent } = await mount({ isAdmin: true });
const sw = el.shadowRoot!.querySelector<HTMLInputElement>("ha-switch")!;
expect(sw, "switch rendered for admin").to.exist;
(sw as unknown as { checked: boolean }).checked = true;
sw.dispatchEvent(new Event("change"));
await new Promise((r) => setTimeout(r, 20));
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
expect(upd.length).to.equal(1);
expect(upd[0].enabled).to.equal(true);
});
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 buffer = root.querySelector<HTMLInputElement>('input[type="number"]')!;
buffer.value = "3";
buffer.dispatchEvent(new Event("input"));
await el.updateComplete;
// The dirty state arms the primary Save button.
const save = [...root.querySelectorAll<HTMLButtonElement>(".actions .btn")]
.find((b) => b.classList.contains("primary"))!;
expect(save, "save button armed").to.exist;
save.click();
await new Promise((r) => setTimeout(r, 20));
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
expect(upd.length).to.equal(1);
expect(upd[0].start).to.equal("2026-09-01");
expect(upd[0].end).to.equal("2026-09-10");
expect(upd[0].buffer_days).to.equal(3);
});
it("status pill mirrors the server state", async () => {
const { el } = await mount({ state: { enabled: true, is_active: true } });
const pill = el.shadowRoot!.querySelector(".status-pill")!;
expect(pill.classList.contains("active")).to.be.true;
const { el: el2 } = await mount({ state: { enabled: true, is_active: false } });
expect(el2.shadowRoot!.querySelector(".status-pill")!.classList.contains("scheduled"))
.to.be.true;
});
});
@@ -0,0 +1,90 @@
/** Pure-function tests for the task-table windowing math (helpers/virtual-window).
*
* Pins:
* - the window covers the visible range plus overscan on both sides
* - start/end snap to the step grid (re-render churn control)
* - clamping at the top (scrollTop above the table) and at the bottom
* - spacer heights always add up: padTop + rendered + padBottom == total
* - degenerate inputs (0 rows, table far below viewport) stay sane
*/
import { expect } from "@open-wc/testing";
import { computeWindow, VIRTUAL_MIN_ROWS } from "../helpers/virtual-window.js";
const BASE = {
viewportHeight: 500,
listTop: 200,
rowHeight: 50,
total: 500,
overscan: 12,
step: 6,
};
function invariant(w: { start: number; end: number; padTop: number; padBottom: number }) {
expect(w.start).to.be.at.least(0);
expect(w.end).to.be.at.most(BASE.total);
expect(w.start).to.be.at.most(w.end);
expect(w.padTop).to.equal(w.start * BASE.rowHeight);
expect(w.padBottom).to.equal((BASE.total - w.end) * BASE.rowHeight);
}
describe("virtual-window", () => {
it("at the top the window starts at 0 with no top pad", () => {
const w = computeWindow({ ...BASE, scrollTop: 0 });
expect(w.start).to.equal(0);
expect(w.padTop).to.equal(0);
// 10 visible + 1 + 12 overscan, snapped up to a step multiple.
expect(w.end).to.be.at.least(23);
expect(w.end).to.be.at.most(30);
invariant(w);
});
it("mid-scroll covers the visible range plus overscan", () => {
// scrollTop 1200 → firstVisible = (1200-200)/50 = row 20
const w = computeWindow({ ...BASE, scrollTop: 1200 });
expect(w.start).to.be.at.most(20 - BASE.overscan + BASE.step); // ≤ snapped(8)
expect(w.start).to.be.at.least(0);
expect(w.end).to.be.at.least(20 + 11); // last visible row rendered
expect(w.start % BASE.step).to.equal(0);
invariant(w);
});
it("start/end snap to the step grid", () => {
const a = computeWindow({ ...BASE, scrollTop: 1200 });
const b = computeWindow({ ...BASE, scrollTop: 1200 + 49 }); // < 1 row later
// A sub-row scroll may shift the window at most one step, never per-pixel.
expect(Math.abs(b.start - a.start) % BASE.step).to.equal(0);
});
it("clamps at the bottom: end == total and no bottom pad", () => {
const w = computeWindow({ ...BASE, scrollTop: 200 + 500 * 50 }); // past the end
expect(w.end).to.equal(BASE.total);
expect(w.padBottom).to.equal(0);
invariant(w);
});
it("scrolled above the table (negative firstVisible) starts at 0", () => {
const w = computeWindow({ ...BASE, scrollTop: 0, listTop: 5000 });
expect(w.start).to.equal(0);
expect(w.padTop).to.equal(0);
expect(w.end).to.be.greaterThan(0);
});
it("total 0 → empty window", () => {
const w = computeWindow({ ...BASE, scrollTop: 0, total: 0 });
expect(w).to.deep.equal({ start: 0, end: 0, padTop: 0, padBottom: 0 });
});
it("pads + rendered slice always account for every row", () => {
for (const scrollTop of [0, 137, 999, 5000, 12345, 26000]) {
const w = computeWindow({ ...BASE, scrollTop });
const rendered = w.end - w.start;
expect(w.padTop + rendered * BASE.rowHeight + w.padBottom)
.to.equal(BASE.total * BASE.rowHeight);
}
});
it("exports a sane virtualization threshold", () => {
expect(VIRTUAL_MIN_ROWS).to.be.greaterThan(50);
});
});
@@ -0,0 +1,61 @@
/**
* Warranty status classification (#67) — pure + today-injectable.
*
* Pins the 4 states and the 60-day amber threshold so the object-detail chip
* and the objects-table column stay in lockstep with the backend field.
*/
import { expect } from "@open-wc/testing";
import { warrantyStatus, WARRANTY_WARN_DAYS } from "../helpers/warranty";
const TODAY = new Date(2026, 5, 1); // 2026-06-01 local
describe("warrantyStatus (#67)", () => {
it("null / undefined / empty / invalid → none", () => {
expect(warrantyStatus(null, TODAY).kind).to.equal("none");
expect(warrantyStatus(undefined, TODAY).kind).to.equal("none");
expect(warrantyStatus("", TODAY).kind).to.equal("none");
expect(warrantyStatus("not-a-date", TODAY).kind).to.equal("none");
});
it("far future → valid", () => {
const s = warrantyStatus("2027-06-01", TODAY);
expect(s.kind).to.equal("valid");
expect(s.days).to.equal(365);
});
it("61 days out (just past threshold) → valid", () => {
const s = warrantyStatus("2026-08-01", TODAY); // Jun1 -> Aug1 = 61d
expect(s.days).to.equal(61);
expect(s.kind).to.equal("valid");
});
it("exactly the warn threshold (60d) → expiring", () => {
const s = warrantyStatus("2026-07-31", TODAY); // Jun1 -> Jul31 = 60d
expect(s.days).to.equal(WARRANTY_WARN_DAYS);
expect(s.kind).to.equal("expiring");
});
it("within threshold → expiring", () => {
const s = warrantyStatus("2026-06-30", TODAY); // 29d
expect(s.kind).to.equal("expiring");
expect(s.days).to.equal(29);
});
it("today → expiring (0 days)", () => {
const s = warrantyStatus("2026-06-01", TODAY);
expect(s.kind).to.equal("expiring");
expect(s.days).to.equal(0);
});
it("past → expired (negative days)", () => {
const s = warrantyStatus("2026-05-20", TODAY);
expect(s.kind).to.equal("expired");
expect(s.days).to.equal(-12);
});
it("echoes the ISO date back (null when absent)", () => {
expect(warrantyStatus("2027-06-01", TODAY).date).to.equal("2027-06-01");
expect(warrantyStatus("2026-05-20", TODAY).date).to.equal("2026-05-20");
expect(warrantyStatus(null, TODAY).date).to.equal(null);
});
});