orbit-studio / src /modules /util /clockDeck.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
7.03 kB
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));
});
});