Updated apps
This commit is contained in:
@@ -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("—");
|
||||
|
||||
+51
@@ -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 €");
|
||||
});
|
||||
});
|
||||
+112
@@ -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);
|
||||
});
|
||||
});
|
||||
+68
@@ -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);
|
||||
});
|
||||
});
|
||||
+76
@@ -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);
|
||||
});
|
||||
});
|
||||
+79
@@ -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);
|
||||
});
|
||||
});
|
||||
+182
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user