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, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent } from "@/components/MobileFeed";
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
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 = /