The PRD said a reader's rule could optionally stop on a date; the until control was deliberately descoped from the form during planning, and the PRD was written as though it shipped. Corrected the PRD to describe what actually ships, and noted that the field still exists in the schema — reachable today only by importing a file that carries one. That gap had a second-order bug behind it: because the edit form rebuilt `repeat` from scratch on every save, hard-coding `until: null`, a rule that already carried a non-null `until` had it silently reset to eternal on any edit at all, including a pure title fix. Extracted the `repeat`-building logic into `repeatFrom`, which now carries `initial?.repeat?.until` forward, and is exported so the behaviour is provable without a submit nothing in this suite's renderToStaticMarkup tests can click. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import {
|
|
cadenceLabel,
|
|
EventForm,
|
|
repeatFrom,
|
|
strandedNotice,
|
|
} from "../src/client/components/CustomForms.tsx";
|
|
import { YourOwn } from "../src/client/components/YourOwn.tsx";
|
|
import { EventRow } from "../src/client/components/EventRow.tsx";
|
|
import { EventDetail } from "../src/client/components/EventDetail.tsx";
|
|
import { AUTHOR, Colophon, REPO_URL } from "../src/client/components/Colophon.tsx";
|
|
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
|
import {
|
|
asDisplayEvent,
|
|
CustomEvent,
|
|
type CustomEvents,
|
|
type CustomGames,
|
|
type DisplayEvent,
|
|
} from "../src/shared/custom.ts";
|
|
import { metaFor } from "../src/shared/games.ts";
|
|
import { clockFor } from "../src/shared/time.ts";
|
|
|
|
/**
|
|
* Static-render checks on the F13 surfaces.
|
|
*
|
|
* Not a substitute for using the thing, but they pin the two claims the feature
|
|
* makes to a reader: their event is marked as theirs, and the end date is
|
|
* allowed to be unknown.
|
|
*/
|
|
|
|
const AT = "2026-08-17T12:00:00.000Z";
|
|
|
|
const GAMES: CustomGames = {
|
|
"mygame:limbus-company": {
|
|
id: "mygame:limbus-company",
|
|
name: "Limbus Company",
|
|
hue: "#C74B50",
|
|
at: AT,
|
|
},
|
|
};
|
|
|
|
const OWN = CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Walpurgisnacht",
|
|
type: "banner",
|
|
summary: null,
|
|
startsAt: "2026-08-20T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-03T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
at: AT,
|
|
updatedAt: AT,
|
|
});
|
|
|
|
const EVENTS: CustomEvents = { [OWN.id]: OWN };
|
|
|
|
function render(node: React.ReactElement): string {
|
|
return renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>{node}</GameMetaProvider>,
|
|
);
|
|
}
|
|
|
|
describe("YourOwn", () => {
|
|
const noop = () => {};
|
|
const props = {
|
|
games: GAMES,
|
|
events: EVENTS,
|
|
lanes: ["genshin", "mygame:limbus-company"],
|
|
onAddGame: noop,
|
|
onEditGame: noop,
|
|
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
|
|
onAddEvent: noop,
|
|
};
|
|
|
|
test("lists a reader's game with the events it holds", () => {
|
|
const html = render(<YourOwn {...props} />);
|
|
expect(html).toContain("Limbus Company");
|
|
expect(html).toContain("1 event");
|
|
expect(html).toContain("#C74B50");
|
|
});
|
|
|
|
test("shows a game with nothing in it rather than hiding it", () => {
|
|
// A game they just made has to appear before it holds anything, or adding
|
|
// one looks like it did nothing.
|
|
const html = render(<YourOwn {...props} events={{}} />);
|
|
expect(html).toContain("no events yet");
|
|
});
|
|
});
|
|
|
|
describe("EventForm", () => {
|
|
test("offers an unknown end rather than demanding a date", () => {
|
|
// The whole point: a form that made the end mandatory would push the reader
|
|
// into inventing one, which is the failure the parsers are forbidden from.
|
|
const html = render(
|
|
<EventForm
|
|
lanes={["genshin", "mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("I don't know when it ends");
|
|
});
|
|
|
|
test("puts the reader's own games at the top of the picker, and defaults to one", () => {
|
|
// Someone entering an event by hand is usually doing it because the game
|
|
// isn't tracked. Two of theirs to prove the group order, not just a swap.
|
|
const twoOfMine: CustomGames = {
|
|
...GAMES,
|
|
"mygame:silver-palace": {
|
|
id: "mygame:silver-palace",
|
|
name: "Silver Palace",
|
|
hue: "#5C7CE0",
|
|
at: AT,
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, twoOfMine)}>
|
|
<EventForm
|
|
lanes={["genshin", "hsr", "mygame:limbus-company", "mygame:silver-palace"]}
|
|
customGames={twoOfMine}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
|
|
// The game picker is the first select; the second is the event kind.
|
|
const picker = html.slice(0, html.indexOf("</select>"));
|
|
const order = [...picker.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
|
|
expect(order).toEqual([
|
|
"mygame:limbus-company",
|
|
"mygame:silver-palace",
|
|
// Tracked games keep their feed order behind them — the sort groups, it
|
|
// does not reshuffle.
|
|
"genshin",
|
|
"hsr",
|
|
]);
|
|
// The default follows the top of the list rather than staying on Genshin.
|
|
expect(html).toContain('value="mygame:limbus-company" selected');
|
|
});
|
|
|
|
test("lets an event be filed under a tracked game too", () => {
|
|
// A source can miss an event in a game we do cover.
|
|
const html = render(
|
|
<EventForm
|
|
lanes={["genshin", "mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Genshin Impact");
|
|
expect(html).toContain("Limbus Company (yours)");
|
|
});
|
|
});
|
|
|
|
describe("EventRow provenance", () => {
|
|
const row = (id: string) => {
|
|
const event = { ...asDisplayEvent(OWN), id };
|
|
return { event, clock: clockFor(event, "europe", Date.parse(AT)) };
|
|
};
|
|
|
|
test("marks the reader's own event as theirs", () => {
|
|
const html = render(
|
|
<ul>
|
|
<EventRow
|
|
row={row(OWN.id)}
|
|
now={Date.parse(AT)}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
expect(html).toContain("yours");
|
|
});
|
|
|
|
test("does not mark a scraped event as theirs", () => {
|
|
const html = render(
|
|
<ul>
|
|
<EventRow
|
|
row={row("genshin:windblume-festival:2026-03-14")}
|
|
now={Date.parse(AT)}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
expect(html).not.toContain(">yours<");
|
|
});
|
|
|
|
test("counts from the instant it is handed, not from the wall clock", () => {
|
|
// The row used to ask `Date.now()` for its caption and its "starts in",
|
|
// which is why neither could be asserted at all: the numbers moved with
|
|
// whenever the suite happened to run. Rendering the same row at two instants
|
|
// has to produce two different countdowns, and both have to be the ones the
|
|
// injected clock implies.
|
|
const event = { ...asDisplayEvent(OWN), id: OWN.id };
|
|
const at = Date.parse(AT);
|
|
// A window opening in two days, so the row takes its "starts in" branch.
|
|
const upcoming = {
|
|
...event,
|
|
startsAt: new Date(at + 2 * 24 * 3_600_000).toISOString(),
|
|
};
|
|
const at2 = (ms: number) =>
|
|
render(
|
|
<ul>
|
|
<EventRow
|
|
row={{ event: upcoming, clock: clockFor(upcoming, "europe", ms) }}
|
|
now={ms}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
|
|
expect(at2(at)).toContain("starts in 2d");
|
|
// A day later the same row says one day, with nothing about the real clock
|
|
// involved in either answer.
|
|
expect(at2(at + 24 * 3_600_000)).toContain("starts in 1d");
|
|
});
|
|
});
|
|
|
|
describe("Colophon freshness notice (PRD F7)", () => {
|
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
const fresh = {
|
|
sourceId: "genshin-game8-events",
|
|
game: "genshin" as const,
|
|
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
|
lastSuccessAt: new Date(NOW - 3 * HOUR).toISOString(),
|
|
eventCount: 9,
|
|
};
|
|
|
|
test("states when the data was refreshed, unprompted", () => {
|
|
// Always rendered, not only on a problem: a footer that says nothing about
|
|
// its own age reads as current.
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
expect(html).toContain("Event data last refreshed");
|
|
expect(html).toContain("3h 0m ago");
|
|
// The machine-readable instant is there for anyone checking the claim.
|
|
// Matched case-insensitively: React emits the JSX spelling verbatim, and
|
|
// HTML attribute names are case-insensitive, so either is correct.
|
|
expect(html).toMatch(
|
|
new RegExp(`<time [^>]*datetime="${fresh.lastSuccessAt}"`, "i"),
|
|
);
|
|
expect(html).not.toContain("not refreshed in over two days");
|
|
});
|
|
|
|
test("names the games that are behind, with how far", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{ ...fresh, sourceId: "nikki-game8-events", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
// A count cannot be acted on; a name tells the reader which source page to
|
|
// go and check.
|
|
expect(html).toContain("Infinity Nikki");
|
|
expect(html).toContain("not refreshed in over two days");
|
|
expect(html).toContain("3d 8h ago");
|
|
// The headline still reports the freshest confirmation.
|
|
expect(html).toContain("3h 0m ago");
|
|
});
|
|
|
|
test("summarises instead of listing when every game is behind", () => {
|
|
// What a refresh that stopped running looks like. Ten names each repeating
|
|
// the same age is less readable than the count this replaced.
|
|
const behind = (["genshin", "hsr", "zzz"] as const).map((game, i) => ({
|
|
...fresh,
|
|
sourceId: `${game}-src`,
|
|
game,
|
|
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
|
}));
|
|
const html = renderToStaticMarkup(<Colophon sources={behind} now={NOW} />);
|
|
expect(html).toContain("Nothing has refreshed in over two days");
|
|
expect(html).not.toContain("Genshin Impact (");
|
|
});
|
|
|
|
test("caps the list and counts the remainder", () => {
|
|
const behind = (["hsr", "zzz", "wuwa", "nte", "nikki", "p5x"] as const).map(
|
|
(game, i) => ({
|
|
...fresh,
|
|
sourceId: `${game}-src`,
|
|
game,
|
|
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
|
}),
|
|
);
|
|
// `fresh` is Genshin, a game absent from the list above — otherwise its
|
|
// sibling source would drag Genshin stale too and every game would be
|
|
// behind, which is the other branch.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[...behind, fresh]} now={NOW} />,
|
|
);
|
|
expect(html).toContain("and 2 other games");
|
|
// Oldest first, so the two dropped are the *least* overdue, not an
|
|
// arbitrary pair: P5X at 85h is named, Star Rail at 80h is summarised.
|
|
expect(html).toContain("Persona 5: The Phantom X (");
|
|
expect(html).not.toContain("Honkai: Star Rail (");
|
|
});
|
|
|
|
test("says so plainly when nothing has ever been fetched", () => {
|
|
// A fresh checkout with no fixtures. The headline must not format a null,
|
|
// and with every game unfetched the list collapses to the sentence.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[{ ...fresh, lastSuccessAt: null }]} now={NOW} />,
|
|
);
|
|
expect(html).toContain("no source has been fetched yet");
|
|
expect(html).toContain("Nothing has refreshed in over two days");
|
|
});
|
|
|
|
test("marks a never-fetched source as never, beside games that have", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[fresh, { ...fresh, sourceId: "r1999-src", game: "r1999", lastSuccessAt: null }]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Reverse: 1999 (never)");
|
|
});
|
|
|
|
test("the author's links sit together, above the ideas credit", () => {
|
|
// The row is who built this and where to find them; the credit below it is
|
|
// other people. Reading order follows that, so a reader scanning the footer
|
|
// does not pass a stranger's handle on the way to the author's own links.
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
const links = html.indexOf("@StereotypicalCat");
|
|
const ideas = html.indexOf("Additional ideas");
|
|
expect(links).toBeGreaterThan(-1);
|
|
expect(ideas).toBeGreaterThan(-1);
|
|
expect(links).toBeLessThan(ideas);
|
|
});
|
|
|
|
test("carries the Ko-fi link, in the row and not as an appeal", () => {
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
expect(html).toContain(AUTHOR.kofi);
|
|
// Last in the author's row, so it never lands between the disclaimer and
|
|
// the reader — and never louder than the links beside it.
|
|
expect(html.indexOf(AUTHOR.kofi)).toBeGreaterThan(html.indexOf(REPO_URL));
|
|
expect(html.indexOf(AUTHOR.kofi)).toBeLessThan(html.indexOf("Additional ideas"));
|
|
// Unobtrusive is a property of the markup, not a matter of taste: the same
|
|
// muted class as its neighbours, and no ask anywhere in the footer.
|
|
expect(html).not.toMatch(/support me|buy me|donate|tip jar/i);
|
|
});
|
|
});
|
|
|
|
describe("stating a repeat", () => {
|
|
const repeating = (over: Record<string, unknown> = {}) =>
|
|
CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Abyss",
|
|
type: "challenge",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: { unit: "weeks", interval: 2, until: null },
|
|
at: AT,
|
|
updatedAt: AT,
|
|
...over,
|
|
});
|
|
|
|
test("a fresh form offers a repeat, set to never", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm lanes={["mygame:limbus-company"]} customGames={GAMES} onSave={() => {}} onCancel={() => {}} />,
|
|
);
|
|
expect(html).toContain("Repeats");
|
|
// The interval field is hidden until there is something to count, so the
|
|
// form a reader already knows is unchanged until they reach for this.
|
|
expect(html).not.toContain("Every");
|
|
});
|
|
|
|
test("editing a rule shows the rule it already has", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating()}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Every");
|
|
expect(html).toContain('value="2"');
|
|
});
|
|
|
|
test("an unknown end with a rule stops claiming there is no countdown", () => {
|
|
// "It'll show with no countdown and no daily checklist" is true of an
|
|
// unbounded event and false once an interval bounds it. Leaving it there
|
|
// would talk a reader out of the simplest way to record a weekly reset.
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown" })}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("until the next one opens");
|
|
expect(html).not.toContain("no countdown");
|
|
});
|
|
|
|
test("an unknown end with no rule keeps the original note", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: null })}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("no countdown");
|
|
});
|
|
});
|
|
|
|
describe("repeatFrom", () => {
|
|
test("a fresh rule has no `until` to carry — there is no control for one", () => {
|
|
expect(repeatFrom("weeks", 2, null)).toEqual({ unit: "weeks", interval: 2, until: null });
|
|
});
|
|
|
|
test("an edit preserves the `until` already on the record", () => {
|
|
// The form has no field for this, so the only way it can end up on the
|
|
// save is by surviving from what was already there — an import-sourced
|
|
// rule, since that is the only door `until` has today.
|
|
const existing = "2027-01-01T00:00:00.000Z";
|
|
expect(repeatFrom("weeks", 2, existing)).toEqual({
|
|
unit: "weeks",
|
|
interval: 2,
|
|
until: existing,
|
|
});
|
|
// It survives a schedule change too — changing the unit or interval is a
|
|
// different edit from changing when the series stops, and the reader
|
|
// never touched the latter.
|
|
expect(repeatFrom("months", 1, existing)).toEqual({
|
|
unit: "months",
|
|
interval: 1,
|
|
until: existing,
|
|
});
|
|
});
|
|
|
|
test("turning the repeat off drops it, existing `until` included", () => {
|
|
expect(repeatFrom("never", 2, "2027-01-01T00:00:00.000Z")).toBeNull();
|
|
});
|
|
|
|
test("an invalid interval is refused the same way regardless of `until`", () => {
|
|
expect(repeatFrom("weeks", 0, "2027-01-01T00:00:00.000Z")).toBeNull();
|
|
expect(repeatFrom("weeks", 400, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("what a reschedule costs", () => {
|
|
test("says nothing when nothing would be stranded", () => {
|
|
expect(strandedNotice(0)).toBe(null);
|
|
});
|
|
|
|
test("counts, and agrees with itself about plurals", () => {
|
|
expect(strandedNotice(1)).toContain("1 tick");
|
|
expect(strandedNotice(1)).not.toContain("ticks");
|
|
expect(strandedNotice(3)).toContain("3 ticks");
|
|
});
|
|
|
|
test("says what happens, not what is forbidden", () => {
|
|
// It informs; it never blocks. Their data is theirs to reorganise, and a
|
|
// form that refused the edit would be a worse answer than one that says
|
|
// what it costs — removeGame refuses because a cascade is unrecoverable,
|
|
// and an orphaned mark is not.
|
|
expect(strandedNotice(3)!.toLowerCase()).toContain("strand");
|
|
});
|
|
});
|
|
|
|
describe("the sheet says how often", () => {
|
|
test("a repeating event shows its cadence", () => {
|
|
const rule = CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Abyss",
|
|
type: "challenge",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: { unit: "weeks", interval: 2, until: null },
|
|
at: AT,
|
|
updatedAt: AT,
|
|
});
|
|
expect(cadenceLabel(rule.repeat)).toBe("every 2 weeks");
|
|
});
|
|
|
|
test("an interval of one drops the number and the plural", () => {
|
|
expect(cadenceLabel({ unit: "weeks", interval: 1, until: null })).toBe("every week");
|
|
expect(cadenceLabel({ unit: "months", interval: 1, until: null })).toBe("every month");
|
|
});
|
|
|
|
test("a non-repeating event has no cadence to show", () => {
|
|
expect(cadenceLabel(null)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe("the derived-boundary note", () => {
|
|
const NOW = Date.parse("2026-08-25T12:00:00.000Z");
|
|
|
|
const noop = () => {};
|
|
const detailProps = {
|
|
completed: false,
|
|
ignored: false,
|
|
status: undefined,
|
|
effort: undefined,
|
|
note: "",
|
|
region: "europe" as const,
|
|
now: NOW,
|
|
daily: false,
|
|
detectedDaily: false,
|
|
dailyDays: [],
|
|
onDaily: noop,
|
|
onToggleDay: noop,
|
|
onIgnore: noop,
|
|
onStatus: noop,
|
|
onEffort: noop,
|
|
onNote: noop,
|
|
onClose: noop,
|
|
};
|
|
|
|
// A parser declining to guess a time of day — the case the note was written
|
|
// for. `dates.ts` stores the placeholder as 00:00Z, and `boundaryMs` reads
|
|
// it against the game's own server reset rather than literally.
|
|
const PARSED: DisplayEvent = {
|
|
id: "genshin:walpurgisnacht:2026-09-03",
|
|
game: "genshin",
|
|
title: "Walpurgisnacht",
|
|
type: "banner",
|
|
summary: null,
|
|
startsAt: "2026-08-20T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-03T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
regionScoped: false,
|
|
regionEnds: null,
|
|
sourceUrl: "https://example.com/events",
|
|
sourceId: "genshin-game8-events",
|
|
status: "published",
|
|
confidence: 1,
|
|
extractionMethod: "parser",
|
|
version: 1,
|
|
firstSeenAt: AT,
|
|
updatedAt: AT,
|
|
};
|
|
|
|
test("present for a parser-sourced day-precision event", () => {
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
expect(html).toContain("server reset");
|
|
});
|
|
|
|
test("absent for a reader's own day-precision event", () => {
|
|
// False three times over: no source, nobody "gave" a date, and
|
|
// `boundaryMs` only applies the reset shift for `extractionMethod ===
|
|
// "parser"` — a reader's own event is "manual" even at day precision.
|
|
const own = asDisplayEvent(OWN);
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
row={{ event: own, clock: clockFor(own, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
expect(html).not.toContain("server reset");
|
|
});
|
|
});
|