Files
frontend/test/data/timer.test.ts
T
Maarten LakerveldandCopilot Autofix powered by AI e09c4084b7 Timer UI enhancements (#53793)
* Add timer data helpers for formatter-based display and duration serialization

Refactor computeDisplayTimer to take formatEntityState instead of hass so
context-migrated components can reuse it, add finishes_at to TimerEntity,
and add durationDataToTimerString for serializing duration input values.

* Modernize timer more-info dialog with state header and live countdown

Use ha-more-info-state-header with a ticking remaining-time display and
the standard more-info control layout. Adds timer to
DOMAINS_WITH_NEW_MORE_INFO, replacing the legacy state-card row.

* Add timer-actions and timer-presets card features

timer-actions shows start/pause/cancel/finish buttons (finish opt-in)
with state-aware disabling; start becomes restart while active.
timer-presets shows configurable one-tap durations that call timer.start.
Timer tile suggestions default to the timer-actions feature.

* Pulse timer red when it finishes

When a timer runs out or timer.finish is called, the tile icon and the
more-info countdown pulse red twice. Cancelling does not pulse: the new
timerJustFinished helper distinguishes the transitions via the
last_transition attribute. Honors prefers-reduced-motion.

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Send normalized duration for timer presets

Presets are parsed leniently by createDurationData like other duration
inputs, but the raw config value was sent to timer.start. A malformed
preset such as 1:nope:00 rendered as 1 h yet was rejected by core. Store
the normalized seconds so the label and the service call always match.

* Store timer presets on the entity instead of the card config

Review feedback: presets belong to the timer entity, like cover favorite
positions and light favorite colors. They now live in the entity registry
options and are edited in the more info dialog through the shared favorites
UI, which also brings reset and copy to other timers. The tile feature keeps
its style option but reads the presets from the registry.

* Reject a zero duration when adding or editing a timer preset

A zero duration passed the form's required check but was then treated as a
cancel, so saving the prefilled 0:00:00 silently did nothing. Form dialogs
now take an optional submit-time validate hook that blocks the submit and
shows a field error, and the preset dialog uses it to require at least one
second.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-31 14:12:26 +03:00

233 lines
6.1 KiB
TypeScript

import { assert, describe, expect, it } from "vitest";
import { createDurationData } from "../../src/common/datetime/create_duration_data";
import {
computeDisplayTimer,
durationDataToTimerString,
normalizeTimerDuration,
normalizeTimerPresets,
timerDurationData,
timerJustFinished,
} from "../../src/data/timer";
const timerState = (state: string, lastTransition?: string) =>
({
state,
attributes: { last_transition: lastTransition },
}) as any;
describe("timerDurationData", () => {
it("derives the input prefill from the configured duration", () => {
assert.deepEqual(
timerDurationData({
state: "idle",
attributes: {
duration: "0:03:00",
remaining: "0:03:00",
},
} as any),
{ hours: 0, minutes: 3, seconds: 0, milliseconds: 0 }
);
});
it("uses the configured duration, not the remaining time, when running", () => {
assert.deepEqual(
timerDurationData({
state: "active",
attributes: {
duration: "2:00:00",
remaining: "1:30:00",
finishes_at: "2018-01-17T16:16:35Z",
},
} as any),
{ hours: 2, minutes: 0, seconds: 0, milliseconds: 0 }
);
});
it("handles durations beyond an hour", () => {
assert.deepEqual(
timerDurationData({
state: "idle",
attributes: {
duration: "3:30:00",
remaining: "3:30:00",
},
} as any),
{ hours: 3, minutes: 30, seconds: 0, milliseconds: 0 }
);
});
});
describe("durationDataToTimerString", () => {
it("serializes with zero-padded minutes and seconds", () => {
assert.strictEqual(
durationDataToTimerString({ hours: 1, minutes: 5, seconds: 7 }),
"1:05:07"
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToTimerString({ minutes: 30 }), "0:30:00");
assert.strictEqual(durationDataToTimerString({}), "0:00:00");
});
it("folds days into hours", () => {
assert.strictEqual(
durationDataToTimerString({ days: 1, hours: 2 }),
"26:00:00"
);
});
it("round-trips through createDurationData", () => {
const data = createDurationData("2:15:30")!;
assert.strictEqual(durationDataToTimerString(data), "2:15:30");
});
it("normalizes out-of-range fields", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 3600 }), "1:00:00");
assert.strictEqual(durationDataToTimerString({ minutes: 90 }), "1:30:00");
});
it("floors fractional seconds instead of serializing decimals", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 1.5 }), "0:00:01");
assert.strictEqual(
durationDataToTimerString({ seconds: 1, milliseconds: 500 }),
"0:00:01"
);
});
});
describe("normalizeTimerDuration", () => {
it("decomposes overflowing fields into hours/minutes/seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 3600 }), {
hours: 1,
minutes: 0,
seconds: 0,
});
assert.deepEqual(normalizeTimerDuration({ days: 1, hours: 2 }), {
hours: 26,
minutes: 0,
seconds: 0,
});
});
it("drops fractional seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 90.9 }), {
hours: 0,
minutes: 1,
seconds: 30,
});
});
});
describe("timerJustFinished", () => {
it("detects a timer running out or being finished", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "finished"))
).toBe(true);
expect(
timerJustFinished(timerState("paused"), timerState("idle", "finished"))
).toBe(true);
});
it("does not match a cancelled timer", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "cancelled"))
).toBe(false);
});
it("does not match without a state transition", () => {
expect(
timerJustFinished(
timerState("idle", "finished"),
timerState("idle", "finished")
)
).toBe(false);
expect(timerJustFinished(undefined, timerState("idle", "finished"))).toBe(
false
);
});
it("does not match on older cores without last_transition", () => {
expect(timerJustFinished(timerState("active"), timerState("idle"))).toBe(
false
);
});
});
describe("computeDisplayTimer", () => {
const formatEntityState = (stateObj: any) =>
stateObj.state === "idle"
? "Idle"
: stateObj.state === "paused"
? "Paused"
: "Active";
it("shows the formatted state when idle", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "idle", attributes: {} } as any,
undefined
),
"Idle"
);
});
it("shows the formatted state when the remaining time is zero", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
0
),
"Active"
);
});
it("shows the remaining time when active", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
90
),
"1:30"
);
});
it("appends the formatted state when paused", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "paused", attributes: {} } as any,
90
),
"1:30 (Paused)"
);
});
});
describe("normalizeTimerPresets", () => {
it("returns an empty list when nothing is stored", () => {
assert.deepEqual(normalizeTimerPresets(undefined), []);
assert.deepEqual(normalizeTimerPresets([]), []);
});
it("keeps the stored order and drops duplicates", () => {
assert.deepEqual(normalizeTimerPresets([600, 60, 300, 60]), [600, 60, 300]);
});
it("truncates fractional seconds", () => {
// Timers only accept whole seconds, and 90.4 and 90.9 are the same preset.
assert.deepEqual(normalizeTimerPresets([90.4, 90.9]), [90]);
});
it("drops values that cannot start a timer", () => {
assert.deepEqual(
normalizeTimerPresets([0, -60, NaN, Infinity, 300] as number[]),
[300]
);
});
});