Updated Scheduler and Maintainance Apps
This commit is contained in:
+18
-6
@@ -105,6 +105,7 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
openQr: () => undefined,
|
||||
duplicateTask: () => undefined,
|
||||
promptReset: () => undefined,
|
||||
promptPostpone: () => undefined,
|
||||
snoozeTask: () => undefined,
|
||||
printWorksheet: () => undefined,
|
||||
deleteTask: () => undefined,
|
||||
@@ -162,21 +163,32 @@ describe("task-detail renderer", () => {
|
||||
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", () => {
|
||||
it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => {
|
||||
const calls: string[] = [];
|
||||
const host = mount(task(), ctx({
|
||||
moreMenuOpen: true,
|
||||
closeMoreMenu: () => calls.push("close"),
|
||||
deleteTask: () => calls.push("delete"),
|
||||
promptPostpone: () => calls.push("postpone"),
|
||||
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"]);
|
||||
expect(items.length).to.equal(7);
|
||||
(items[3] as HTMLElement).click(); // postpone
|
||||
(items[4] as HTMLElement).click(); // snooze
|
||||
(items[5] as HTMLElement).click(); // work sheet (v2.21)
|
||||
(items[6] as HTMLElement).click(); // delete (danger)
|
||||
expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]);
|
||||
});
|
||||
|
||||
it("shows a postponed badge when the task has a due_override, and none otherwise", () => {
|
||||
const plain = mount(task(), ctx());
|
||||
expect(plain.querySelector(".postponed-badge")).to.be.null;
|
||||
const postponed = mount(task({ due_override: "2026-06-20" }), ctx());
|
||||
const badge = postponed.querySelector(".postponed-badge");
|
||||
expect(badge, "postponed badge").to.exist;
|
||||
expect(badge!.textContent).to.match(/2026|20/);
|
||||
});
|
||||
|
||||
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/** Tests for <maintenance-task-detail-view> — the task-detail sub-view as a
|
||||
* web component (incremental step over the renderers/task-detail
|
||||
* extraction). Pins the contract the component adds on top of the renderer:
|
||||
* - registers and renders into LIGHT DOM (no own shadow root) so the
|
||||
* panel's shadow-scoped styles keep matching
|
||||
* - header / breadcrumb / actions come through the component boundary
|
||||
* - callbacks in the passed TaskDetailContext still fire (Complete, tab
|
||||
* switch) — props in, panel callbacks out
|
||||
* - property changes re-render (new task object → new name in the DOM)
|
||||
* - renders nothing until both `task` and `ctx` are set
|
||||
*/
|
||||
|
||||
import { expect, fixture } from "@open-wc/testing";
|
||||
import { html } from "lit";
|
||||
import "../components/task-detail-view.js";
|
||||
import type { MaintenanceTaskDetailView } from "../components/task-detail-view.js";
|
||||
import 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,
|
||||
promptPostpone: () => undefined,
|
||||
snoozeTask: () => undefined,
|
||||
printWorksheet: () => undefined,
|
||||
deleteTask: () => undefined,
|
||||
applySuggestion: () => undefined,
|
||||
reanalyze: () => undefined,
|
||||
dismissSuggestion: () => undefined,
|
||||
openSeasonalOverrides: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t: MaintenanceTask, c: TaskDetailContext): Promise<MaintenanceTaskDetailView> {
|
||||
const el = await fixture<MaintenanceTaskDetailView>(html`
|
||||
<maintenance-task-detail-view .task=${t} .ctx=${c}></maintenance-task-detail-view>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("maintenance-task-detail-view", () => {
|
||||
it("is registered as a custom element", () => {
|
||||
expect(customElements.get("maintenance-task-detail-view")).to.exist;
|
||||
});
|
||||
|
||||
it("renders into light DOM so the panel's shadow-scoped styles keep applying", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
expect(el.shadowRoot, "no own shadow root").to.equal(null);
|
||||
// The detail markup is queryable directly on the element (= light DOM).
|
||||
expect(el.querySelector(".detail-section"), "detail section in light DOM").to.exist;
|
||||
});
|
||||
|
||||
it("renders header, breadcrumb and status through the component boundary", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Filter Wechsel");
|
||||
expect(el.querySelector(".object-name-breadcrumb")?.textContent).to.contain("Pool Pump");
|
||||
expect(el.querySelector(".status-chip"), "status chip").to.exist;
|
||||
expect(el.querySelector(".kpi-bar"), "KPI bar").to.exist;
|
||||
});
|
||||
|
||||
it("routes the Complete action to the panel callback with the task", async () => {
|
||||
let completed: MaintenanceTask | null = null;
|
||||
const el = await mount(task(), ctx({ openComplete: (tk) => { completed = tk; } }));
|
||||
const btn = [...el.querySelectorAll(".task-header-actions ha-button")]
|
||||
.find((b) => b.textContent?.match(/complete/i)) as HTMLElement;
|
||||
expect(btn, "complete button").to.exist;
|
||||
btn.click();
|
||||
expect(completed, "openComplete received the task").to.not.equal(null);
|
||||
expect((completed as unknown as MaintenanceTask).id).to.equal("t1");
|
||||
});
|
||||
|
||||
it("switches tabs via the panel-owned setActiveTab callback", async () => {
|
||||
let tab = "";
|
||||
const el = await mount(task(), ctx({ setActiveTab: (v) => { tab = v; } }));
|
||||
const tabs = [...el.querySelectorAll(".tab-bar .tab")] as HTMLElement[];
|
||||
expect(tabs.length).to.equal(2);
|
||||
tabs[1].click();
|
||||
expect(tab).to.equal("history");
|
||||
});
|
||||
|
||||
it("re-renders when the task property changes", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
el.task = task({ name: "Pumpe entkalken" });
|
||||
await el.updateComplete;
|
||||
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Pumpe entkalken");
|
||||
});
|
||||
|
||||
it("renders nothing until both task and ctx are set", async () => {
|
||||
const el = await fixture<MaintenanceTaskDetailView>(html`
|
||||
<maintenance-task-detail-view></maintenance-task-detail-view>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
expect(el.querySelector(".detail-section")).to.equal(null);
|
||||
});
|
||||
|
||||
it("hides the more-menu for operators (read-only surface preserved)", async () => {
|
||||
const el = await mount(task(), ctx({ isOperator: true }));
|
||||
expect(el.querySelector(".more-menu-wrapper")).to.equal(null);
|
||||
});
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Lit tests for the seasonal-window + finite-series recurrence extras in
|
||||
* <maintenance-task-dialog>. Pins hydration from the nested schedule and the
|
||||
* outgoing `schedule` payload (backend: test_schedule.py / test_ws_io.py).
|
||||
*/
|
||||
|
||||
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 recurrence extras (season / finite series)", () => {
|
||||
it("hydrates season_months and a count end from the nested schedule", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 14, interval_unit: "days", warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5, 6], ends: { count: 6 } },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._seasonMonths).to.deep.equal([4, 5, 6]);
|
||||
expect((el as any)._endsMode).to.equal("count");
|
||||
expect((el as any)._endsCount).to.equal("6");
|
||||
});
|
||||
|
||||
it("hydrates an until end", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Cure", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 30, warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 30, unit: "days", ends: { until: "2027-01-01" } },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._endsMode).to.equal("until");
|
||||
expect((el as any)._endsUntil).to.equal("2027-01-01");
|
||||
});
|
||||
|
||||
it("create sends season_months + ends on an interval task's nested schedule", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Mow";
|
||||
(el as any)._scheduleType = "time_based";
|
||||
(el as any)._intervalDays = "14";
|
||||
(el as any)._intervalUnit = "days";
|
||||
(el as any)._seasonMonths = [7, 4, 5];
|
||||
(el as any)._endsMode = "count";
|
||||
(el as any)._endsCount = "6";
|
||||
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: "interval", season_months: [4, 5, 7], ends: { count: 6 } });
|
||||
expect(msg.interval_days).to.equal(14); // flat fields still carry the cadence
|
||||
});
|
||||
|
||||
it("attaches the extras to a calendar-kind schedule too", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Gutter";
|
||||
(el as any)._scheduleType = "day_of_month";
|
||||
(el as any)._domDay = "15";
|
||||
(el as any)._seasonMonths = [10, 11];
|
||||
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, season_months: [10, 11] });
|
||||
});
|
||||
|
||||
it("clearing season/ends on edit sends an authoritative schedule without them", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 14, warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5], ends: { count: 3 } },
|
||||
} as any);
|
||||
(el as any)._seasonMonths = [];
|
||||
(el as any)._endsMode = "never";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "interval" }); // no season_months, no ends
|
||||
});
|
||||
|
||||
it("shows the season chips + ends selector for recurring, not for one_time", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._scheduleType = "time_based";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(12);
|
||||
expect(el.shadowRoot!.querySelector('select option[value="count"]')).to.exist;
|
||||
|
||||
(el as any)._scheduleType = "one_time";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user