import { describe, expect, it } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { readFileSync } from "node:fs"; import { FeedView, type FeedViewProps } from "@/components/FeedView"; import { pickCurrent } from "@/components/MobileFeed"; import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context"; import { toCardPosts } from "@/lib/feed-model"; const NOW = Date.parse("2026-10-19T12:00:00Z"); const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({ type: "post", publishedAt: "2026-10-19T11:00:00Z", likesCount: 1, commentsCount: 0, post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems }, }); const POSTS = toCardPosts([ item("t1", "me"), item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]), item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]), ]); function render(over: Partial, compact = true) { const props: FeedViewProps = { posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over, }; return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props))); } describe("compact layout", () => { it("switches at 760px and below", () => { expect(isCompactWidth(390)).toBe(true); expect(isCompactWidth(760)).toBe(true); expect(isCompactWidth(761)).toBe(false); expect(isCompactWidth(1440)).toBe(false); expect(isCompactWidth(0)).toBe(false); }); it("keeps the desktop panel outside the phone layout", () => { const html = render({}, false); expect(html).toContain('data-testid="post-card"'); expect(html).not.toContain("feed-reel"); }); }); describe("mobile feed", () => { it("renders one slide per post, text as a quote and media under the fades", () => { const html = render({}); expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3); expect(html).toContain('class="slide-quote">text t1

'); expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/); expect(html).toContain("slide-scrim-top"); expect(html).toContain('class="slide-text">text i1

'); expect(html).toContain('aria-label="Play video"'); // The quote slide shows its text once, as the quote. expect(html.match(/text t1/g)).toHaveLength(1); }); it("marks the first slide as the one on screen", () => { const html = render({}); expect(html).toMatch(/class="slide slide-on-text is-cur"/); expect(html).toContain('data-current="0"'); }); it("shows the frosted menu only on the person's own posts", () => { const html = render({}); expect(html.match(/slide-menu-btn/g)).toHaveLength(1); expect(html).toContain('aria-label="More actions"'); expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn"); }); it("ends with a loading slide while another page can load", () => { expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"'); expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"'); expect(render({})).not.toContain("feed-slide-load"); const failed = render({ hasMore: true, loadMoreFailed: true }); expect(failed).not.toContain("feed-slide-load"); expect(failed).toContain("Try again"); }); it("shows the loading slide on first load", () => { expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"'); }); it("picks the slide that is mostly on screen", () => { expect(pickCurrent(0, [])).toBe(0); expect(pickCurrent(0, [1])).toBe(1); expect(pickCurrent(2, [1, 2])).toBe(2); expect(pickCurrent(4, [1, 2])).toBe(1); }); }); describe("mobile stylesheet", () => { const css = readFileSync("src/styles/mobile.css", "utf8"); it("snaps one slide per screen", () => { expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/); expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/); }); it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => { expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/); expect(css).toMatch(/\.slide-quote::before \{[^}]*color: var\(--accent\)/); expect(css).toMatch(/transform: scale\(1\.04\)/); expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/); }); it("uses tokens only", () => { expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/); }); }); describe("one step per gesture", () => { it("moves one slide in the gesture's direction and stays inside the reel", async () => { const { stepTarget } = await import("@/components/MobileFeed"); expect(stepTarget(0, 120, 5)).toBe(1); expect(stepTarget(3, -4, 5)).toBe(2); expect(stepTarget(4, 300, 5)).toBe(4); expect(stepTarget(0, -50, 5)).toBe(0); expect(stepTarget(2, 0, 5)).toBe(2); }); }); describe("video slide before play", () => { it("shows only the frosted play button, no native controls", () => { const html = render({}); const video = /]*>/.exec(html)?.[0] ?? ""; expect(video).not.toBe(""); expect(video).not.toContain("controls"); expect(html).toContain('class="post-play slide-play"'); }); });