Files
frontend-helper/tests/mobile-feed.test.ts
T

281 lines
12 KiB
TypeScript

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<FeedViewProps>, 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 = /<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("<section").filter((s) => 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);
});
});