Spaces:
Configuration error
Configuration error
| import { describe, expect, test } from "vitest"; | |
| import { | |
| CHIP_PX, | |
| clampMagnitude, | |
| clockLabel, | |
| dateLabel, | |
| decayVelocity, | |
| MAX_SCRUB_VELOCITY, | |
| MS_PER_PX, | |
| multiplierLabel, | |
| nearestRung, | |
| rateLabel, | |
| REAL_TIME_RUNG, | |
| passMarks, | |
| timelineTicks, | |
| rungFor, | |
| LADDER, | |
| SPEED_TICKS, | |
| } from "./clockDeck"; | |
| const AT = new Date("2026-08-19T14:37:09Z"); | |
| describe("the speed ladder", () => { | |
| test("runs from full reverse to full forward through real time", () => { | |
| expect(LADDER).toHaveLength(SPEED_TICKS.length * 2); | |
| expect(LADDER[0]).toBe(-86400); | |
| expect(LADDER.at(-1)).toBe(86400); | |
| expect(LADDER[REAL_TIME_RUNG]).toBe(1); | |
| // Reverse is the mirror of forward, which is what makes the middle a boundary | |
| // rather than a rung with nothing on one side of it. | |
| expect(LADDER[REAL_TIME_RUNG - 1]).toBe(-1); | |
| }); | |
| test("has no rung slower than real time, in either direction", () => { | |
| expect(LADDER.every((rung) => Math.abs(rung) >= 1)).toBe(true); | |
| }); | |
| test("finds the nearest rung for a speed nothing on the ladder set", () => { | |
| // A url can ask for any multiplier; the ladder still has to open somewhere. | |
| expect(LADDER[rungFor(1)]).toBe(1); | |
| expect(LADDER[rungFor(70)]).toBe(60); | |
| expect(LADDER[rungFor(-500)]).toBe(-600); | |
| expect(LADDER[rungFor(1e9)]).toBe(86400); | |
| }); | |
| test("reads the rung under the needle from the scroll offset", () => { | |
| expect(nearestRung(0)).toBe(0); | |
| expect(nearestRung(REAL_TIME_RUNG * CHIP_PX)).toBe(REAL_TIME_RUNG); | |
| // Two thirds of the way past a rung is nearer the next one. | |
| expect(nearestRung(REAL_TIME_RUNG * CHIP_PX + CHIP_PX * 0.7)).toBe(REAL_TIME_RUNG + 1); | |
| // Off either end, the ladder is what it is. | |
| expect(nearestRung(-500)).toBe(0); | |
| expect(nearestRung(1e6)).toBe(LADDER.length - 1); | |
| }); | |
| }); | |
| describe("labels", () => { | |
| test("say the multiplier with a real minus sign", () => { | |
| expect(multiplierLabel(1)).toBe("1×"); | |
| expect(multiplierLabel(86400)).toBe("86400×"); | |
| expect(multiplierLabel(-60)).toBe("−60×"); | |
| }); | |
| test("say the rate in the largest unit that keeps it small", () => { | |
| expect(rateLabel(1)).toBe("1 s/s"); | |
| expect(rateLabel(30)).toBe("30 s/s"); | |
| expect(rateLabel(60)).toBe("1 min/s"); | |
| expect(rateLabel(90)).toBe("1.5 min/s"); | |
| expect(rateLabel(3600)).toBe("1 h/s"); | |
| expect(rateLabel(86400)).toBe("1 d/s"); | |
| expect(rateLabel(-7200)).toBe("−2 h/s"); | |
| }); | |
| test("read the clock in UTC whatever the machine's zone is", () => { | |
| expect(clockLabel(AT)).toBe("14:37:09"); | |
| expect(dateLabel(AT)).toBe("Wed 19 Aug"); | |
| }); | |
| }); | |
| describe("the ruler", () => { | |
| test("centres the clock's moment under the needle", () => { | |
| const ticks = timelineTicks(AT.getTime(), 360); | |
| const centre = ticks.reduce((best, tick) => (Math.abs(tick.x - 180) < Math.abs(best.x - 180) ? tick : best)); | |
| // The nearest tick to the middle is within half a ten-minute step of now. | |
| expect(Math.abs(centre.at - AT.getTime())).toBeLessThanOrEqual(300_000); | |
| }); | |
| test("covers the width it is given, at the scale it promises", () => { | |
| const ticks = timelineTicks(AT.getTime(), 360); | |
| const step = 600_000 / MS_PER_PX; | |
| // Ticks land on ten-minute boundaries, so the outermost pair cannot be the | |
| // ruler's own edges — what matters is that neither end is short by more than | |
| // one step, or the ruler would have a bald patch. | |
| expect(ticks[0]!.x).toBeLessThanOrEqual(0); | |
| expect(ticks.at(-1)!.x).toBeGreaterThan(360 - step); | |
| // One hour is 150 px at MS_PER_PX, which is the whole reason a pass is findable. | |
| expect(3_600_000 / MS_PER_PX).toBe(150); | |
| }); | |
| test("labels the hour, and names the day at midnight", () => { | |
| const midnight = Date.UTC(2026, 7, 19, 0, 0, 0); | |
| const ticks = timelineTicks(midnight, 360); | |
| const labelled = ticks.filter((tick) => tick.label !== ""); | |
| expect(labelled.every((tick) => tick.major)).toBe(true); | |
| expect(ticks.find((tick) => tick.at === midnight)?.label).toBe("Wed 19 Aug"); | |
| expect(ticks.find((tick) => tick.at === midnight + 3_600_000)?.label).toBe("01:00"); | |
| // Ten-minute ticks carry no label, or the ruler would be a wall of numbers. | |
| expect(ticks.find((tick) => tick.at === midnight + 600_000)?.label).toBe(""); | |
| }); | |
| test("draws more ticks for a wider ruler and none beyond it", () => { | |
| const narrow = timelineTicks(AT.getTime(), 320); | |
| const wide = timelineTicks(AT.getTime(), 900); | |
| expect(wide.length).toBeGreaterThan(narrow.length); | |
| expect(narrow.every((tick) => tick.x >= -600 && tick.x <= 920)).toBe(true); | |
| }); | |
| }); | |
| describe("pass bands on the ruler", () => { | |
| const NOON = Date.UTC(2026, 7, 19, 12, 0, 0); | |
| const span = (startMin: number, endMin: number) => ({ start: NOON + startMin * 60_000, end: NOON + endMin * 60_000 }); | |
| test("puts a pass where its minutes are", () => { | |
| // 360 px is 2.4 hours, so the ruler runs from 10:48 to 13:12. | |
| const [band] = passMarks([span(0, 10)], NOON, 360); | |
| expect(band!.left).toBe(180); | |
| expect(band!.width).toBe((10 * 60_000) / MS_PER_PX); | |
| }); | |
| test("leaves out what is not on screen, and clips what is half on", () => { | |
| const bands = passMarks([span(-600, -500), span(-90, 10)], NOON, 360); | |
| expect(bands).toHaveLength(1); | |
| // Clipped at the ruler's left edge rather than drawn off it. | |
| expect(bands[0]!.left).toBe(0); | |
| expect(bands[0]!.left + bands[0]!.width).toBeCloseTo(180 + (10 * 60_000) / MS_PER_PX, 6); | |
| }); | |
| test("keeps a pass that swallows the whole ruler", () => { | |
| // A geostationary contact never ends; a ruler showing only its middle should | |
| // still be entirely blue rather than empty. | |
| const [band] = passMarks([span(-6000, 6000)], NOON, 360); | |
| expect(band).toEqual({ key: expect.any(String), left: 0, width: 360 }); | |
| }); | |
| test("never draws a band nobody can see", () => { | |
| const [band] = passMarks([span(0, 0)], NOON, 360); | |
| expect(band!.width).toBe(1); | |
| }); | |
| }); | |
| describe("flick physics", () => { | |
| test("clamps a velocity two events in the same millisecond would produce", () => { | |
| // `dt` is floored at 1 ms, so an unclamped drag of 40 px reads as 40 px/ms and | |
| // coasts for days. | |
| expect(clampMagnitude((40 * MS_PER_PX) / 1, MAX_SCRUB_VELOCITY)).toBe(MAX_SCRUB_VELOCITY); | |
| expect(clampMagnitude(-1e9, MAX_SCRUB_VELOCITY)).toBe(-MAX_SCRUB_VELOCITY); | |
| expect(clampMagnitude(1000, MAX_SCRUB_VELOCITY)).toBe(1000); | |
| }); | |
| test("decays toward a stop, at a rate that does not depend on the frame rate", () => { | |
| const oneFrame = decayVelocity(100, 16.7); | |
| const twoHalfFrames = decayVelocity(decayVelocity(100, 8.35), 8.35); | |
| expect(oneFrame).toBeCloseTo(94, 5); | |
| expect(twoHalfFrames).toBeCloseTo(oneFrame, 10); | |
| // A second of coasting keeps a fortieth of the throw, which is where both | |
| // gestures give up and settle. | |
| expect(decayVelocity(100, 1000)).toBeLessThan(3); | |
| expect(decayVelocity(100, 1000)).toBeLessThan(decayVelocity(100, 500)); | |
| }); | |
| }); | |