Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
eslint:
name: ESLint (cards + panel)
runs-on: ubuntu-latest
# The card render tests finish in about a minute. A stuck timer in a test
# once held the job until GitHub's six-hour default; fail fast instead.
timeout-minutes: 15

steps:
- name: Check out repository
Expand Down
5 changes: 4 additions & 1 deletion custom_components/taskmate/www/taskmate-child-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -4439,7 +4439,10 @@ class TaskMateChildCard extends LitElement {
// A tap still in flight counts from the moment it was made.
const opt = this._optimisticCompletions?.[`${baseId}_${child.id}`];
const optTimes = opt ? (opt.timestamps || [opt.timestamp || now]) : [];
const latest = Math.max(0, ...mine.map(c => Date.parse(c.completed_at || "") || 0), ...optTimes);
// A stamp ahead of this browser's clock (server clock skew) counts as
// made just now, so the settle timer never waits longer than the
// settle window itself.
const latest = Math.min(now, Math.max(0, ...mine.map(c => Date.parse(c.completed_at || "") || 0), ...optTimes));
if (latest && now - latest < this.constructor.AVAILABLE_FIRST_SETTLE_MS) {
settleAt = Math.min(settleAt, latest + this.constructor.AVAILABLE_FIRST_SETTLE_MS);
return 0;
Expand Down
73 changes: 73 additions & 0 deletions tests/frontend/taskmate-child-card-settle-timer.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// The "available first" sort keeps a just-ticked chore in place for a few
// seconds with a real timer (_availableFirst's settle timer). A completion
// stamped ahead of the browser's clock (server clock skew, or a test fixture
// set later in the day) must not stretch that timer past the settle window:
// a timer hours long kept the card re-rendering late and kept Node test
// files from exiting until the stamp came round.

const assert = require("node:assert/strict");
const { test } = require("node:test");

const { loadCard } = require("./harness.cjs");

const ENTITY = "sensor.taskmate_overview";

function cardWithTimers() {
const delays = [];
const { get } = loadCard("taskmate-child-card.js", {
sandbox: {
setTimeout: (_fn, ms) => { delays.push(ms); return delays.length; },
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
},
});
const ChildCard = get("taskmate-child-card");
const card = new ChildCard();
card.setConfig({ entity: ENTITY, child_id: "kid1" });
card._loading = {};
card._optimisticCompletions = {};
return { card, delays };
}

function chore(id, name) {
return { id, name, points: 2, time_category: "anytime", assigned_to: [], due_days: [], schedule_mode: "specific_days", assignment_mode: "everyone" };
}

function rankWith(completedAt) {
const { card, delays } = cardWithTimers();
const child = { id: "kid1", name: "Mia", chore_order: [] };
card.hass = {
states: { [ENTITY]: { state: "ok", attributes: {
children: [child],
chores: [chore("bed", "Make bed"), chore("dog", "Walk dog")],
todays_completions: [{ chore_id: "bed", child_id: "kid1", approved: true, completed_at: completedAt }],
chore_availability: {},
} } },
};
const order = card._availableFirst([chore("bed", "Make bed"), chore("dog", "Walk dog")], child).map((c) => c.id);
return { order, delays };
}

test("a completion stamped in the future settles within the settle window", () => {
// Ninety seconds ahead keeps it "today" whenever the suite runs (bar the
// last ninety seconds before midnight) while still being in the future.
const later = new Date(Date.now() + 90 * 1000).toISOString();
const { order, delays } = rankWith(later);
assert.deepEqual(order, ["bed", "dog"], "the just-ticked row stays where it was");
assert.ok(delays.length > 0, "a settle timer is set");
const settle = 8000 + 50;
assert.ok(Math.max(...delays) <= settle, `no timer longer than the settle window (got ${Math.max(...delays)} ms)`);
});

test("a completion ticked just now still holds its place for the settle window", () => {
const { order, delays } = rankWith(new Date().toISOString());
assert.deepEqual(order, ["bed", "dog"]);
assert.ok(delays.some((d) => d > 7000 && d <= 8050), "the settle timer runs for about eight seconds");
});

test("an old completion drops to the bottom with no timer", () => {
const { order, delays } = rankWith(new Date(Date.now() - 60 * 1000).toISOString());
assert.deepEqual(order, ["dog", "bed"]);
assert.equal(delays.length, 0);
});
11 changes: 8 additions & 3 deletions tests/frontend/taskmate-kid-tour.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,16 @@ const LOCKED_CHORES = [
{ id: "walk", name: "Walk dog", points: 3, time_category: "anytime", assigned_to: [], due_days: [], schedule_mode: "specific_days", assignment_mode: "everyone" },
];
// A completion stamped earlier today (local time), so "done today" checks hold
// whatever day the suite runs on.
// whatever day the suite runs on. 08:00, or a minute ago when the suite runs
// before 08:00: a stamp in the future would keep the card's settle timer
// (_availableFirst) waiting until then, and the test file would never exit.
const TODAY_8AM = (() => {
const d = new Date();
const eight = new Date();
eight.setHours(8, 0, 0, 0);
const minuteAgo = new Date(Date.now() - 60 * 1000);
const d = eight <= minuteAgo || minuteAgo.getDate() !== eight.getDate() ? eight : minuteAgo;
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T08:00:00`;
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:00`;
})();
const BED_DONE = [{ chore_id: "bed", child_id: "kid1", approved: true, completed_at: TODAY_8AM }];

Expand Down
14 changes: 11 additions & 3 deletions tests/frontend/taskmate-panel-setup-wizard.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,19 @@ const TEMPLATES = [
{ id: "custom_pack", name: "Ours", builtin: false, chores: [{ name: "Custom", points: 1 }] },
];

// The panel reads a birthday as a local calendar date, so the fixtures must be
// local dates too: toISOString() gives the UTC date, which is a day off when
// the suite runs east of UTC late in the evening.
function localIso(d) {
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

function age(years, monthsAgo = 1) {
const d = new Date();
d.setMonth(d.getMonth() - monthsAgo);
d.setFullYear(d.getFullYear() - years);
return d.toISOString().slice(0, 10);
return localIso(d);
}

function panel(state = {}) {
Expand Down Expand Up @@ -108,7 +116,7 @@ test("a birthday gives an age that only goes up on the day", () => {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setFullYear(d.getFullYear() - 9);
assert.equal(p._wizardAgeOf({ mode: "bday", bday: d.toISOString().slice(0, 10) }), 8);
assert.equal(p._wizardAgeOf({ mode: "bday", bday: localIso(d) }), 8);
assert.equal(p._wizardAgeOf({ mode: "age", age: 6 }), 6);
assert.equal(p._wizardAgeOf({ mode: "none", band: "6_8" }), null);
});
Expand Down Expand Up @@ -224,7 +232,7 @@ test("a birthday in the future is refused", async () => {
const p = await opened({});
const d = new Date();
d.setDate(d.getDate() + 3);
Object.assign(p._wz.form, { name: "Isla", mode: "bday", bday: d.toISOString().slice(0, 10) });
Object.assign(p._wz.form, { name: "Isla", mode: "bday", bday: localIso(d) });
assert.equal(p._wizardAddKid(), false);
assert.equal(p._wz.kids.length, 0);
assert.equal(p._toasts[0][0], "err");
Expand Down
Loading