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"; import { pickQuoteFit, QUOTE_SIZES } from "@/components/SlideText"; 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).toMatch(/class="slide-quote" data-fit-size="31" style="font-size:31px">text t1<\/p>/); expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/); expect(html).toContain("slide-scrim-top"); expect(html).toMatch(/class="slide-text"[^>]*>text i1<\/p>/); 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 slide-own"/); 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-mark \{[^}]*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"'); }); }); describe("double-tap detection", () => { it("needs two taps within 300ms", () => { expect(isDoubleTap(null, 100)).toBe(false); expect(isDoubleTap(100, 250)).toBe(true); expect(isDoubleTap(100, 400)).toBe(true); expect(isDoubleTap(100, 401)).toBe(false); }); it("ignores taps on controls", () => { const el = (html: string) => { const fake = (match: boolean) => ({ closest: () => (match ? {} : null) }); return html === "slide" ? fake(false) : fake(true); }; expect(isTapTarget(el("slide") as unknown as EventTarget)).toBe(true); expect(isTapTarget(el("button") as unknown as EventTarget)).toBe(false); }); }); describe("featured experts slide", () => { it("sits after the second post and only with experts", () => { expect(featuredSlideIndex(3, 5)).toBe(2); expect(featuredSlideIndex(2, 5)).toBe(2); expect(featuredSlideIndex(1, 5)).toBe(-1); expect(featuredSlideIndex(3, 0)).toBe(-1); }); it("renders the title, eyebrow, hint and a 156px carousel as a non-post slide", () => { const out = render({ featuredExperts: FIXTURE_DISCOVER.featuredExperts }); expect(out).toContain('data-slide-kind="featured"'); expect(out).toContain("Featured experts"); expect(out).toContain("Ask the people everyone is talking about"); expect(out).toContain("--cfw:156px"); expect(out).toContain("Keep scrolling"); expect(out.match(/data-testid="feed-slide"/g)).toHaveLength(3); // After the second post, before the third. expect(out.indexOf("Author i1")).toBeLessThan(out.indexOf("feed-slide-featured")); expect(out.indexOf("feed-slide-featured")).toBeLessThan(out.indexOf("Author v1")); }); it("is absent without experts", () => { expect(render({})).not.toContain("feed-slide-featured"); }); }); describe("top bar and progress rail", () => { it("renders the brand pill, the theme toggle and the rail", () => { const html = render({}); expect(html).toContain('data-testid="top-bar"'); expect(html).toContain("MINNECT"); expect(html).toMatch(/data-testid="top-theme"[^>]*aria-label="Switch to (light|dark) theme"/); expect(html).toContain('data-testid="feed-rail"'); expect(html).toContain('data-on-media="false"'); }); it("sizes the rail by the slide on screen out of the loaded slides", () => { expect(railPercent(0, 4)).toBe(25); expect(railPercent(2, 4)).toBe(75); expect(railPercent(9, 4)).toBe(100); expect(railPercent(0, 0)).toBe(0); }); it("keeps the own-post menu below the bar and the safe area", () => { const css = readFileSync("src/styles/mobile.css", "utf8"); expect(css).toMatch(/\.slide-menu \{[^}]*top: calc\(70px \+ env\(safe-area-inset-top/); expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/); }); }); describe("videoFit", () => { it("fills the slide for 4:5 and taller, shows whole for wider, covers while unknown", async () => { const { videoFit } = await import("@/components/MobileFeed"); expect(videoFit(720, 1280)).toBe("cover"); expect(videoFit(1080, 1350)).toBe("cover"); expect(videoFit(1080, 1080)).toBe("contain"); expect(videoFit(1920, 1080)).toBe("contain"); expect(videoFit(0, 0)).toBe("cover"); }); }); describe("long post text", () => { it("picks the largest quote size that fits, measured, not counted", () => { const fitsBelow = (max: number) => (size: number) => size <= max; expect(pickQuoteFit(fitsBelow(40), () => 0)).toEqual({ size: 31, lines: 0 }); expect(pickQuoteFit(fitsBelow(26), () => 0)).toEqual({ size: 26, lines: 0 }); expect(pickQuoteFit(fitsBelow(22), () => 0)).toEqual({ size: 22, lines: 0 }); expect(pickQuoteFit(fitsBelow(19), () => 0)).toEqual({ size: 19, lines: 0 }); expect([...QUOTE_SIZES]).toEqual([31, 26, 22, 19]); }); it("clamps at 19px to the whole lines that fit when nothing fits", () => { const tried: number[] = []; const fit = pickQuoteFit((s) => (tried.push(s), false), () => 17.8); expect(tried).toEqual([31, 26, 22, 19]); expect(fit).toEqual({ size: 19, lines: 17 }); expect(pickQuoteFit(() => false, () => 0.2)).toEqual({ size: 19, lines: 1 }); }); it("shows no button on short text until a measurement says it is long", () => { const html = render({}); expect(html).not.toContain("Read more"); expect(html).not.toMatch(/>more<\/button>/); expect(html).not.toContain("slide-reader"); }); it("keeps taps on open text from counting toward a double-tap like", () => { const el = { closest: (sel: string) => (sel.includes("[data-expanded-text]") ? {} : null) } as unknown as Element; expect(isTapTarget(el)).toBe(false); }); }); describe("long post text stylesheet", () => { const css = readFileSync("src/styles/mobile.css", "utf8"); it("fits the quote between the top bar and the author block", () => { expect(css).toMatch(/\.slide-quote-w \{[^}]*top: calc\(72px[^}]*bottom: 181px[^}]*overflow: hidden/); }); it("makes the buttons at least 44px tall", () => { expect(css).toMatch(/\.slide-more \{[^}]*min-height: 44px/); }); it("holds the reel still while text is open, and the open text scrolls", () => { expect(css).toMatch(/\.reel\[data-locked="true"\] \{ overflow-y: hidden; \}/); expect(css).toMatch(/\.slide-reader-scroll \{[^}]*overflow-y: auto/); expect(css).toMatch(/\.slide-text\.is-open \{[^}]*overflow-y: auto/); expect(css).toMatch(/\.slide-text-open \.slide-scrim \{[^}]*var\(--media-scrim\)/); }); it("drops the expand animation under reduced motion", () => { expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/); }); }); describe("delete error on a text-only slide", () => { const css = readFileSync("src/styles/mobile.css", "utf8"); const slideOf = (html: string) => html.split(" s.includes('data-testid="feed-slide"')); it("marks only the slide with the error so the quote box can end above it", () => { const html = render({ deleteErrors: new Set([POSTS[0].key]) }); const [own, image] = slideOf(html); expect(own).toContain("slide-has-error"); expect(own).toContain("Could not delete the post."); expect(image).not.toContain("slide-has-error"); expect(slideOf(render({}))[0]).not.toContain("slide-has-error"); }); it("lifts the quote box by the error line and its gap", () => { const base = Number(/\.slide-quote-w \{[^}]*?bottom: (\d+)px/.exec(css)![1]); const withError = Number(/\.slide-has-error \.slide-quote-w \{ bottom: (\d+)px; \}/.exec(css)![1]); const line = Number(/\.slide-error \{[^}]*?line-height: (\d+)px/.exec(css)![1]); expect(withError).toBe(base + line + 12); }); });