New apps Added
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
+465
@@ -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");
|
||||
});
|
||||
});
|
||||
+86
@@ -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"));
|
||||
});
|
||||
});
|
||||
+169
@@ -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;
|
||||
});
|
||||
});
|
||||
+164
@@ -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);
|
||||
});
|
||||
});
|
||||
+216
@@ -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);
|
||||
});
|
||||
});
|
||||
+39
@@ -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.5–3 → 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);
|
||||
});
|
||||
});
|
||||
+66
@@ -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");
|
||||
});
|
||||
});
|
||||
+109
@@ -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);
|
||||
});
|
||||
});
|
||||
+85
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+111
@@ -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");
|
||||
});
|
||||
});
|
||||
+97
@@ -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);
|
||||
});
|
||||
});
|
||||
+182
@@ -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;
|
||||
});
|
||||
});
|
||||
+147
@@ -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;
|
||||
});
|
||||
});
|
||||
+117
@@ -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;
|
||||
});
|
||||
});
|
||||
+211
@@ -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("");
|
||||
});
|
||||
});
|
||||
+126
@@ -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");
|
||||
});
|
||||
});
|
||||
+280
@@ -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;
|
||||
});
|
||||
});
|
||||
+97
@@ -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);
|
||||
});
|
||||
});
|
||||
+86
@@ -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("");
|
||||
});
|
||||
});
|
||||
+87
@@ -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;
|
||||
});
|
||||
});
|
||||
+125
@@ -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");
|
||||
});
|
||||
});
|
||||
+106
@@ -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;
|
||||
});
|
||||
});
|
||||
+107
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user