Touch drags flip pages: the page follows the finger live, the axis locks on first movement so vertical pans never flip, and edge drags get rubber-band resistance. Swipes starting on interactive controls are ignored. Swipe intent lives in bookSwipeDirection with unit tests.
598 lines
19 KiB
TypeScript
598 lines
19 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
|
|
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
|
import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats";
|
|
import { bookColumnOffset, bookPageCount, bookSwipeDirection } from "@/lib/story/bookLayout";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `story-${Date.now().toString(36)}`;
|
|
|
|
// Minimal MPEG-1 Layer 3 frame-sync header: what `file-type` sniffs as
|
|
// audio/mpeg. The bytes must be wrapped in a realm-local Uint8Array, because
|
|
// vitest's jsdom environment swaps the global Uint8Array and Node Buffers then
|
|
// fail file-type's instanceof check (Payload would report the file unreadable).
|
|
function mp3FileData(): Buffer {
|
|
const buffer = Buffer.alloc(67);
|
|
buffer[0] = 0xff;
|
|
buffer[1] = 0xfb;
|
|
return new Uint8Array(buffer) as unknown as Buffer;
|
|
}
|
|
|
|
function textFileData(text: string): Buffer {
|
|
return new Uint8Array(Buffer.from(text)) as unknown as Buffer;
|
|
}
|
|
|
|
function lexicalBody(text: string): StoryBeat["body"] {
|
|
return {
|
|
root: {
|
|
type: "root",
|
|
format: "",
|
|
indent: 0,
|
|
version: 1,
|
|
direction: null,
|
|
children: [
|
|
{
|
|
type: "paragraph",
|
|
format: "",
|
|
indent: 0,
|
|
version: 1,
|
|
direction: null,
|
|
textFormat: 0,
|
|
textStyle: "",
|
|
children: [
|
|
{ type: "text", format: 0, version: 1, mode: "normal", style: "", text, detail: 0 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
} as StoryBeat["body"];
|
|
}
|
|
|
|
describe("Story beats", () => {
|
|
let admin: User;
|
|
let adminId: number;
|
|
let user: User;
|
|
let userId: number;
|
|
let otherUser: User;
|
|
let otherUserId: number;
|
|
const beatIds: number[] = [];
|
|
const stateIds: number[] = [];
|
|
const voiceoverIds: number[] = [];
|
|
let draftBeatId: number;
|
|
let publishedBeatId: number;
|
|
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
const createTestUser = async (name: string, roles: User["roles"]) => {
|
|
return (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-${name}`,
|
|
discordUsername: `${RUN}-${name}`,
|
|
displayName: name.toUpperCase(),
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test123",
|
|
roles,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
};
|
|
|
|
admin = await createTestUser("admin", ["admin"]);
|
|
adminId = admin.id;
|
|
user = await createTestUser("user", ["user"]);
|
|
userId = user.id;
|
|
otherUser = await createTestUser("other", ["user"]);
|
|
otherUserId = otherUser.id;
|
|
|
|
const draftBeat = (await payload.create({
|
|
collection: "story-beats",
|
|
data: {
|
|
title: `${RUN} Draft beat`,
|
|
status: "draft",
|
|
body: lexicalBody("A draft beat that should never be public."),
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as StoryBeat;
|
|
draftBeatId = draftBeat.id;
|
|
beatIds.push(draftBeatId);
|
|
|
|
const publishedBeat = (await payload.create({
|
|
collection: "story-beats",
|
|
data: {
|
|
title: `${RUN} Published beat`,
|
|
status: "published",
|
|
body: lexicalBody("A published beat body."),
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as StoryBeat;
|
|
publishedBeatId = publishedBeat.id;
|
|
beatIds.push(publishedBeatId);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const id of voiceoverIds) {
|
|
await payload
|
|
.delete({ collection: "story-voiceovers", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
for (const id of stateIds) {
|
|
await payload
|
|
.delete({ collection: "story-beat-states", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
for (const id of beatIds) {
|
|
await payload.delete({ collection: "story-beats", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of [adminId, userId, otherUserId]) {
|
|
const accounts = await payload
|
|
.find({
|
|
collection: "bank-accounts",
|
|
where: { ownerUser: { equals: id } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const account of accounts?.docs ?? []) {
|
|
await payload
|
|
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
const profiles = await payload
|
|
.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: id } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const profile of profiles?.docs ?? []) {
|
|
await payload
|
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
describe("visibility gate", () => {
|
|
it("hides all beats from guests", async () => {
|
|
await expect(
|
|
payload.find({ collection: "story-beats", overrideAccess: false }),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
payload.findByID({
|
|
collection: "story-beats",
|
|
id: publishedBeatId,
|
|
overrideAccess: false,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("shows only published beats to normal users", async () => {
|
|
const result = (await payload.find({
|
|
collection: "story-beats",
|
|
where: { title: { like: RUN } },
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
})) as unknown as { docs: StoryBeat[] };
|
|
expect(result.docs).toHaveLength(1);
|
|
expect(result.docs[0]?.id).toBe(publishedBeatId);
|
|
expect(result.docs[0]?.status).toBe("published");
|
|
|
|
await expect(
|
|
payload.findByID({
|
|
collection: "story-beats",
|
|
id: draftBeatId,
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("shows all beats to admin/developer authors", async () => {
|
|
const result = (await payload.find({
|
|
collection: "story-beats",
|
|
where: { title: { like: RUN } },
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as { docs: StoryBeat[] };
|
|
const ids = result.docs.map((beat) => beat.id).sort();
|
|
expect(ids).toEqual([draftBeatId, publishedBeatId].sort());
|
|
});
|
|
|
|
it("blocks normal users from creating beats", async () => {
|
|
await expect(
|
|
payload.create({
|
|
collection: "story-beats",
|
|
data: { title: "nope", status: "draft", body: lexicalBody("nope") },
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("allows admin authors to create and publish beats", async () => {
|
|
const created = (await payload.create({
|
|
collection: "story-beats",
|
|
data: { title: `${RUN} Authoring check`, status: "draft", body: lexicalBody("wip") },
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as StoryBeat;
|
|
beatIds.push(created.id);
|
|
const published = (await payload.update({
|
|
collection: "story-beats",
|
|
id: created.id,
|
|
data: { status: "published" },
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as StoryBeat;
|
|
expect(published.status).toBe("published");
|
|
});
|
|
});
|
|
|
|
describe("read state", () => {
|
|
it("marks a published beat read for a normal user", async () => {
|
|
const state = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
|
stateIds.push(state.id);
|
|
expect(state.readAt).toBeTruthy();
|
|
});
|
|
|
|
it("is idempotent: a second read does not change readAt", async () => {
|
|
const first = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
|
const second = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
|
expect(second.id).toBe(first.id);
|
|
expect(second.readAt).toBe(first.readAt);
|
|
});
|
|
|
|
it("refuses to mark a draft beat read for a normal user", async () => {
|
|
await expect(markBeatRead(payload, { user, beatId: draftBeatId })).rejects.toThrow();
|
|
});
|
|
|
|
it("scopes state docs to their owner", async () => {
|
|
const result = (await payload.find({
|
|
collection: "story-beat-states",
|
|
where: { id: { in: stateIds } },
|
|
overrideAccess: false,
|
|
user: otherUser,
|
|
depth: 0,
|
|
})) as unknown as { docs: StoryBeatState[] };
|
|
expect(result.docs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("reactions", () => {
|
|
it("sets and replaces reactions", async () => {
|
|
const heart = await setBeatReaction(payload, {
|
|
user,
|
|
beatId: publishedBeatId,
|
|
reaction: "heart",
|
|
});
|
|
expect(heart.reaction).toBe("heart");
|
|
const salute = await setBeatReaction(payload, {
|
|
user,
|
|
beatId: publishedBeatId,
|
|
reaction: "salute",
|
|
});
|
|
expect(salute.reaction).toBe("salute");
|
|
});
|
|
|
|
it("toggles the same reaction off", async () => {
|
|
const set = await setBeatReaction(payload, {
|
|
user,
|
|
beatId: publishedBeatId,
|
|
reaction: "like",
|
|
});
|
|
expect(set.reaction).toBe("like");
|
|
const cleared = await setBeatReaction(payload, {
|
|
user,
|
|
beatId: publishedBeatId,
|
|
reaction: "like",
|
|
});
|
|
expect(cleared.reaction).toBeNull();
|
|
});
|
|
|
|
it("rejects invalid reaction values", async () => {
|
|
await expect(
|
|
setBeatReaction(payload, {
|
|
user,
|
|
beatId: publishedBeatId,
|
|
reaction: "fire" as never,
|
|
}),
|
|
).rejects.toThrow("Invalid reaction.");
|
|
});
|
|
|
|
it("refuses reactions on draft beats for normal users", async () => {
|
|
await expect(
|
|
setBeatReaction(payload, { user, beatId: draftBeatId, reaction: "heart" }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("persists reaction state on the user's beat-state doc", async () => {
|
|
await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "salute" });
|
|
const result = (await payload.find({
|
|
collection: "story-beat-states",
|
|
where: { and: [{ user: { equals: userId } }, { beat: { equals: publishedBeatId } }] },
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
})) as unknown as { docs: StoryBeatState[] };
|
|
expect(result.docs).toHaveLength(1);
|
|
expect(result.docs[0]?.reaction).toBe("salute");
|
|
expect(result.docs[0]?.readAt).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("voiceovers", () => {
|
|
let voiceoverId: number;
|
|
|
|
it("blocks normal users from creating voiceovers", async () => {
|
|
await expect(
|
|
payload.create({
|
|
collection: "story-voiceovers",
|
|
data: {},
|
|
file: {
|
|
data: mp3FileData(),
|
|
mimetype: "audio/mpeg",
|
|
name: `${RUN}-ch0.mp3`,
|
|
size: 67,
|
|
},
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("allows admin authors to upload audio files", async () => {
|
|
const created = (await payload.create({
|
|
collection: "story-voiceovers",
|
|
data: {},
|
|
file: {
|
|
data: mp3FileData(),
|
|
mimetype: "audio/mpeg",
|
|
name: `${RUN}-ch1.mp3`,
|
|
size: 67,
|
|
},
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as StoryVoiceover;
|
|
voiceoverId = created.id;
|
|
voiceoverIds.push(created.id);
|
|
expect(created.filename).toBe(`${RUN}-ch1.mp3`);
|
|
expect(created.mimeType).toBe("audio/mpeg");
|
|
expect(created.url).toBeTruthy();
|
|
});
|
|
|
|
it("hides voiceovers from guests but lets any logged-in user read them", async () => {
|
|
await expect(
|
|
payload.find({ collection: "story-voiceovers", overrideAccess: false }),
|
|
).rejects.toThrow();
|
|
const result = (await payload.find({
|
|
collection: "story-voiceovers",
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
})) as unknown as { docs: StoryVoiceover[] };
|
|
expect(result.docs.map((doc) => doc.id)).toContain(voiceoverId);
|
|
});
|
|
|
|
it("flags AI-generated narrations and defaults the checkbox to false", async () => {
|
|
const ai = (await payload.create({
|
|
collection: "story-voiceovers",
|
|
data: { aiGenerated: true },
|
|
file: {
|
|
data: mp3FileData(),
|
|
mimetype: "audio/mpeg",
|
|
name: `${RUN}-ch2-ai.mp3`,
|
|
size: 67,
|
|
},
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as StoryVoiceover;
|
|
voiceoverIds.push(ai.id);
|
|
expect(ai.aiGenerated).toBe(true);
|
|
|
|
const human = (await payload.findByID({
|
|
collection: "story-voiceovers",
|
|
id: voiceoverId,
|
|
overrideAccess: false,
|
|
user,
|
|
depth: 0,
|
|
})) as unknown as StoryVoiceover;
|
|
expect(human.aiGenerated).toBe(false);
|
|
});
|
|
|
|
it("roundtrips a beat voiceover as a hydrated upload doc at depth 1", async () => {
|
|
const beat = (await payload.create({
|
|
collection: "story-beats",
|
|
data: {
|
|
title: `${RUN} Voiceover beat`,
|
|
status: "published",
|
|
body: lexicalBody("Narrated chapter."),
|
|
voiceover: voiceoverId,
|
|
},
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
})) as unknown as StoryBeat;
|
|
beatIds.push(beat.id);
|
|
expect(beat.voiceover).toBe(voiceoverId);
|
|
|
|
const found = (await payload.findByID({
|
|
collection: "story-beats",
|
|
id: beat.id,
|
|
depth: 1,
|
|
overrideAccess: false,
|
|
user,
|
|
})) as unknown as StoryBeat;
|
|
const doc = found.voiceover && typeof found.voiceover !== "number" ? found.voiceover : null;
|
|
expect(doc).toBeTruthy();
|
|
expect(doc?.url).toBeTruthy();
|
|
expect(doc?.mimeType).toBe("audio/mpeg");
|
|
expect(doc?.aiGenerated).toBe(false);
|
|
});
|
|
|
|
it("rejects non-audio file types", async () => {
|
|
await expect(
|
|
payload.create({
|
|
collection: "story-voiceovers",
|
|
data: {},
|
|
file: {
|
|
data: textFileData("plain text, definitely not audio"),
|
|
mimetype: "text/plain",
|
|
name: `${RUN}-note.txt`,
|
|
size: 34,
|
|
},
|
|
overrideAccess: false,
|
|
user: admin,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sortBeats (chapter ordering)", () => {
|
|
const baseBeat = {
|
|
status: "published" as const,
|
|
body: {} as StoryBeat["body"],
|
|
};
|
|
|
|
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
|
|
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
|
|
}
|
|
|
|
it("defaults to real creation date ascending, ignoring in-story timestamps", () => {
|
|
// Fictional in-story dates deliberately out of release order.
|
|
const prologue = beat({
|
|
id: 1,
|
|
title: "Little Green Light",
|
|
createdAt: "2026-09-20T10:00:00Z",
|
|
occurredAt: new Date("2156-03-18T08:00:00Z").toISOString(),
|
|
});
|
|
const followUp = beat({
|
|
id: 2,
|
|
title: "What Makes Us Human",
|
|
createdAt: "2026-09-24T10:00:00Z",
|
|
occurredAt: new Date("2034-06-12T08:00:00Z").toISOString(),
|
|
});
|
|
|
|
const sorted = sortBeats([followUp, prologue]);
|
|
expect(sorted.map((beat) => beat.id)).toEqual([1, 2]);
|
|
});
|
|
|
|
it("puts explicitly ordered beats first, sorted by their order value", () => {
|
|
const old = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
|
|
const newer = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z", order: 1 });
|
|
const pinnedLast = beat({ id: 3, createdAt: "2026-03-01T00:00:00Z", order: 2 });
|
|
// order = 0 is a valid curation value and must still lead the list.
|
|
const pinnedFirst = beat({ id: 4, createdAt: "2026-04-01T00:00:00Z", order: 0 });
|
|
|
|
const sorted = sortBeats([old, pinnedFirst, newer, pinnedLast]);
|
|
expect(sorted.map((beat) => beat.id)).toEqual([4, 2, 3, 1]);
|
|
});
|
|
|
|
it("does not mutate the input array", () => {
|
|
const a = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
|
|
const b = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z" });
|
|
const input = [b, a];
|
|
|
|
sortBeats(input);
|
|
expect(input.map((beat) => beat.id)).toEqual([2, 1]);
|
|
});
|
|
});
|
|
|
|
describe("chronicleDisplayOrder (page display)", () => {
|
|
const baseBeat = {
|
|
status: "published" as const,
|
|
body: {} as StoryBeat["body"],
|
|
};
|
|
|
|
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
|
|
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
|
|
}
|
|
|
|
it("shows the newest chapter first by default, with chapter numbers still ascending", () => {
|
|
const ch1 = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
|
|
const ch2 = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
|
|
const ch3 = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z" });
|
|
|
|
const { beats, chapterById } = chronicleDisplayOrder([ch1, ch2, ch3]);
|
|
|
|
expect(beats.map((beat) => beat.id)).toEqual([3, 2, 1]);
|
|
expect(chapterById.get(1)).toBe(1);
|
|
expect(chapterById.get(2)).toBe(2);
|
|
expect(chapterById.get(3)).toBe(3);
|
|
});
|
|
|
|
it("switches to book mode when any beat is hand-ordered", () => {
|
|
const a = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
|
|
const b = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
|
|
const c = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z", order: 1 });
|
|
|
|
const { beats, chapterById } = chronicleDisplayOrder([a, b, c]);
|
|
|
|
expect(beats.map((beat) => beat.id)).toEqual([3, 1, 2]);
|
|
expect(chapterById.get(3)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("bookLayout (Book view paging)", () => {
|
|
it("counts one page when content fits the viewport", () => {
|
|
// One 2-up page: scrollWidth = column width = (1200 - 32) / 2 = 584.
|
|
expect(bookPageCount(584, 1200, 2)).toBe(1);
|
|
// scrollWidth == viewport width means both visible pages are exactly full: 2 pages.
|
|
expect(bookPageCount(1200, 1200, 2)).toBe(2);
|
|
});
|
|
|
|
it("counts pages from the measured multicol scrollWidth", () => {
|
|
// 3 pages at 2-up: scrollWidth = 3 * pitch - gap = 3 * 616 - 32 = 1816.
|
|
expect(bookPageCount(1816, 1200, 2)).toBe(3);
|
|
// A lone right page (2.5 pages of content) still counts as its own page.
|
|
expect(bookPageCount(1816 + 300, 1200, 2)).toBe(4);
|
|
});
|
|
|
|
it("pays the gap once per page in single-column mode", () => {
|
|
// 2 pages at 1-up, viewport 700: scrollWidth = 2 * 732 - 32 = 1432.
|
|
expect(bookPageCount(1432, 700, 1)).toBe(2);
|
|
});
|
|
|
|
it("computes the flip offset as pageIndex * pitch", () => {
|
|
expect(bookColumnOffset(0, 1200, 2)).toBe(0);
|
|
expect(bookColumnOffset(2, 1200, 2)).toBe(1232);
|
|
expect(bookColumnOffset(1, 700, 1)).toBe(732);
|
|
});
|
|
|
|
it("decides swipe intent from direction and travel", () => {
|
|
// Swipe left (negative dx) flips forward once past the threshold.
|
|
expect(bookSwipeDirection(-100, 0, 400)).toBe(1);
|
|
// Swipe right flips back.
|
|
expect(bookSwipeDirection(100, 0, 400)).toBe(-1);
|
|
// Threshold is max(48, 18% of the page width): 72px at 400 wide.
|
|
expect(bookSwipeDirection(-72, 0, 400)).toBe(1);
|
|
expect(bookSwipeDirection(-30, 0, 400)).toBe(0);
|
|
// Vertical or diagonal drags never flip pages.
|
|
expect(bookSwipeDirection(0, 100, 400)).toBe(0);
|
|
expect(bookSwipeDirection(60, 80, 400)).toBe(0);
|
|
});
|
|
});
|