Add editable countdown timer with deadline-based timing

Co-authored-by: Shelley <shelley@exe.dev>
This commit is contained in:
Leon Mika 2026-10-10 06:45:15 +00:00
parent d3ed55847a
commit 9c993beda8
6 changed files with 440 additions and 0 deletions

View file

@ -0,0 +1,142 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Countdown, integer, splitSeconds } from './timer.mjs';
function fixture() {
let now = 1000;
return {
timer: new Countdown(() => now),
advance(ms) { now += ms; },
};
}
test('starts idle at zero and accepts nonnegative integers only', () => {
const { timer } = fixture();
assert.equal(timer.state, 'idle');
assert.deepEqual(timer.display(), { minutes: '0', seconds: '00' });
for (const invalid of ['-1', '1.5', '1e2', 'abc', '']) {
assert.equal(integer(invalid), 0n);
}
assert.equal(integer('00090'), 90n);
});
test('normalizes overflowing seconds and preserves arbitrarily large integers', () => {
const { timer } = fixture();
timer.edit('0', '90');
timer.toggle();
assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' });
const minutes = '123456789012345678901234567890';
timer.reset();
timer.edit(minutes, '120');
assert.deepEqual(timer.display(), { minutes: String(BigInt(minutes) + 2n), seconds: '00' });
assert.deepEqual(splitSeconds(61n), { minutes: '1', seconds: '01' });
});
test('uses elapsed time, rounding up, even when refreshes are delayed', () => {
const { timer, advance } = fixture();
timer.edit('0', '90');
timer.toggle();
advance(999);
timer.update();
assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' });
advance(1);
timer.update();
assert.deepEqual(timer.display(), { minutes: '1', seconds: '29' });
advance(75000);
timer.update();
assert.deepEqual(timer.display(), { minutes: '0', seconds: '14' });
});
test('pause/resume preserves fractional seconds and excludes paused time', () => {
const { timer, advance } = fixture();
timer.edit('0', '10');
timer.toggle();
advance(1250);
timer.toggle();
assert.equal(timer.state, 'paused');
assert.equal(timer.remaining, 8750n);
advance(100000);
timer.update();
assert.equal(timer.remaining, 8750n);
timer.toggle();
advance(749);
timer.update();
assert.equal(timer.display().seconds, '09');
advance(1);
timer.update();
assert.equal(timer.display().seconds, '08');
});
test('reset restores normalized last input while running or paused', () => {
const { timer, advance } = fixture();
timer.edit('2', '90');
for (const pause of [false, true]) {
timer.toggle();
advance(1500);
if (pause) timer.toggle();
timer.reset();
assert.equal(timer.state, 'idle');
assert.deepEqual(timer.display(), { minutes: '3', seconds: '30' });
assert.equal(timer.finished, false);
}
});
test('editing a paused timer establishes a new countdown and reset value', () => {
const { timer, advance } = fixture();
timer.edit('0', '10');
timer.toggle();
advance(2000);
timer.toggle();
timer.edit('0', '90');
assert.equal(timer.state, 'idle');
timer.toggle();
advance(3000);
timer.reset();
assert.deepEqual(timer.display(), { minutes: '1', seconds: '30' });
});
test('running countdown cannot be edited', () => {
const { timer } = fixture();
timer.edit('0', '10');
timer.toggle();
timer.edit('5', '0');
assert.equal(timer.duration, 10000n);
assert.equal(timer.state, 'running');
});
test('completion returns to idle and Start restarts the original duration', () => {
const { timer, advance } = fixture();
timer.edit('0', '2');
timer.toggle();
advance(5000);
timer.update();
assert.equal(timer.state, 'idle');
assert.equal(timer.finished, true);
assert.equal(timer.display().seconds, '00');
timer.toggle();
assert.equal(timer.finished, false);
assert.equal(timer.state, 'running');
assert.equal(timer.display().seconds, '02');
});
test('editing or resetting after completion clears the completion flag', () => {
const { timer } = fixture();
timer.toggle();
assert.equal(timer.finished, true);
timer.edit('0', '3');
assert.equal(timer.finished, false);
timer.toggle();
timer.reset();
assert.equal(timer.finished, false);
assert.equal(timer.display().seconds, '03');
});
test('stopping after the deadline completes instead of leaving a paused zero', () => {
const { timer, advance } = fixture();
timer.edit('0', '1');
timer.toggle();
advance(1000);
timer.toggle();
assert.equal(timer.state, 'idle');
assert.equal(timer.finished, true);
});