Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -34,6 +34,16 @@ describe("formatDate with HA profile date_format (#97)", () => {
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026");
});
it("pl/cs/sv derive their own locale, not en-US (live-check find)", () => {
setDateTimePrefs({ date_format: "language" });
// These three were missing from langToLocale and silently rendered
// US-ordered dates. Day must come before month for all of them.
expect(formatDate("2026-08-10", "pl")).to.equal("10.08.2026");
expect(formatDate("2026-08-10", "cs")).to.equal("10. 08. 2026"); // Czech spaces its dots
expect(formatDate("2026-08-10", "sv")).to.equal("2026-08-10"); // sv-SE = ISO order
reset();
});
it("null/invalid input unchanged by prefs", () => {
setDateTimePrefs({ date_format: "DMY" });
expect(formatDate(null, "en")).to.equal("—");
@@ -0,0 +1,51 @@
/**
* Dashboard budget display: spent totals without a maximum (#104).
*
* With budget tracking enabled but no monthly/yearly maximum configured,
* the spent totals used to be invisible (a bar needs a denominator).
* Pins: spent-only lines render without a bar, a configured maximum still
* renders the classic bar, and the mixed case shows one of each.
*/
import { expect } from "@open-wc/testing";
import { DEFAULT_FEATURES, DEFAULT_SETTINGS_RESPONSE } from "./_test-utils.js";
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
function handlers(budget: Record<string, unknown>) {
return {
"maintenance_supporter/settings": () => ({
...DEFAULT_SETTINGS_RESPONSE,
features: { ...DEFAULT_FEATURES, budget: true },
}),
"maintenance_supporter/budget_status": () => ({
alert_threshold_pct: 80,
currency_symbol: "€",
...budget,
}),
};
}
describe("dashboard budget: spent-only display (#104)", () => {
beforeEach(() => resetTaskSeq());
it("no maximum set: renders spent lines without bars", async () => {
const { el } = await mountPanel([obj("e1", [task()])], handlers({
monthly_budget: 0, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6,
}));
const items = sr(el).querySelectorAll(".budget-spent-only");
expect(items.length).to.equal(2);
expect(items[0].textContent).to.contain("9.00 €");
expect(items[1].textContent).to.contain("429.60 €");
expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(0);
});
it("mixed: monthly maximum renders a bar, yearly stays spent-only", async () => {
const { el } = await mountPanel([obj("e1", [task()])], handlers({
monthly_budget: 150, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6,
}));
expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(1);
const spentOnly = sr(el).querySelectorAll(".budget-spent-only");
expect(spentOnly.length).to.equal(1);
expect(spentOnly[0].textContent).to.contain("429.60 €");
});
});
@@ -0,0 +1,112 @@
/**
* <maintenance-suggested-setups-dialog>: the optional counting start value
* (#102 — "the last service was at reading X").
*
* Pins: usage_delta duties on a selected row render the baseline input
* (other directions don't), an entered value is forwarded per task in the
* adopt payload, and empty/invalid values are omitted.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/suggested-setups-dialog.js";
import type { MaintenanceSuggestedSetupsDialog } from "../components/suggested-setups-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
const SETUPS = [
{
device_id: "car1", device_name: "Kia EV6", area_name: "Garage",
integration: "kia_uvo", integration_name: "Kia Uvo",
suggested_entry_id: null, suggested_object_name: "Kia EV6",
tasks: [
{
task_name: "Annual Service", entity_ids: ["sensor.kia_odometer"],
threshold: 15000, direction: "usage_delta",
},
{
task_name: "Tire Rotation", entity_ids: ["sensor.kia_odometer"],
threshold: 10000, direction: "usage_delta",
},
],
},
{
device_id: "vac1", device_name: "Roborock", area_name: null,
integration: "roborock", integration_name: "Roborock",
suggested_entry_id: null, suggested_object_name: "Roborock",
tasks: [
{
task_name: "Replace Filter", entity_ids: ["sensor.vac_filter"],
threshold: 24, direction: "duration_left",
},
],
},
];
async function mountOpen(): Promise<{ el: MaintenanceSuggestedSetupsDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/integration_setups/discover": () => ({ setups: SETUPS }),
"maintenance_supporter/objects": () => ({
objects: [{ entry_id: "obj_existing", object: { name: "My Vacuum" } }],
}),
"maintenance_supporter/integration_setups/adopt": () => ({
tasks_created: 3, objects_created: 2, total: 2,
}),
},
});
const el = await fixture<MaintenanceSuggestedSetupsDialog>(html`
<maintenance-suggested-setups-dialog .hass=${hass}></maintenance-suggested-setups-dialog>
`);
await el.open();
await el.updateComplete;
return { el, sent };
}
describe("suggested-setups dialog: counting start value (#102)", () => {
it("renders baseline inputs for usage_delta duties only", async () => {
const { el } = await mountOpen();
const rows = el.shadowRoot!.querySelectorAll(".row");
expect(rows.length).to.equal(2);
expect(rows[0].querySelectorAll(".baseline-field").length).to.equal(2); // both car duties
expect(rows[1].querySelectorAll(".baseline-field").length).to.equal(0); // duration_left
});
it("target picker: choosing an existing object forwards its entry_id (#105)", async () => {
const { el, sent } = await mountOpen();
const selects = el.shadowRoot!.querySelectorAll<HTMLSelectElement>(".target-select");
expect(selects.length).to.equal(2); // one per selected row
// First row (car1): pick the existing object instead of "create new".
selects[0].value = "obj_existing";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
await el.updateComplete;
el.shadowRoot!.querySelectorAll<HTMLElement>("ha-button")[1].click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 0));
const adopt = sent.find((m) => m.type === "maintenance_supporter/integration_setups/adopt")! as {
selections: Array<{ device_id: string; entry_id?: string }>;
};
const byId = Object.fromEntries(adopt.selections.map((s) => [s.device_id, s]));
expect(byId["car1"].entry_id).to.equal("obj_existing");
expect(byId["vac1"].entry_id).to.equal(undefined); // untouched row keeps default
});
it("forwards entered start values per task and omits empty ones", async () => {
const { el, sent } = await mountOpen();
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".baseline-field input")!;
input.value = "12000";
input.dispatchEvent(new Event("input", { bubbles: true }));
await el.updateComplete;
el.shadowRoot!.querySelectorAll<HTMLElement>("ha-button")[1].click(); // Set up selected
await el.updateComplete;
await new Promise((r) => setTimeout(r, 0));
const adopt = sent.find((m) => m.type === "maintenance_supporter/integration_setups/adopt")! as {
selections: Array<{ device_id: string; baselines?: Record<string, number> }>;
};
const byId = Object.fromEntries(adopt.selections.map((s) => [s.device_id, s]));
expect(byId["car1"].baselines).to.deep.equal({ "Annual Service": 12000 });
expect(byId["vac1"].baselines).to.equal(undefined);
});
});
@@ -0,0 +1,68 @@
/**
* <maintenance-task-dialog>: the delta-counter start-value field (#102).
*
* Pins the create/edit split: creating shows the "count from the current
* reading" help, while editing shows the "keep the existing counting" help
* plus the LIVE effective anchor (Store baseline from the read-model) — an
* adopted delta task has no config baseline, so without the live line the
* field would read as empty/zero even though counting is anchored.
*/
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({ states: { "sensor.odometer": { state: "27100" } } });
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return el;
}
describe("task-dialog delta start-value field (#102)", () => {
it("edit mode shows the edit help and the live effective anchor", async () => {
const el = await mountDialog();
await el.openEdit("entry_x", {
id: "t1",
name: "Annual Service",
type: "custom",
schedule_type: "sensor_based",
warning_days: 7,
enabled: true,
trigger_config: {
type: "counter",
entity_id: "sensor.odometer",
trigger_target_value: 15000,
trigger_delta_mode: true,
},
trigger_baseline_value: 27000, // live anchor from the read-model
} as any);
await el.updateComplete;
const helps = [...el.shadowRoot!.querySelectorAll(".field-help")]
.map((n) => n.textContent ?? "")
.join(" | ");
expect(helps).to.contain("keep the existing counting");
const effective = el.shadowRoot!.querySelector(".baseline-effective");
expect(effective?.textContent).to.contain("27000");
});
it("create mode shows the count-from-current help and no effective line", async () => {
const el = await mountDialog();
await el.openCreate("entry_x", []);
await el.updateComplete;
(el as any)._scheduleType = "sensor_based";
(el as any)._triggerType = "counter";
(el as any)._triggerDeltaMode = true;
await el.updateComplete;
const helps = [...el.shadowRoot!.querySelectorAll(".field-help")]
.map((n) => n.textContent ?? "")
.join(" | ");
expect(helps).to.contain("count from the current value");
expect(el.shadowRoot!.querySelector(".baseline-effective")).to.equal(null);
});
});
@@ -0,0 +1,76 @@
/**
* <maintenance-task-dialog>: runtime trigger_on_states in the UI (#103).
*
* Pins: the field is rendered for runtime triggers, an adopted task's
* on_states (e.g. ["mowing"]) hydrate into it and SURVIVE a save roundtrip
* (before this fix the dialog rebuilt trigger_config without
* trigger_on_states — any edit silently reset a mower task to ["on"] and
* stopped the accumulation), and an empty field omits the key (backend
* default ["on"]).
*/
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({
states: { "lawn_mower.navi": { state: "mowing" } },
handlers: { "maintenance_supporter/task/update": () => ({ success: true }) },
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
const MOWER_TASK = {
id: "t1",
name: "Replace Blades",
type: "replacement",
schedule_type: "sensor_based",
warning_days: 7,
enabled: true,
trigger_config: {
type: "runtime",
entity_id: "lawn_mower.navi",
entity_ids: ["lawn_mower.navi"],
trigger_runtime_hours: 100,
trigger_on_states: ["mowing"],
},
};
describe("task-dialog runtime on-states (#103)", () => {
it("hydrates adopted on_states into the field", async () => {
const { el } = await mountDialog();
await el.openEdit("entry_x", MOWER_TASK as any);
await el.updateComplete;
expect((el as any)._triggerOnStates).to.equal("mowing");
});
it("on_states survive a save roundtrip (no silent reset to ['on'])", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("entry_x", MOWER_TASK as any);
await el.updateComplete;
await (el as any)._save();
const update = sent.find((m) => m.type === "maintenance_supporter/task/update")! as {
trigger_config: { trigger_on_states?: string[] };
};
expect(update.trigger_config.trigger_on_states).to.deep.equal(["mowing"]);
});
it("an empty field omits trigger_on_states (backend default)", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("entry_x", MOWER_TASK as any);
await el.updateComplete;
(el as any)._triggerOnStates = "";
await el.updateComplete;
await (el as any)._save();
const update = sent.find((m) => m.type === "maintenance_supporter/task/update")! as {
trigger_config: { trigger_on_states?: string[] };
};
expect(update.trigger_config.trigger_on_states).to.equal(undefined);
});
});
@@ -0,0 +1,79 @@
/**
* <maintenance-task-dialog>: the live "next dates" schedule preview (#83).
*
* Pins: the dialog debounce-fetches maintenance_supporter/schedule/preview
* with the DRAFT schedule (engine dict form, mirroring the save mapping),
* renders the returned occurrences as weekday-prefixed dates, appends the
* series-end hint, and hides the box for manual schedules.
*/
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 mountCreate(previewResponse: {
occurrences: string[];
series_ended: boolean;
}): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/schedule/preview": () => previewResponse,
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
await el.openCreate("entry_x", []);
await el.updateComplete;
return { el, sent };
}
const settle = async (el: MaintenanceTaskDialog) => {
await new Promise((r) => setTimeout(r, 400)); // debounce is 300 ms
await el.updateComplete;
};
describe("task-dialog schedule preview (#83)", () => {
it("fetches the draft schedule and renders weekday-prefixed dates", async () => {
const { el, sent } = await mountCreate({
occurrences: ["2027-01-09", "2027-07-10", "2028-01-08"],
series_ended: false,
});
(el as any)._scheduleType = "nth_weekday";
(el as any)._nth = "2";
(el as any)._nthWeekday = "5";
(el as any)._seasonMonths = [1, 7];
await settle(el);
const req = sent.find((m) => m.type === "maintenance_supporter/schedule/preview") as any;
expect(req, "preview request sent").to.exist;
expect(req.schedule).to.deep.include({ kind: "nth_weekday", nth: 2, weekday: 5 });
expect(req.schedule.season_months).to.deep.equal([1, 7]);
const box = el.shadowRoot!.querySelector(".schedule-preview");
expect(box, "preview box rendered").to.exist;
const text = box!.textContent!.replace(/\s+/g, " ");
expect(text).to.contain("2027");
expect(text).to.match(/Sat|Sa/); // weekday prefix from the shared helper
});
it("shows the series-end hint and hides for manual schedules", async () => {
const { el } = await mountCreate({
occurrences: ["2026-07-26", "2026-08-02"],
series_ended: true,
});
(el as any)._scheduleType = "time_based";
(el as any)._intervalDays = "7";
(el as any)._endsMode = "count";
(el as any)._endsCount = "2";
await settle(el);
const box = el.shadowRoot!.querySelector(".schedule-preview");
expect(box!.textContent).to.contain("series ends");
(el as any)._scheduleType = "manual";
await settle(el);
expect(el.shadowRoot!.querySelector(".schedule-preview")).to.equal(null);
});
});
@@ -0,0 +1,182 @@
/**
* <maintenance-task-dialog>: full trigger_config save-roundtrip closure
* (#103 class).
*
* The dialog rebuilds trigger_config from scratch on save, so every key the
* engine knows must survive openEdit -> _save unchanged. Pins a MAXIMAL
* config per trigger type — including per-condition attribute / baseline /
* entity_logic inside compound triggers, which travel through the editor's
* `carry` passthrough without having form fields.
*/
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 saveRoundtrip(triggerConfig: Record<string, unknown>): Promise<Record<string, unknown>> {
const { hass, sent } = createMockHass({
states: { "sensor.a": { state: "42" }, "sensor.b": { state: "7" } },
handlers: { "maintenance_supporter/task/update": () => ({ success: true }) },
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
await el.openEdit("entry_x", {
id: "t1",
name: "Roundtrip",
type: "custom",
schedule_type: "sensor_based",
warning_days: 7,
enabled: true,
trigger_config: triggerConfig,
} as any);
await el.updateComplete;
await (el as any)._save();
const update = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
expect(update, "update message sent").to.exist;
return update.trigger_config;
}
describe("task-dialog trigger_config roundtrip closure (#103 class)", () => {
it("threshold: attribute, both bounds, for_minutes, entity_logic, recovery", async () => {
const tc = await saveRoundtrip({
type: "threshold",
entity_id: "sensor.a",
entity_ids: ["sensor.a", "sensor.b"],
entity_logic: "all",
attribute: "level",
trigger_above: 80,
trigger_below: 10,
trigger_for_minutes: 5,
auto_complete_on_recovery: true,
});
expect(tc).to.deep.include({
type: "threshold",
entity_logic: "all",
attribute: "level",
trigger_above: 80,
trigger_below: 10,
trigger_for_minutes: 5,
auto_complete_on_recovery: true,
});
});
it("threshold stored with ONLY plural entity_ids survives an edit (#106)", async () => {
// The Battery Fleet task's trigger has no singular entity_id; the save
// path gates on _triggerEntityId, so before the hydration fallback an
// unrelated edit sent trigger_config: null and wiped the trigger.
const tc = await saveRoundtrip({
type: "threshold",
entity_ids: ["sensor.a"],
entity_logic: "any",
trigger_above: 0,
auto_complete_on_recovery: true,
});
expect(tc, "trigger_config must not be nulled").to.exist;
expect(tc).to.deep.include({
type: "threshold",
entity_id: "sensor.a",
trigger_above: 0,
auto_complete_on_recovery: true,
});
expect(tc.entity_ids).to.deep.equal(["sensor.a"]);
});
it("counter: delta mode with start value", async () => {
const tc = await saveRoundtrip({
type: "counter",
entity_id: "sensor.a",
entity_ids: ["sensor.a"],
trigger_target_value: 15000,
trigger_delta_mode: true,
trigger_baseline_value: 12000,
});
expect(tc).to.deep.include({
type: "counter",
trigger_target_value: 15000,
trigger_delta_mode: true,
trigger_baseline_value: 12000,
});
});
it("runtime: on_states and attribute", async () => {
const tc = await saveRoundtrip({
type: "runtime",
entity_id: "sensor.a",
entity_ids: ["sensor.a"],
trigger_runtime_hours: 250,
trigger_on_states: ["cooling", "heating"],
attribute: "hvac_action",
});
expect(tc).to.deep.include({
type: "runtime",
trigger_runtime_hours: 250,
attribute: "hvac_action",
});
expect(tc.trigger_on_states).to.deep.equal(["cooling", "heating"]);
});
it("state_change: from/to states and target changes", async () => {
const tc = await saveRoundtrip({
type: "state_change",
entity_id: "sensor.a",
entity_ids: ["sensor.a"],
trigger_from_state: "unlocked",
trigger_to_state: "locked",
trigger_target_changes: 500,
auto_complete_on_recovery: true,
});
expect(tc).to.deep.include({
type: "state_change",
trigger_from_state: "unlocked",
trigger_to_state: "locked",
trigger_target_changes: 500,
auto_complete_on_recovery: true,
});
});
it("compound: per-condition attribute/baseline/entity_logic survive via carry", async () => {
const tc = await saveRoundtrip({
type: "compound",
compound_logic: "OR",
conditions: [
{
type: "runtime",
entity_id: "sensor.a",
entity_ids: ["sensor.a"],
trigger_runtime_hours: 500,
trigger_on_states: ["printing"],
attribute: "job_state",
},
{
type: "counter",
entity_id: "sensor.b",
entity_ids: ["sensor.b"],
trigger_target_value: 100,
trigger_delta_mode: true,
trigger_baseline_value: 40,
entity_logic: "any",
},
],
});
expect(tc.type).to.equal("compound");
expect(tc.compound_logic).to.equal("OR");
const conds = tc.conditions as Array<Record<string, unknown>>;
expect(conds).to.have.length(2);
expect(conds[0]).to.deep.include({
type: "runtime",
trigger_runtime_hours: 500,
attribute: "job_state",
});
expect(conds[0].trigger_on_states).to.deep.equal(["printing"]);
expect(conds[1]).to.deep.include({
type: "counter",
trigger_target_value: 100,
trigger_delta_mode: true,
trigger_baseline_value: 40,
entity_logic: "any",
});
});
});
@@ -0,0 +1,54 @@
/**
* renderTriggerProgress: delta-mode counters must never fall back to the RAW
* counter value (issue #102 — a 27,000 km odometer with a 15,000 km interval
* rendered as a full red "27000/15000" bar right after adoption, before the
* baseline reached the read-model). Pins: delta used when present, computed
* from baseline when only the baseline is known, NOTHING rendered while the
* baseline is still unknown, and absolute-mode counters keep the raw value.
*/
import { expect, fixture, html } from "@open-wc/testing";
import { renderTriggerProgress } from "../renderers/progress.js";
import type { TaskRow } from "../types";
function row(overrides: Partial<TaskRow>): TaskRow {
return {
trigger_config: {
type: "counter",
trigger_target_value: 15000,
trigger_delta_mode: true,
},
trigger_current_value: 27000,
trigger_current_delta: null,
trigger_baseline_value: null,
...overrides,
} as unknown as TaskRow;
}
async function labelOf(r: TaskRow): Promise<string | null> {
const el = await fixture(html`<div>${renderTriggerProgress(r)}</div>`);
return el.querySelector(".trigger-progress-label")?.textContent?.trim() ?? null;
}
describe("renderTriggerProgress — delta-mode counter (issue #102)", () => {
it("renders nothing while the baseline is still unknown (no raw-value lie)", async () => {
expect(await labelOf(row({}))).to.equal(null);
});
it("uses the exposed delta when present", async () => {
const label = await labelOf(row({ trigger_current_delta: 100 }));
expect(label).to.contain("100.0 / 15000");
});
it("computes the delta from the baseline when only the baseline is exposed", async () => {
const label = await labelOf(row({ trigger_baseline_value: 27000 }));
expect(label).to.contain("0.0 / 15000");
});
it("keeps the raw value for absolute-mode counters", async () => {
const label = await labelOf(
row({ trigger_config: { type: "counter", trigger_target_value: 30000 } as never }),
);
expect(label).to.contain("27000.0 / 30000");
});
});