It printed the unticked days left, so ticking a cycle off dropped its figure by one, and on the last day read 0d. Tasks now carry daysLeft, the period's days from today, and the chip prints that. Found in the whole-branch review. Co-Authored-By: Claude Opus 5.5 <[email protected]>
186 lines
7.2 KiB
TypeScript
186 lines
7.2 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { RESERVED_ID_SEGMENTS } from "../src/shared/custom.ts";
|
|
import { nextResetMs } from "../src/shared/daily.ts";
|
|
import { goalCount } from "../src/shared/goal.ts";
|
|
import { mintTaskId, TaskSchema, taskCaption, taskStatus, type Task } from "../src/shared/task.ts";
|
|
|
|
/**
|
|
* A reader's own weekly and monthly chores: ZZZ's weekly activity points,
|
|
* Endfield's weekly routine. Not events — no window, no end, no status — just a
|
|
* period that rolls on the game's reset and a measure that fills it.
|
|
*/
|
|
|
|
const AT = "2026-10-01T00:00:00.000Z";
|
|
const noon = (key: string) => Date.parse(`${key}T12:00:00Z`);
|
|
const task = (over: Partial<Task>): Task => ({
|
|
id: "mytask:abc123def4",
|
|
game: "zzz",
|
|
title: "Activity points",
|
|
period: { unit: "week", startsOn: 1 },
|
|
measure: { kind: "amount", target: 2100, unit: "points" },
|
|
at: AT,
|
|
updatedAt: AT,
|
|
...over,
|
|
});
|
|
|
|
describe("task ids", () => {
|
|
test("live in their own reserved space", () => {
|
|
expect(mintTaskId(() => 0.5)).toMatch(/^mytask:[a-z0-9]{10}$/);
|
|
expect(RESERVED_ID_SEGMENTS).toContain("mytask");
|
|
expect(RESERVED_ID_SEGMENTS).toContain("routine");
|
|
});
|
|
});
|
|
|
|
describe("TaskSchema", () => {
|
|
test("accepts both measures and both periods", () => {
|
|
expect(TaskSchema.safeParse(task({})).success).toBe(true);
|
|
expect(
|
|
TaskSchema.safeParse(
|
|
task({ period: { unit: "month", startsOn: 31 }, measure: { kind: "days", required: 1 } }),
|
|
).success,
|
|
).toBe(true);
|
|
});
|
|
|
|
test("refuses a blank title, a zero target and a game-less task", () => {
|
|
expect(TaskSchema.safeParse(task({ title: " " })).success).toBe(false);
|
|
expect(
|
|
TaskSchema.safeParse(task({ measure: { kind: "amount", target: 0 } })).success,
|
|
).toBe(false);
|
|
expect(TaskSchema.safeParse(task({ game: "" })).success).toBe(false);
|
|
});
|
|
|
|
test("game is any lane, not just a tracked game", () => {
|
|
expect(TaskSchema.safeParse(task({ game: "mygame:limbus-company" })).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("taskStatus", () => {
|
|
const amounts = {
|
|
"2026-10-05": { value: 2100, at: AT },
|
|
"2026-10-12": { value: 1900, at: AT },
|
|
};
|
|
|
|
test("a weekly amount reads this week's value", () => {
|
|
const s = taskStatus({
|
|
task: task({}), region: "europe", now: noon("2026-10-15"), logged: [], amounts,
|
|
});
|
|
expect(s).toMatchObject({
|
|
scope: "week", measure: "amount", periodStart: "2026-10-12",
|
|
count: 1900, required: 2100, met: false, needed: 200,
|
|
});
|
|
});
|
|
|
|
test("the week rolls at the game's reset in the reader's region", () => {
|
|
const reset = nextResetMs(Date.parse("2026-10-18T12:00:00Z"), "europe", "zzz");
|
|
const before = taskStatus({ task: task({}), region: "europe", now: reset - 1, logged: [], amounts });
|
|
const after = taskStatus({ task: task({}), region: "europe", now: reset, logged: [], amounts });
|
|
expect(before.periodStart).toBe("2026-10-12");
|
|
expect(after.periodStart).toBe("2026-10-19");
|
|
expect(after.count).toBe(0);
|
|
});
|
|
|
|
test("a weekly tick task is done once any day this week is ticked", () => {
|
|
const t = task({ measure: { kind: "days", required: 1 } });
|
|
const s = taskStatus({
|
|
task: t, region: "europe", now: noon("2026-10-15"), logged: ["2026-10-13"],
|
|
});
|
|
expect(s).toMatchObject({ measure: "days", count: 1, met: true });
|
|
const lastWeek = taskStatus({
|
|
task: t, region: "europe", now: noon("2026-10-15"), logged: ["2026-10-11"],
|
|
});
|
|
expect(lastWeek.met).toBe(false);
|
|
});
|
|
|
|
test("a monthly task from the 31st keys a 30-day month by its last day", () => {
|
|
const t = task({ period: { unit: "month", startsOn: 31 } });
|
|
const s = taskStatus({
|
|
task: t, region: "europe", now: noon("2026-10-05"), logged: [],
|
|
amounts: { "2026-09-30": { value: 50, at: AT } },
|
|
});
|
|
expect(s).toMatchObject({ scope: "month", periodStart: "2026-09-30", count: 50 });
|
|
});
|
|
|
|
test("a days count never asks for more days than the period has", () => {
|
|
const t = task({
|
|
period: { unit: "month", startsOn: 1 },
|
|
measure: { kind: "days", required: 31 },
|
|
});
|
|
const s = taskStatus({ task: t, region: "europe", now: noon("2026-02-10"), logged: [] });
|
|
expect(s.required).toBe(28);
|
|
});
|
|
});
|
|
|
|
describe("taskCaption", () => {
|
|
test("says when it rolls and what it wants", () => {
|
|
expect(taskCaption(task({}))).toBe("every week from Monday · reach 2,100 points");
|
|
expect(
|
|
taskCaption(
|
|
task({ period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 } }),
|
|
),
|
|
).toBe("every month from the 1st · tick it off");
|
|
expect(
|
|
taskCaption(
|
|
task({ period: { unit: "month", startsOn: 22 }, measure: { kind: "days", required: 3 } }),
|
|
),
|
|
).toBe("every month from the 22nd · 3 days");
|
|
});
|
|
});
|
|
|
|
describe("a task on a cycle", () => {
|
|
test("its status is by its cycle, and its caption names the start", () => {
|
|
const t = task({
|
|
period: { unit: "cycle", days: 14, from: "2026-10-16" },
|
|
measure: { kind: "days", required: 5 },
|
|
});
|
|
expect(TaskSchema.safeParse(t).success).toBe(true);
|
|
expect(taskCaption(t)).toBe("every 14 days from 16 Oct 2026 · 5 days");
|
|
const s = taskStatus({
|
|
task: t, region: "europe", now: noon("2026-10-20"),
|
|
logged: ["2026-10-15", "2026-10-16", "2026-10-18"],
|
|
});
|
|
expect(s).toMatchObject({ scope: "cycle", periodStart: "2026-10-16", count: 2, required: 5, open: 10 });
|
|
});
|
|
|
|
test("a short cycle never asks for more days than it holds", () => {
|
|
const t = task({
|
|
period: { unit: "cycle", days: 3, from: "2026-10-16" },
|
|
measure: { kind: "days", required: 5 },
|
|
});
|
|
expect(taskStatus({ task: t, region: "europe", now: noon("2026-10-16"), logged: [] }).required).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("a task on another server's clock", () => {
|
|
// NTE's Asia server rolls at 05:00 UTC+8, 21:00 UTC; its Europe server at 03:00 UTC.
|
|
const t = {
|
|
game: "nte",
|
|
period: { unit: "cycle" as const, days: 14, from: "2026-10-22" },
|
|
measure: { kind: "days" as const, required: 1 },
|
|
clock: "asia" as const,
|
|
};
|
|
|
|
test("its cycle rolls at that server's reset for a reader on another", () => {
|
|
const before = taskStatus({ task: t, region: "europe", now: Date.parse("2026-10-21T20:59:00Z"), logged: [] });
|
|
const after = taskStatus({ task: t, region: "europe", now: Date.parse("2026-10-21T21:01:00Z"), logged: [] });
|
|
expect(before.periodStart).toBe("2026-10-08");
|
|
expect(after.periodStart).toBe("2026-10-22");
|
|
});
|
|
|
|
test("without one, it rolls on the reader's own reset", () => {
|
|
const { clock: _, ...own } = t;
|
|
const after = taskStatus({ task: own, region: "europe", now: Date.parse("2026-10-21T21:01:00Z"), logged: [] });
|
|
expect(after.periodStart).toBe("2026-10-08");
|
|
});
|
|
});
|
|
|
|
describe("a cycle chip's days left", () => {
|
|
test("is the days left in the cycle, ticked or not", () => {
|
|
const t = task({ period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 } });
|
|
const at = (logged: string[], day: string) =>
|
|
goalCount(taskStatus({ task: t, region: "europe", now: noon(day), logged }));
|
|
expect(at([], "2026-10-20")).toBe("0/1 · 10d");
|
|
expect(at(["2026-10-20"], "2026-10-20")).toBe("1/1 · 10d");
|
|
expect(at(["2026-10-29"], "2026-10-29")).toBe("1/1 · 1d");
|
|
});
|
|
});
|