mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-29 16:33:43 +01:00
* 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>
233 lines
6.1 KiB
TypeScript
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]
|
|
);
|
|
});
|
|
});
|