chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TILE_SPRITES } from "@/components/Placeholder/tileSprites";
|
||||
import About from "@/pages/About";
|
||||
|
||||
describe("<About>", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
});
|
||||
|
||||
it("renders the product story and current bird sprites", () => {
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Capture first/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/quick capture/i)).toBeInTheDocument();
|
||||
|
||||
const birds = screen.getByRole("region", { name: "Birds" });
|
||||
expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length);
|
||||
|
||||
for (const sprite of TILE_SPRITES) {
|
||||
expect(within(birds).getByText(sprite.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add nested horizontal page padding on mobile", () => {
|
||||
const { container } = render(<About />);
|
||||
|
||||
const contentWrapper = container.querySelector("section > div");
|
||||
|
||||
expect(contentWrapper).toHaveClass("w-full");
|
||||
expect(contentWrapper).not.toHaveClass("px-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTooltipText } from "@/components/ActivityCalendar/utils";
|
||||
|
||||
// Minimal stub for the i18n translate fn — returns a deterministic string we can assert on.
|
||||
const t = ((key: string, vars?: Record<string, unknown>) => {
|
||||
if (!vars) return key;
|
||||
const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`);
|
||||
return `${key}|${parts.join(",")}`;
|
||||
}) as Parameters<typeof getTooltipText>[2];
|
||||
|
||||
describe("getTooltipText", () => {
|
||||
it("returns just the date when count is 0", () => {
|
||||
expect(getTooltipText(0, "2026-05-02", t)).toBe("2026-05-02");
|
||||
});
|
||||
|
||||
it("uses the created-tooltip key for create_time basis (default)", () => {
|
||||
const out = getTooltipText(3, "2026-05-02", t);
|
||||
expect(out.toLowerCase()).toContain("memo.count-memos-in-date");
|
||||
expect(out.toLowerCase()).not.toContain("updated");
|
||||
});
|
||||
|
||||
it("uses the updated-tooltip key for update_time basis", () => {
|
||||
const out = getTooltipText(3, "2026-05-02", t, "update_time");
|
||||
expect(out.toLowerCase()).toContain("memo.count-memos-updated-in-date");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/auth-state", () => ({
|
||||
clearAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { clearAccessToken } from "@/auth-state";
|
||||
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
|
||||
|
||||
const mockedClearAccessToken = vi.mocked(clearAccessToken);
|
||||
|
||||
type NavigationStub = { replace: ReturnType<typeof vi.fn>; href: string };
|
||||
|
||||
function installLocation(href: string): NavigationStub {
|
||||
const url = new URL(href);
|
||||
const replace = vi.fn((next: string) => {
|
||||
// Mirror real navigation: update the mutable href on subsequent inspection.
|
||||
location.href = new URL(next, url).toString();
|
||||
});
|
||||
const location: NavigationStub = { replace, href: url.toString() };
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() {
|
||||
return location.href;
|
||||
},
|
||||
set href(value: string) {
|
||||
location.href = value;
|
||||
},
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
hash: url.hash,
|
||||
origin: url.origin,
|
||||
replace,
|
||||
},
|
||||
});
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
describe("redirectOnAuthFailure", () => {
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when the user is already on an /auth page", () => {
|
||||
const nav = installLocation("http://localhost/auth?foo=bar");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
expect(mockedClearAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing on a public route by default", () => {
|
||||
const nav = installLocation("http://localhost/explore");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
expect(mockedClearAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the token and redirects to /auth on a protected route", () => {
|
||||
const nav = installLocation("http://localhost/home?tab=pins#latest");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest");
|
||||
});
|
||||
|
||||
it("honours forceRedirect even on a public route", () => {
|
||||
const nav = installLocation("http://localhost/explore");
|
||||
|
||||
redirectOnAuthFailure(true);
|
||||
|
||||
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fexplore");
|
||||
});
|
||||
|
||||
it("embeds the reason parameter when provided", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { reason: "protected-memo" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome&reason=protected-memo");
|
||||
});
|
||||
|
||||
it("prefers an explicitly provided redirect target over the current location", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { redirect: "/setting" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fsetting");
|
||||
});
|
||||
|
||||
it("drops an unsafe redirect target silently", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { redirect: "//evil.example/phish" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell";
|
||||
import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
|
||||
|
||||
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
|
||||
date: "2025-05-01",
|
||||
label: "1",
|
||||
count: 0,
|
||||
isCurrentMonth: true,
|
||||
isToday: false,
|
||||
isSelected: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("CalendarCell empty-day clickability", () => {
|
||||
it("fires onClick for an in-month day with count=0", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /May 1, 2025/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith("2025-05-01");
|
||||
});
|
||||
|
||||
it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => {
|
||||
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /May 1, 2025/ });
|
||||
expect(button).toHaveAttribute("tabindex", "0");
|
||||
expect(button).toHaveAttribute("aria-disabled", "false");
|
||||
});
|
||||
|
||||
it("still renders a populated in-month day as interactive", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ }));
|
||||
expect(onClick).toHaveBeenCalledWith("2025-05-01");
|
||||
});
|
||||
|
||||
it("does not render out-of-month days as interactive (no role=button)", () => {
|
||||
render(
|
||||
<CalendarCell day={makeDay({ isCurrentMonth: false })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { columnCountForWidth } from "@/components/ColumnGrid";
|
||||
|
||||
// Min column width 260px, gap 12px. A width fits N columns when
|
||||
// floor((width + gap) / (minColumnWidth + gap)) === N, never below 1.
|
||||
describe("columnCountForWidth", () => {
|
||||
it("never returns fewer than one column, even at zero width", () => {
|
||||
expect(columnCountForWidth(0)).toBe(1);
|
||||
expect(columnCountForWidth(100)).toBe(1);
|
||||
expect(columnCountForWidth(259)).toBe(1);
|
||||
});
|
||||
|
||||
it("stays at one column just below the two-column threshold", () => {
|
||||
// 2*260 + 12 = 532 is the first width that fits two columns.
|
||||
expect(columnCountForWidth(531)).toBe(1);
|
||||
});
|
||||
|
||||
it("reaches two columns exactly at the threshold (the list-fallback boundary)", () => {
|
||||
expect(columnCountForWidth(532)).toBe(2);
|
||||
});
|
||||
|
||||
it("scales up with width", () => {
|
||||
expect(columnCountForWidth(804)).toBe(3);
|
||||
expect(columnCountForWidth(1152)).toBe(4);
|
||||
expect(columnCountForWidth(1600)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assignColumnsByEstimatedHeight } from "@/components/ColumnGrid";
|
||||
|
||||
describe("assignColumnsByEstimatedHeight", () => {
|
||||
it("assigns each card to the shortest estimated column with deterministic ties", () => {
|
||||
const columns = assignColumnsByEstimatedHeight({
|
||||
keys: ["a", "b", "c", "d"],
|
||||
columnCount: 2,
|
||||
getEstimatedHeight: (key) => ({ a: 100, b: 80, c: 70, d: 60 })[key] ?? 0,
|
||||
});
|
||||
|
||||
expect(Object.fromEntries(columns)).toEqual({
|
||||
a: 0,
|
||||
b: 1,
|
||||
c: 1,
|
||||
d: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pinned cards in the first column while balancing later cards", () => {
|
||||
const columns = assignColumnsByEstimatedHeight({
|
||||
keys: ["leading", "a", "priority", "b", "c"],
|
||||
columnCount: 3,
|
||||
getEstimatedHeight: (key) => ({ leading: 160, a: 90, priority: 80, b: 120, c: 70 })[key] ?? 0,
|
||||
pinnedKeys: new Set(["leading", "priority"]),
|
||||
});
|
||||
|
||||
expect(Object.fromEntries(columns)).toEqual({
|
||||
leading: 0,
|
||||
a: 1,
|
||||
priority: 0,
|
||||
b: 2,
|
||||
c: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ColumnGrid from "@/components/ColumnGrid";
|
||||
|
||||
// jsdom has no layout engine (offsetHeight/clientWidth are 0) and no ResizeObserver,
|
||||
// both of which ColumnGrid guards for — so these assert render structure, not positioning.
|
||||
interface Item {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const item = (id: string): Item => ({ id });
|
||||
const getKey = (i: Item) => i.id;
|
||||
|
||||
describe("<ColumnGrid>", () => {
|
||||
it("renders one card per item", () => {
|
||||
const { container } = render(
|
||||
<ColumnGrid items={[item("a"), item("b"), item("c")]} getKey={getKey} renderItem={(i) => <div data-testid="card">{i.id}</div>} />,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders the leading node as the first tile, before the items", () => {
|
||||
const { container, getByTestId } = render(
|
||||
<ColumnGrid
|
||||
items={[item("a")]}
|
||||
getKey={getKey}
|
||||
renderItem={(i) => <div data-testid={`card-${i.id}`}>{i.id}</div>}
|
||||
leading={<div data-testid="composer" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("composer")).toBeInTheDocument();
|
||||
// The grid is the container's only child; its first wrapper holds the leading node.
|
||||
const grid = container.firstElementChild as HTMLElement;
|
||||
expect(grid.children[0].querySelector('[data-testid="composer"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for an empty list", () => {
|
||||
const { container } = render(<ColumnGrid items={[]} getKey={getKey} renderItem={() => <div data-testid="card" />} />);
|
||||
|
||||
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import type { MemoFilter } from "@/contexts/MemoFilterContext";
|
||||
|
||||
describe("deriveDefaultCreateTimeFromFilters", () => {
|
||||
const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local
|
||||
|
||||
it("returns undefined when no filters are set", () => {
|
||||
expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no displayTime filter is present", () => {
|
||||
const filters: MemoFilter[] = [
|
||||
{ factor: "tagSearch", value: "work" },
|
||||
{ factor: "pinned", value: "true" },
|
||||
];
|
||||
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges the displayTime date with the current local hh:mm:ss", () => {
|
||||
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
|
||||
const result = deriveDefaultCreateTimeFromFilters(filters, now);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(4); // May (0-indexed)
|
||||
expect(result!.getDate()).toBe(1);
|
||||
expect(result!.getHours()).toBe(14);
|
||||
expect(result!.getMinutes()).toBe(32);
|
||||
expect(result!.getSeconds()).toBe(10);
|
||||
});
|
||||
|
||||
it("ignores extra non-displayTime filters", () => {
|
||||
const filters: MemoFilter[] = [
|
||||
{ factor: "tagSearch", value: "work" },
|
||||
{ factor: "displayTime", value: "2025-05-01" },
|
||||
{ factor: "pinned", value: "true" },
|
||||
];
|
||||
const result = deriveDefaultCreateTimeFromFilters(filters, now);
|
||||
expect(result?.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns undefined for a malformed YYYY-MM-DD value", () => {
|
||||
const cases: MemoFilter[][] = [
|
||||
[{ factor: "displayTime", value: "not-a-date" }],
|
||||
[{ factor: "displayTime", value: "2025-13-40" }],
|
||||
[{ factor: "displayTime", value: "" }],
|
||||
[{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day
|
||||
];
|
||||
for (const filters of cases) {
|
||||
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses real `new Date()` when `now` is omitted", () => {
|
||||
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
|
||||
const before = new Date();
|
||||
const result = deriveDefaultCreateTimeFromFilters(filters);
|
||||
const after = new Date();
|
||||
expect(result).toBeDefined();
|
||||
// Date components must come from the filter, not from `now` — guards
|
||||
// against an impl that silently returns `new Date()` and ignores filters.
|
||||
expect(result!.getFullYear()).toBe(2025);
|
||||
expect(result!.getMonth()).toBe(4); // May (0-indexed)
|
||||
expect(result!.getDate()).toBe(1);
|
||||
// Time-of-day should fall between before and after (within 1s tolerance).
|
||||
const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds();
|
||||
const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds();
|
||||
const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds();
|
||||
// Handle midnight rollover by allowing any value if before > after.
|
||||
if (beforeTimeOnly <= afterTimeOnly) {
|
||||
expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly);
|
||||
expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createController } from "@/components/MemoEditor/Editor/controller";
|
||||
|
||||
function view(doc = "") {
|
||||
return new EditorView({ state: EditorState.create({ doc }) });
|
||||
}
|
||||
|
||||
describe("source editor controller", () => {
|
||||
it("round-trips markdown verbatim, including previously-lossy inputs", () => {
|
||||
const v = view();
|
||||
const c = createController(v, {} as never);
|
||||
for (const md of ["", "Title\n===", "1. a\n 1. b\n 1. c", "a b"]) {
|
||||
c.setMarkdown(md);
|
||||
expect(c.getMarkdown()).toBe(md);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports emptiness on whitespace", () => {
|
||||
const c = createController(view(" \n "), {} as never);
|
||||
expect(c.isEmpty()).toBe(true);
|
||||
});
|
||||
|
||||
it("inserts markdown as its own block", () => {
|
||||
const v = view("alpha");
|
||||
const c = createController(v, {} as never);
|
||||
v.dispatch({ selection: { anchor: 5 } });
|
||||
c.insertMarkdown("beta");
|
||||
expect(c.getMarkdown()).toBe("alpha\n\nbeta");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tagMentionDecorations } from "@/components/MemoEditor/Editor/tagMentionDecorations";
|
||||
|
||||
function countClass(doc: string, cls: string): number {
|
||||
const view = new EditorView({ state: EditorState.create({ doc, extensions: [tagMentionDecorations] }), parent: document.body });
|
||||
const n = view.dom.querySelectorAll(`.${cls}`).length;
|
||||
view.destroy();
|
||||
return n;
|
||||
}
|
||||
|
||||
describe("tag/mention decorations", () => {
|
||||
it("decorates #tags", () => expect(countClass("a #todo and #work/sub b", "cm-memo-tag")).toBe(2));
|
||||
it("decorates @mentions", () => expect(countClass("hi @alice", "cm-memo-mention")).toBe(1));
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFormattingController } from "@/components/MemoEditor/Editor/formatting";
|
||||
|
||||
function setup(doc: string, from: number, to: number) {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [markdown()], selection: EditorSelection.range(from, to) }),
|
||||
});
|
||||
return { view, f: createFormattingController(view, new Set()) };
|
||||
}
|
||||
|
||||
describe("formatting controller", () => {
|
||||
it("wraps selection in bold and reports active", () => {
|
||||
const { view, f } = setup("hello world", 0, 5);
|
||||
f.run("bold");
|
||||
expect(view.state.doc.toString()).toBe("**hello** world");
|
||||
view.dispatch({ selection: { anchor: 3 } });
|
||||
expect(f.getActiveFormats().bold).toBe(true);
|
||||
});
|
||||
|
||||
it("prefixes a heading and reports its level", () => {
|
||||
const { view, f } = setup("Title", 0, 0);
|
||||
f.run("heading1");
|
||||
expect(view.state.doc.toString()).toBe("# Title");
|
||||
view.dispatch({ selection: { anchor: 3 } });
|
||||
expect(f.getActiveFormats().headingLevel).toBe(1);
|
||||
});
|
||||
|
||||
it("toggles a bullet list line", () => {
|
||||
const { view, f } = setup("item", 0, 0);
|
||||
f.run("bulletList");
|
||||
expect(view.state.doc.toString()).toBe("- item");
|
||||
f.run("bulletList");
|
||||
expect(view.state.doc.toString()).toBe("item");
|
||||
});
|
||||
|
||||
it("unbolds when already bold", () => {
|
||||
const { view, f } = setup("**hello** world", 4, 4); // cursor inside bold
|
||||
f.run("bold");
|
||||
expect(view.state.doc.toString()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("unwraps italic when already italic", () => {
|
||||
const { view, f } = setup("*hi* there", 2, 2);
|
||||
f.run("italic");
|
||||
expect(view.state.doc.toString()).toBe("hi there");
|
||||
});
|
||||
|
||||
it("unwraps inline code when already code", () => {
|
||||
const { view, f } = setup("`code` here", 3, 3);
|
||||
f.run("code");
|
||||
expect(view.state.doc.toString()).toBe("code here");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { headingDecorations } from "@/components/MemoEditor/Editor/headingDecorations";
|
||||
|
||||
function hasClass(doc: string, cls: string): boolean {
|
||||
const view = new EditorView({ state: EditorState.create({ doc, extensions: [markdown(), headingDecorations] }), parent: document.body });
|
||||
const found = view.dom.querySelector(`.${cls}`) !== null;
|
||||
view.destroy();
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("heading line decorations require a space", () => {
|
||||
it("styles '# Title' as h1", () => expect(hasClass("# Title", "cm-md-h1")).toBe(true));
|
||||
it("styles '### x' as h3", () => expect(hasClass("### x", "cm-md-h3")).toBe(true));
|
||||
it("does NOT style a bare '#'", () => expect(hasClass("#", "cm-md-h1")).toBe(false));
|
||||
it("does NOT style '#tag' (no space)", () => expect(hasClass("#tag", "cm-md-h1")).toBe(false));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
|
||||
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
|
||||
|
||||
function makeView(doc: string, onSubmit: () => void = () => {}) {
|
||||
return new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: buildEditorExtensions({
|
||||
placeholder: "",
|
||||
onChange: () => {},
|
||||
onFiles: () => {},
|
||||
onUpdate: () => {},
|
||||
onSubmit,
|
||||
getTags: () => [],
|
||||
}),
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
function press(view: EditorView, key: string, opts: KeyboardEventInit = {}) {
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts }));
|
||||
}
|
||||
|
||||
/** Place the cursor on the given 1-based line, then press Tab/Shift-Tab. */
|
||||
function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
view.dispatch({ selection: { anchor: line.to } });
|
||||
press(view, "Tab", { shiftKey });
|
||||
}
|
||||
|
||||
describe("editor key bindings", () => {
|
||||
it("Cmd+Enter submits without inserting a blank line", () => {
|
||||
let submitted = 0;
|
||||
const view = makeView("hello", () => {
|
||||
submitted += 1;
|
||||
});
|
||||
view.dispatch({ selection: { anchor: 5 } });
|
||||
press(view, "Enter", { metaKey: true });
|
||||
// The save shortcut must outrank defaultKeymap's Mod-Enter (insertBlankLine).
|
||||
expect(submitted).toBe(1);
|
||||
expect(view.state.doc.toString()).toBe("hello");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Ctrl+Enter also submits without editing the document", () => {
|
||||
let submitted = 0;
|
||||
const view = makeView("hello", () => {
|
||||
submitted += 1;
|
||||
});
|
||||
press(view, "Enter", { ctrlKey: true });
|
||||
expect(submitted).toBe(1);
|
||||
expect(view.state.doc.toString()).toBe("hello");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("plain Enter still inserts a newline", () => {
|
||||
const view = makeView("hello");
|
||||
view.dispatch({ selection: { anchor: 5 } });
|
||||
press(view, "Enter");
|
||||
expect(view.state.doc.toString()).toBe("hello\n");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Tab indents a non-list line by two spaces", () => {
|
||||
const view = makeView("hello");
|
||||
view.dispatch({ selection: { anchor: 0 } });
|
||||
press(view, "Tab");
|
||||
expect(view.state.doc.toString()).toBe(" hello");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Escape blurs the editor (keyboard escape hatch)", () => {
|
||||
const view = makeView("x");
|
||||
view.focus();
|
||||
expect(view.hasFocus).toBe(true);
|
||||
press(view, "Escape");
|
||||
expect(view.hasFocus).toBe(false);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Tab nests an ordered item: marker-aligned indent + renumbered to 1", () => {
|
||||
const view = makeView("1. a\n2. b");
|
||||
tabOnLine(view, 2);
|
||||
// 3-space indent (past "1. ") AND renumbered 2 -> 1 so CommonMark nests it.
|
||||
expect(view.state.doc.toString()).toBe("1. a\n 1. b");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Tab nests a bullet item to two spaces", () => {
|
||||
const view = makeView("- a\n- b");
|
||||
tabOnLine(view, 2);
|
||||
expect(view.state.doc.toString()).toBe("- a\n - b");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Shift-Tab outdents a nested list item back to its parent level", () => {
|
||||
const view = makeView("1. a\n 2. b");
|
||||
tabOnLine(view, 2, true);
|
||||
expect(view.state.doc.toString()).toBe("1. a\n2. b");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("Tab-nested ordered lists render nested in the display (no flat siblings)", () => {
|
||||
const view = makeView("1. a\n2. b\n3. c");
|
||||
tabOnLine(view, 2); // nest b under a
|
||||
tabOnLine(view, 3); // nest c under a ...
|
||||
tabOnLine(view, 3); // ... then under b
|
||||
const md = view.state.doc.toString();
|
||||
view.destroy();
|
||||
// Each nested level renumbers to 1, which is what CommonMark requires to
|
||||
// nest an ordered sublist (a 2./3. start can't interrupt the parent line).
|
||||
expect(md).toBe("1. a\n 1. b\n 1. c");
|
||||
// remark-gfm (the display) now reads this as three nested ordered lists.
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(MemoMarkdownRenderer, { content: md, resolvedMentionUsernames: new Set<string>() }),
|
||||
);
|
||||
expect((html.match(/<ol/g) ?? []).length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeTagCompletionSource } from "@/components/MemoEditor/Editor/tagAutocomplete";
|
||||
|
||||
function complete(doc: string, pos: number, tags: string[]) {
|
||||
const source = makeTagCompletionSource(() => tags);
|
||||
const state = EditorState.create({ doc });
|
||||
return source(new CompletionContext(state, pos, false));
|
||||
}
|
||||
|
||||
describe("tag autocomplete", () => {
|
||||
it("offers known tags after #", () => {
|
||||
const result = complete("hello #to", 9, ["todo", "today", "work"]);
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["todo", "today"]);
|
||||
});
|
||||
|
||||
it("returns null on a bare # with nothing typed", () => {
|
||||
expect(complete("hello #", 7, ["todo"])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when not in a tag", () => {
|
||||
expect(complete("hello world", 11, ["todo"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Editor from "@/components/MemoEditor/Editor";
|
||||
import type { EditorController } from "@/components/MemoEditor/types/editorController";
|
||||
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useTagCounts: () => ({ data: {} }),
|
||||
}));
|
||||
|
||||
describe("Editor", () => {
|
||||
it("loads markdown and serializes it back verbatim", () => {
|
||||
const ref = createRef<EditorController>();
|
||||
render(
|
||||
<Editor
|
||||
ref={ref}
|
||||
className="x"
|
||||
initialContent={"# Title\n\n- a\n 1. b"}
|
||||
placeholder="memo"
|
||||
onContentChange={vi.fn()}
|
||||
onFiles={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(ref.current?.getMarkdown()).toBe("# Title\n\n- a\n 1. b");
|
||||
});
|
||||
|
||||
it("emits changes through onContentChange", () => {
|
||||
const ref = createRef<EditorController>();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Editor
|
||||
ref={ref}
|
||||
className="x"
|
||||
initialContent=""
|
||||
placeholder="memo"
|
||||
onContentChange={onChange}
|
||||
onFiles={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
ref.current?.setMarkdown("hello");
|
||||
expect(onChange).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("reconfigures the placeholder when its translation changes", () => {
|
||||
const props = {
|
||||
className: "x",
|
||||
initialContent: "",
|
||||
onContentChange: vi.fn(),
|
||||
onFiles: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(<Editor {...props} placeholder="Any thoughts?" />);
|
||||
|
||||
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "Any thoughts?");
|
||||
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts?");
|
||||
|
||||
rerender(<Editor {...props} placeholder="有什么想法?" />);
|
||||
|
||||
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "有什么想法?");
|
||||
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("有什么想法?");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock dependencies BEFORE importing the hook under test.
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useAllUserStats: vi.fn(),
|
||||
useUserStats: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/useMemoQueries", () => ({
|
||||
useMemos: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => ({ name: "users/test", id: 1 }),
|
||||
}));
|
||||
|
||||
const mockUseView = vi.fn();
|
||||
vi.mock("@/contexts/ViewContext", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/contexts/ViewContext")>("@/contexts/ViewContext");
|
||||
return {
|
||||
...actual,
|
||||
useView: () => mockUseView(),
|
||||
};
|
||||
});
|
||||
|
||||
import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries";
|
||||
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => children as never;
|
||||
|
||||
const ts = (year: number, month: number, day: number) => ({
|
||||
seconds: BigInt(Math.floor(Date.UTC(year, month - 1, day) / 1000)),
|
||||
nanos: 0,
|
||||
});
|
||||
|
||||
describe("useFilteredMemoStats", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useAllUserStats).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useAllUserStats>);
|
||||
vi.mocked(useUserStats).mockReturnValue({
|
||||
data: {
|
||||
memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)],
|
||||
memoUpdatedTimestamps: [ts(2026, 5, 3), ts(2026, 5, 3), ts(2026, 5, 3)],
|
||||
tagCount: {},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useUserStats>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("aggregates by created timestamps when timeBasis is create_time", () => {
|
||||
mockUseView.mockReturnValue({
|
||||
timeBasis: "create_time",
|
||||
orderByTimeAsc: false,
|
||||
toggleSortOrder: vi.fn(),
|
||||
setTimeBasis: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
|
||||
|
||||
expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 2, "2026-05-02": 1 });
|
||||
expect(result.current.statistics.timeBasis).toBe("create_time");
|
||||
});
|
||||
|
||||
it("aggregates by updated timestamps when timeBasis is update_time", () => {
|
||||
mockUseView.mockReturnValue({
|
||||
timeBasis: "update_time",
|
||||
orderByTimeAsc: false,
|
||||
toggleSortOrder: vi.fn(),
|
||||
setTimeBasis: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
|
||||
|
||||
expect(result.current.statistics.activityStats).toEqual({ "2026-05-03": 3 });
|
||||
expect(result.current.statistics.timeBasis).toBe("update_time");
|
||||
});
|
||||
|
||||
it("falls back to created timestamps when updated array is empty (old server)", () => {
|
||||
// Old servers that don't know about the new field deserialize it as [].
|
||||
// Length divergence (created non-empty, updated empty) is the signal.
|
||||
vi.mocked(useUserStats).mockReturnValue({
|
||||
data: {
|
||||
memoCreatedTimestamps: [ts(2026, 5, 1)],
|
||||
memoUpdatedTimestamps: [],
|
||||
tagCount: {},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useUserStats>);
|
||||
mockUseView.mockReturnValue({
|
||||
timeBasis: "update_time",
|
||||
orderByTimeAsc: false,
|
||||
toggleSortOrder: vi.fn(),
|
||||
setTimeBasis: vi.fn(),
|
||||
});
|
||||
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
|
||||
|
||||
expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 1 });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "@/components/MemoEditor/formatting/commands";
|
||||
import { FormattingToolbar } from "@/components/MemoEditor/Toolbar/FormattingToolbar";
|
||||
import type { EditorController } from "@/components/MemoEditor/types/editorController";
|
||||
|
||||
// Match the repo convention: t echoes the i18n key (no i18next backend in tests),
|
||||
// so accessible names below are the keys themselves.
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
|
||||
// Radix DropdownMenu reaches for layout/pointer APIs jsdom doesn't implement.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
Element.prototype.setPointerCapture = vi.fn();
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
|
||||
const run = vi.fn();
|
||||
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
|
||||
const controller: EditorController = {
|
||||
focus: () => {},
|
||||
hasFocus: () => false,
|
||||
isEmpty: () => true,
|
||||
getMarkdown: () => "",
|
||||
setMarkdown: () => {},
|
||||
insertMarkdown: vi.fn(),
|
||||
scrollToCursor: () => {},
|
||||
selectAll: () => {},
|
||||
formatting: {
|
||||
run,
|
||||
getActiveFormats: () => activeFormats,
|
||||
getSelectedText: opts.getSelectedText ?? (() => ""),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
};
|
||||
return { controller, run };
|
||||
}
|
||||
|
||||
function renderToolbar(controller: EditorController, onExit = vi.fn()) {
|
||||
const ref = createRef<EditorController>();
|
||||
ref.current = controller;
|
||||
render(<FormattingToolbar controllerRef={ref} onExit={onExit} />);
|
||||
return { onExit };
|
||||
}
|
||||
|
||||
describe("FormattingToolbar", () => {
|
||||
it("runs the bold command when the bold button is clicked", () => {
|
||||
const { controller, run } = makeController();
|
||||
renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.format.bold" }));
|
||||
expect(run).toHaveBeenCalledWith("bold");
|
||||
});
|
||||
|
||||
it("runs the heading command when a heading level is chosen", () => {
|
||||
const { controller, run } = makeController();
|
||||
renderToolbar(controller);
|
||||
// Keyboard open is the most reliable path for Radix menus in jsdom.
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "editor.format.heading" }), { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "editor.format.heading-2" }));
|
||||
expect(run).toHaveBeenCalledWith("heading2");
|
||||
});
|
||||
|
||||
it("reflects active marks via aria-pressed", () => {
|
||||
const { controller } = makeController({ active: { bold: true } });
|
||||
renderToolbar(controller);
|
||||
expect(screen.getByRole("button", { name: "editor.format.bold" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("calls onExit when the exit button is clicked", () => {
|
||||
const { controller } = makeController();
|
||||
const { onExit } = renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.exit-focus-mode" }));
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
|
||||
const mockedUseCurrentUser = vi.mocked(useCurrentUser);
|
||||
|
||||
// Minimal User-like stand-in — guards only check truthiness on the value.
|
||||
const fakeUser = { name: "users/steven" } as unknown as ReturnType<typeof useCurrentUser>;
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
|
||||
};
|
||||
|
||||
const renderAt = (initialEntry: string, children: ReactNode) =>
|
||||
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
|
||||
|
||||
describe("LandingRoute", () => {
|
||||
it("renders the nested home page for an authenticated visitor at /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("home")).toHaveTextContent("home");
|
||||
});
|
||||
|
||||
it("sends an unauthenticated visitor from the entry to /explore", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/explore");
|
||||
});
|
||||
|
||||
it("preserves the query string and hash when redirecting an unauthenticated visitor", () => {
|
||||
// Covers the regression in issue #5846: bookmarks pointing at `/?filter=...`
|
||||
// must not drop their params on the trip through the landing redirect.
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/?filter=tag:work#latest",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/explore?filter=tag:work#latest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequireAuthRoute", () => {
|
||||
it("renders the protected content for authenticated users", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/setting",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("protected")).toHaveTextContent("secret");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated users to /auth with the preserved location", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/setting?tab=pins#latest",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
<Route path="/auth" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fsetting%3Ftab%3Dpins%23latest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequireGuestRoute", () => {
|
||||
it("renders the auth page when no user is present", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/auth",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div data-testid="sign-in">sign in</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in");
|
||||
});
|
||||
|
||||
it("redirects already-authenticated users to / by default", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
|
||||
it("honours a safe redirect target from the query string", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2Fsetting",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/setting" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/setting");
|
||||
});
|
||||
|
||||
it("ignores an auth-family redirect target and falls back to /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2Fauth%2Fcallback",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
|
||||
it("ignores an external redirect target and falls back to /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2F%2Fevil.example%2Fphish",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const memo = (name: string) => ({ name }) as Memo;
|
||||
|
||||
describe("hoistMemoToFront", () => {
|
||||
it("moves the named memo to the front, above everything else", () => {
|
||||
const list = [memo("memos/pin1"), memo("memos/pin2"), memo("memos/new")];
|
||||
expect(hoistMemoToFront(list, "memos/new").map((m) => m.name)).toEqual(["memos/new", "memos/pin1", "memos/pin2"]);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the name is null", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, null)).toBe(list);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the name is absent", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, "memos/missing")).toBe(list);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the memo is already first", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, "memos/a")).toBe(list);
|
||||
});
|
||||
|
||||
it("does not mutate the input list", () => {
|
||||
const list = [memo("memos/a"), memo("memos/new")];
|
||||
hoistMemoToFront(list, "memos/new");
|
||||
expect(list.map((m) => m.name)).toEqual(["memos/a", "memos/new"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store = new Map<string, string>();
|
||||
let shouldThrowOnSet = false;
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
if (shouldThrowOnSet) {
|
||||
throw new Error("localStorage setItem unavailable");
|
||||
}
|
||||
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store = new Map<string, string>();
|
||||
},
|
||||
setShouldThrowOnSet: (value: boolean) => {
|
||||
shouldThrowOnSet = value;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.setShouldThrowOnSet(false);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.setShouldThrowOnSet(false);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the default value when storage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and writes JSON values", () => {
|
||||
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current[1](false);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
|
||||
});
|
||||
|
||||
it("supports updater functions", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((current) => !current);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to the default value for malformed storage", () => {
|
||||
window.localStorage.setItem("hook-test-malformed", "{bad json");
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps in-memory state when the default object reference changes and persistence is unavailable", () => {
|
||||
localStorageMock.setShouldThrowOnSet(true);
|
||||
|
||||
const initialDefaultValue = { enabled: false };
|
||||
const updatedValue = { enabled: true };
|
||||
const { result, rerender } = renderHook(({ defaultValue }) => useLocalStorage("hook-test-object", defaultValue), {
|
||||
initialProps: { defaultValue: initialDefaultValue },
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(initialDefaultValue);
|
||||
|
||||
act(() => {
|
||||
result.current[1](updatedValue);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(updatedValue);
|
||||
|
||||
rerender({ defaultValue: { enabled: false } });
|
||||
|
||||
expect(result.current[0]).toBe(updatedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDebouncedEffect", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("runs the latest callback after the delay", () => {
|
||||
const calls: string[] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ value }) => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push(value);
|
||||
},
|
||||
100,
|
||||
[value],
|
||||
);
|
||||
},
|
||||
{ initialProps: { value: "first" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(99);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
rerender({ value: "second" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("uses the latest callback when unrelated props rerender before the delay", () => {
|
||||
const calls: string[] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ dependency, value }) => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push(value);
|
||||
},
|
||||
100,
|
||||
[dependency],
|
||||
);
|
||||
},
|
||||
{ initialProps: { dependency: "same", value: "first" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
rerender({ dependency: "same", value: "second" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("clears the pending timeout on unmount", () => {
|
||||
const calls: string[] = [];
|
||||
const { unmount } = renderHook(() => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push("called");
|
||||
},
|
||||
100,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLocaleSearchLabels, localeMatchesSearch, normalizeLocaleSearchText } from "@/utils/i18n";
|
||||
|
||||
describe("locale search helpers", () => {
|
||||
it("normalizes case and diacritics for locale search", () => {
|
||||
expect(normalizeLocaleSearchText("Português")).toBe("portugues");
|
||||
});
|
||||
|
||||
it("includes locale code, native name, and English name", () => {
|
||||
const labels = getLocaleSearchLabels("ja", "en");
|
||||
|
||||
expect(labels).toContain("ja");
|
||||
expect(labels).toContain("日本語");
|
||||
expect(labels).toContain("Japanese");
|
||||
});
|
||||
|
||||
it("matches by code, native display name, English display name, and accent-free text", () => {
|
||||
expect(localeMatchesSearch("pt-PT", "pt", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("ja", "日本", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("de", "german", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("pt-PT", "portugues", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("ja", "romanian", "en")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LocaleSearchList } from "@/components/LocalePicker";
|
||||
|
||||
describe("<LocaleSearchList>", () => {
|
||||
it("links to GitHub feedback when no locale matches the search", async () => {
|
||||
render(<LocaleSearchList value="en" onChange={() => {}} />);
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Language" }), { target: { value: "klingon" } });
|
||||
|
||||
const feedbackLink = screen.getByRole("link", { name: "Language not found? Submit feedback" });
|
||||
expect(feedbackLink).toHaveAttribute("href", expect.stringContaining("https://github.com/usememos/memos/issues/new"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import LocationPicker from "@/components/map/LocationPicker";
|
||||
|
||||
const setView = vi.fn();
|
||||
const locate = vi.fn();
|
||||
const zoomIn = vi.fn();
|
||||
const zoomOut = vi.fn();
|
||||
const eventMap = { setView, locate };
|
||||
const controlMap = { zoomIn, zoomOut };
|
||||
|
||||
vi.mock("leaflet", () => {
|
||||
class LatLng {
|
||||
lat: number;
|
||||
lng: number;
|
||||
|
||||
constructor(lat: number, lng: number) {
|
||||
this.lat = lat;
|
||||
this.lng = lng;
|
||||
}
|
||||
}
|
||||
|
||||
class Control {
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
|
||||
remove() {}
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
Control,
|
||||
DomUtil: {
|
||||
create: () => ({ style: {} }),
|
||||
},
|
||||
DomEvent: {
|
||||
disableClickPropagation: () => {},
|
||||
disableScrollPropagation: () => {},
|
||||
},
|
||||
},
|
||||
LatLng,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-leaflet", () => ({
|
||||
MapContainer: ({ children }: { children: ReactNode }) => <div data-testid="map">{children}</div>,
|
||||
Marker: ({ position }: { position: { lat: number; lng: number } }) => <div data-testid="marker">{`${position.lat},${position.lng}`}</div>,
|
||||
useMap: () => controlMap,
|
||||
useMapEvents: () => eventMap,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/map/map-utils", () => ({
|
||||
defaultMarkerIcon: {},
|
||||
ThemedTileLayer: () => <div data-testid="tile-layer" />,
|
||||
}));
|
||||
|
||||
describe("LocationPicker", () => {
|
||||
it("does not recenter when rerendered with the same coordinates", () => {
|
||||
const { rerender } = render(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
|
||||
|
||||
expect(setView).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
|
||||
|
||||
expect(setView).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<LocationPicker latlng={{ lat: 3, lng: 4 }} />);
|
||||
|
||||
expect(setView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
|
||||
|
||||
describe("checkAllTasks", () => {
|
||||
it("checks every unchecked task while preserving source formatting", () => {
|
||||
const markdown = ["Intro", "- [ ] first", "* [x] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n");
|
||||
|
||||
expect(checkAllTasks(markdown)).toBe(["Intro", "- [x] first", "* [x] second", " + [x] nested", "1. [x] ordered", "Outro"].join("\n"));
|
||||
});
|
||||
|
||||
it("returns the original string when no checkbox markers need changing", () => {
|
||||
const markdown = ["Intro", "- [x] first", "Outro"].join("\n");
|
||||
|
||||
expect(checkAllTasks(markdown)).toBe(markdown);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncheckAllTasks", () => {
|
||||
it("unchecks every checked task while preserving source formatting", () => {
|
||||
const markdown = ["Intro", "- [x] first", "* [X] second", " + [ ] nested", "1. [x] ordered", "Outro"].join("\n");
|
||||
|
||||
expect(uncheckAllTasks(markdown)).toBe(["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"));
|
||||
});
|
||||
|
||||
it("returns the original string when no checkbox markers need changing", () => {
|
||||
const markdown = ["Intro", "- [ ] first", "Outro"].join("\n");
|
||||
|
||||
expect(uncheckAllTasks(markdown)).toBe(markdown);
|
||||
});
|
||||
|
||||
it("ignores task-looking text inside fenced and inline code", () => {
|
||||
const markdown = ["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [x] real task"].join("\n");
|
||||
|
||||
expect(uncheckAllTasks(markdown)).toBe(
|
||||
["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [ ] real task"].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CLAMP_PREVIEW_HEIGHT_PX, CLAMP_TRIGGER_HEIGHT_PX } from "@/components/ClampedSection";
|
||||
import MemoBody from "@/components/MemoView/components/MemoBody";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
memo: {
|
||||
name: "memos/1",
|
||||
content: "",
|
||||
relations: [],
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
|
||||
useResolvedMentionUsernames: () => new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoContent/MemoMarkdownRenderer", () => ({
|
||||
MemoMarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoMetadata", () => ({
|
||||
AttachmentListView: () => null,
|
||||
LocationDisplayView: () => null,
|
||||
RelationListView: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoReactionListView", () => ({
|
||||
MemoReactionListView: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoView/hooks", () => ({
|
||||
useMemoHandlers: () => ({
|
||||
handleMemoContentClick: vi.fn(),
|
||||
handleMemoContentDoubleClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoView/MemoViewContext", () => ({
|
||||
useMemoViewContext: () => ({
|
||||
memo: mockState.memo,
|
||||
parentPage: "",
|
||||
showBlurredContent: false,
|
||||
blurred: false,
|
||||
readonly: false,
|
||||
openEditor: vi.fn(),
|
||||
openPreview: vi.fn(),
|
||||
toggleBlurVisibility: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const createMemo = (content: string) => ({
|
||||
name: "memos/1",
|
||||
content,
|
||||
relations: [],
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("<MemoBody /> compact body clamp", () => {
|
||||
it("keeps a buffer between the preview height and the fold trigger", () => {
|
||||
expect(CLAMP_TRIGGER_HEIGHT_PX).toBeGreaterThan(CLAMP_PREVIEW_HEIGHT_PX);
|
||||
});
|
||||
|
||||
it("folds tall compact bodies and keeps them expanded across content changes", async () => {
|
||||
// jsdom has no layout; report every element as taller than the fold trigger.
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100);
|
||||
|
||||
mockState.memo = createMemo("line 1\nline 2");
|
||||
const { rerender } = render(<MemoBody compact={true} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /memo\.show-more/ })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /memo\.show-more/ }));
|
||||
expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument();
|
||||
|
||||
mockState.memo = createMemo("line 1\nline 2 updated");
|
||||
rerender(<MemoBody compact={true} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no clamp when compact is off", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100);
|
||||
|
||||
mockState.memo = createMemo("tall content");
|
||||
render(<MemoBody compact={false} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /memo\.show-more/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateMemoCardHeight } from "@/components/PagedMemoList/memoCardHeight";
|
||||
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const buildAttachment = (overrides: Partial<Attachment>) =>
|
||||
create(AttachmentSchema, {
|
||||
name: "attachments/test",
|
||||
filename: "test.bin",
|
||||
type: "application/octet-stream",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildCommentRelation = (memoName: string, index: number) =>
|
||||
create(MemoRelationSchema, {
|
||||
type: MemoRelation_Type.COMMENT,
|
||||
memo: create(MemoRelation_MemoSchema, { name: `memos/comment-${index}` }),
|
||||
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
|
||||
});
|
||||
|
||||
const buildMemo = (overrides: Partial<Memo> = {}) =>
|
||||
create(MemoSchema, {
|
||||
name: "memos/main",
|
||||
content: "hello",
|
||||
attachments: [],
|
||||
relations: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("estimateMemoCardHeight", () => {
|
||||
it("accounts for visual attachments before images have loaded", () => {
|
||||
const plain = buildMemo();
|
||||
const withImage = buildMemo({
|
||||
attachments: [
|
||||
buildAttachment({
|
||||
name: "attachments/image",
|
||||
filename: "image.png",
|
||||
type: "image/png",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(estimateMemoCardHeight(withImage, { columnWidth: 320 })).toBeGreaterThan(
|
||||
estimateMemoCardHeight(plain, { columnWidth: 320 }) + 100,
|
||||
);
|
||||
});
|
||||
|
||||
it("estimates the visible comment preview height and caps it at three comments", () => {
|
||||
const oneComment = buildMemo({ relations: [buildCommentRelation("memos/main", 1)] });
|
||||
const threeComments = buildMemo({
|
||||
relations: [1, 2, 3].map((index) => buildCommentRelation("memos/main", index)),
|
||||
});
|
||||
const fiveComments = buildMemo({
|
||||
relations: [1, 2, 3, 4, 5].map((index) => buildCommentRelation("memos/main", index)),
|
||||
});
|
||||
|
||||
expect(estimateMemoCardHeight(threeComments, { columnWidth: 320 })).toBeGreaterThan(
|
||||
estimateMemoCardHeight(oneComment, { columnWidth: 320 }),
|
||||
);
|
||||
expect(estimateMemoCardHeight(fiveComments, { columnWidth: 320 })).toBe(estimateMemoCardHeight(threeComments, { columnWidth: 320 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { Element } from "hast";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Paragraph } from "@/components/MemoContent/markdown/Paragraph";
|
||||
|
||||
const viewState = vi.hoisted(() => ({ linkPreview: true }));
|
||||
|
||||
// Paragraph reads the global preference via this hook.
|
||||
vi.mock("@/contexts/ViewContext", () => ({
|
||||
useLinkPreviewEnabled: () => viewState.linkPreview,
|
||||
}));
|
||||
|
||||
// The link-preview card fetches metadata via this hook; stub it so the card would
|
||||
// render whenever it is allowed to.
|
||||
vi.mock("@/hooks/useMemoQueries", () => ({
|
||||
useLinkMetadata: () => ({
|
||||
data: { url: "https://example.com", title: "Example Title", description: "An example site", image: "" },
|
||||
isSuccess: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A single bare-link paragraph node, the shape that triggers a preview card.
|
||||
const singleLinkNode = {
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: "https://example.com" },
|
||||
children: [{ type: "text", value: "https://example.com" }],
|
||||
},
|
||||
],
|
||||
} as unknown as Element;
|
||||
|
||||
const renderParagraph = () =>
|
||||
render(
|
||||
<Paragraph node={singleLinkNode}>
|
||||
<a href="https://example.com">https://example.com</a>
|
||||
</Paragraph>,
|
||||
);
|
||||
|
||||
describe("<Paragraph /> link preview gating", () => {
|
||||
it("renders the link preview card when the setting is enabled (default)", () => {
|
||||
viewState.linkPreview = true;
|
||||
renderParagraph();
|
||||
expect(screen.getByText("Example Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the plain link without a card when the setting is disabled", () => {
|
||||
viewState.linkPreview = false;
|
||||
renderParagraph();
|
||||
expect(screen.queryByText("Example Title")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("https://example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { List, ListItem } from "@/components/MemoContent/markdown";
|
||||
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "@/components/MemoContent/constants";
|
||||
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const renderListContent = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkSplitMixedTaskLists]}
|
||||
components={{
|
||||
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
|
||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
describe("memo content lists", () => {
|
||||
it("keeps bullets on regular items in mixed task and bullet lists", () => {
|
||||
const html = renderListContent("- [ ] pickup package\n- [ ] library returns\n\n- milk\n- eggs\n- bread");
|
||||
const listOpenTags = html.match(/<ul class="[^"]*"/g) ?? [];
|
||||
|
||||
expect(listOpenTags).toHaveLength(2);
|
||||
expect(listOpenTags[0]).toContain(TASK_LIST_CLASS);
|
||||
expect(listOpenTags[0]).toContain("list-none");
|
||||
expect(listOpenTags[0]).not.toContain("pl-6");
|
||||
expect(listOpenTags[1]).not.toContain(TASK_LIST_CLASS);
|
||||
expect(listOpenTags[1]).toContain("pl-6");
|
||||
expect(listOpenTags[1]).toContain("list-disc");
|
||||
expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>');
|
||||
expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>');
|
||||
expect(html).toContain(TASK_LIST_ITEM_CLASS);
|
||||
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
|
||||
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0"> pickup package</div>');
|
||||
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
|
||||
});
|
||||
|
||||
it("keeps compact styling for pure task lists", () => {
|
||||
const html = renderListContent("- [ ] pickup package\n- [ ] library returns");
|
||||
|
||||
expect(html).toMatch(/<ul class="[^"]*\blist-none\b[^"]*"/);
|
||||
expect(html).not.toMatch(/<ul class="[^"]*\blist-disc\b[^"]*"/);
|
||||
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
|
||||
});
|
||||
|
||||
it("keeps nested task lists on their own row", () => {
|
||||
const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
|
||||
|
||||
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
|
||||
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0"> asdas');
|
||||
expect(html).not.toContain("[&_ul.contains-task-list]:ml-6");
|
||||
expect(html).toContain("zzzz");
|
||||
});
|
||||
|
||||
it("keeps loose task list paragraphs while aligning the first line", () => {
|
||||
const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
|
||||
|
||||
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<input type="checkbox" disabled=""\/>/);
|
||||
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0">');
|
||||
expect(html).toContain("<p> plan</p>");
|
||||
expect(html).toContain("<p>keep details</p>");
|
||||
expect(html).toContain("zzzz");
|
||||
});
|
||||
|
||||
it("keeps inline task markdown in the task body", () => {
|
||||
const html = renderListContent("- [ ] Northern Lights in Iceland — booking this for winter, *finally*");
|
||||
|
||||
expect(html).toContain('<input type="checkbox" disabled=""/>');
|
||||
expect(html).toContain(
|
||||
'<div class="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0"> Northern Lights in Iceland — booking this for winter, <em>finally</em></div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSingleLinkHref } from "@/components/MemoContent/markdown/Paragraph";
|
||||
|
||||
const collectSingleLinkHrefs = (content: string): Array<string | undefined> => {
|
||||
const hrefs: Array<string | undefined> = [];
|
||||
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children, node }) => {
|
||||
hrefs.push(getSingleLinkHref(node));
|
||||
return <p>{children}</p>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
return hrefs;
|
||||
};
|
||||
|
||||
describe("memo content paragraph links", () => {
|
||||
it("treats only bare single-link paragraphs as single link hrefs", () => {
|
||||
expect(collectSingleLinkHrefs("https://www.bilibili.com/\n\n[bilibili](https://www.bilibili.com/)")).toEqual([
|
||||
"https://www.bilibili.com/",
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants";
|
||||
|
||||
type IframeProps = React.ComponentProps<"iframe">;
|
||||
|
||||
const TrustedIframe = (props: IframeProps) => {
|
||||
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
|
||||
return null;
|
||||
}
|
||||
return <iframe {...props} />;
|
||||
};
|
||||
|
||||
const renderMemoContent = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]]}
|
||||
components={{ iframe: TrustedIframe }}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
const renderGfmContent = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, SANITIZE_SCHEMA]]}>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
describe("memo content sanitization", () => {
|
||||
it("strips user-controlled inline styles from raw HTML spans", () => {
|
||||
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
|
||||
|
||||
expect(html).toMatch(/<span>overlay<\/span>/);
|
||||
expect(html).not.toMatch(/style=/);
|
||||
expect(html).not.toMatch(/position:fixed/);
|
||||
});
|
||||
|
||||
it("still renders KaTeX output after sanitizing math marker classes", () => {
|
||||
const html = renderMemoContent("$L$");
|
||||
|
||||
expect(html).toMatch(/class="katex"/);
|
||||
expect(html).toMatch(/class="katex-html"/);
|
||||
});
|
||||
|
||||
it("preserves checked state for GFM task list items", () => {
|
||||
const html = renderGfmContent("- [x] Done\n- [ ] Todo");
|
||||
const inputs = html.match(/<input[^>]+\/>/g) ?? [];
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toContain('checked=""');
|
||||
expect(inputs[1]).not.toContain('checked=""');
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted iframe providers", () => {
|
||||
it("accepts trusted providers only", () => {
|
||||
expect(isTrustedIframeSrc("https://www.youtube.com/embed/abc123")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://player.vimeo.com/video/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.loom.com/embed/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://app.diagrams.net/?embed=1")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.draw.io/?embed=1")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://evil.example/embed/abc123")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops untrusted iframe embeds during rendering", () => {
|
||||
const trusted = renderMemoContent('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
|
||||
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
|
||||
|
||||
expect(trusted).toMatch(/<iframe/);
|
||||
expect(trusted).toMatch(/youtube\.com\/embed\/abc123/);
|
||||
expect(untrusted).not.toMatch(/<iframe/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cacheService } from "@/components/MemoEditor/services/cacheService";
|
||||
|
||||
describe("memo editor cache", () => {
|
||||
beforeEach(() => {
|
||||
const storage = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => storage.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||
});
|
||||
cacheService.clearAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("stores draft content", () => {
|
||||
const key = cacheService.key("users/steven", "home-memo-editor");
|
||||
|
||||
cacheService.saveNow(key, "- [x] Draft task");
|
||||
|
||||
expect(cacheService.load(key)).toBe("- [x] Draft task");
|
||||
});
|
||||
|
||||
it("removes empty draft content instead of caching it", () => {
|
||||
const key = cacheService.key("users/steven", "home-memo-editor");
|
||||
|
||||
cacheService.saveNow(key, "");
|
||||
|
||||
expect(cacheService.load(key)).toBe("");
|
||||
});
|
||||
|
||||
it("loads content from previously structured draft entries", () => {
|
||||
const key = cacheService.key("users/steven", "home-memo-editor");
|
||||
localStorage.setItem(key, JSON.stringify({ kind: "memos.editor-cache", version: 1, content: "- [ ] migrated task" }));
|
||||
|
||||
expect(cacheService.load(key)).toBe("- [ ] migrated task");
|
||||
});
|
||||
|
||||
it("keeps raw JSON markdown drafts intact", () => {
|
||||
const key = cacheService.key("users/steven", "home-memo-editor");
|
||||
const jsonDraft = '{"content":"not a cache envelope"}';
|
||||
localStorage.setItem(key, jsonDraft);
|
||||
|
||||
expect(cacheService.load(key)).toBe(jsonDraft);
|
||||
});
|
||||
|
||||
it("keeps structured-looking drafts without a supported version intact", () => {
|
||||
const key = cacheService.key("users/steven", "home-memo-editor");
|
||||
const jsonDraft = JSON.stringify({ kind: "memos.editor-cache", content: "not a supported envelope" });
|
||||
localStorage.setItem(key, jsonDraft);
|
||||
|
||||
expect(cacheService.load(key)).toBe(jsonDraft);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
|
||||
|
||||
describe("MemoEditor CodeMirror extensions", () => {
|
||||
const views: EditorView[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) {
|
||||
view.destroy();
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("uses CodeMirror's selection and placeholder extensions without enabling multi-cursor selection", () => {
|
||||
const parent = document.body.appendChild(document.createElement("div"));
|
||||
const state = EditorState.create({
|
||||
doc: "",
|
||||
extensions: buildEditorExtensions({
|
||||
placeholder: "Any thoughts...",
|
||||
onChange: vi.fn(),
|
||||
onFiles: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
getTags: () => [],
|
||||
}),
|
||||
});
|
||||
const view = new EditorView({ state, parent });
|
||||
views.push(view);
|
||||
|
||||
expect(view.state.facet(EditorState.allowMultipleSelections)).toBe(false);
|
||||
expect(view.dom.querySelector(".cm-selectionLayer")).not.toBeNull();
|
||||
expect(view.dom.querySelector(".cm-cursorLayer")).not.toBeNull();
|
||||
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
|
||||
expect(view.dom.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts...");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["paste", "clipboardData"],
|
||||
["drop", "dataTransfer"],
|
||||
] as const)("intercepts files on %s before CodeMirror inserts their contents", (eventType, transferProperty) => {
|
||||
const onFiles = vi.fn();
|
||||
const parent = document.body.appendChild(document.createElement("div"));
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "memo",
|
||||
extensions: buildEditorExtensions({
|
||||
placeholder: "",
|
||||
onChange: vi.fn(),
|
||||
onFiles,
|
||||
onUpdate: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
getTags: () => [],
|
||||
}),
|
||||
}),
|
||||
parent,
|
||||
});
|
||||
views.push(view);
|
||||
const file = new File(["must not become memo content"], "attachment.txt", { type: "text/plain" });
|
||||
const transfer =
|
||||
eventType === "paste"
|
||||
? { items: [{ kind: "file", getAsFile: () => file }], files: [file] }
|
||||
: { files: [file], types: ["Files"] };
|
||||
const event = new Event(eventType, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, transferProperty, { value: transfer });
|
||||
|
||||
view.contentDOM.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("memo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMemoShareImageFileName,
|
||||
getMemoShareDialogWidth,
|
||||
getMemoSharePreviewAvatarUrl,
|
||||
getMemoSharePreviewWidth,
|
||||
getMemoShareRenderWidth,
|
||||
} from "@/components/MemoActionMenu/memoShareImage";
|
||||
import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel";
|
||||
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const buildMemo = (overrides: Partial<Memo> = {}) =>
|
||||
create(MemoSchema, {
|
||||
name: "memos/test",
|
||||
content: "hello",
|
||||
tags: [],
|
||||
attachments: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildAttachment = (overrides: Partial<Attachment>) =>
|
||||
create(AttachmentSchema, {
|
||||
name: "attachments/test",
|
||||
filename: "test.bin",
|
||||
type: "application/octet-stream",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildPreviewModel = (memo: Memo) =>
|
||||
buildMemoShareImagePreviewModel({
|
||||
memo,
|
||||
fallbackDisplayName: "Memo",
|
||||
locale: "en-US",
|
||||
});
|
||||
|
||||
describe("memo share image preview model", () => {
|
||||
it("does not create footer chips for memo tags already rendered in content", () => {
|
||||
const memo = buildMemo({
|
||||
content: "Investigate #bug",
|
||||
tags: ["bug"],
|
||||
});
|
||||
|
||||
const model = buildPreviewModel(memo);
|
||||
|
||||
expect(model.footerBadges).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps non-visual attachments visible as a footer summary", () => {
|
||||
const memo = buildMemo({
|
||||
attachments: [
|
||||
buildAttachment({
|
||||
name: "attachments/doc",
|
||||
filename: "doc.pdf",
|
||||
type: "application/pdf",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const model = buildPreviewModel(memo);
|
||||
|
||||
expect(model.visualItems).toEqual([]);
|
||||
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 1 }]);
|
||||
});
|
||||
|
||||
it("keeps visual attachments in the media grid without adding a footer summary", () => {
|
||||
const memo = buildMemo({
|
||||
attachments: [
|
||||
buildAttachment({
|
||||
name: "attachments/image",
|
||||
filename: "image.png",
|
||||
type: "image/png",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const model = buildPreviewModel(memo);
|
||||
|
||||
expect(model.visualItems).toHaveLength(1);
|
||||
expect(model.visualItems[0]?.posterUrl).toContain("/file/attachments/image/image.png?thumbnail=true");
|
||||
expect(model.footerBadges).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts mixed visual and non-visual attachments in the summary", () => {
|
||||
const memo = buildMemo({
|
||||
attachments: [
|
||||
buildAttachment({
|
||||
name: "attachments/image",
|
||||
filename: "image.png",
|
||||
type: "image/png",
|
||||
}),
|
||||
buildAttachment({
|
||||
name: "attachments/archive",
|
||||
filename: "archive.zip",
|
||||
type: "application/zip",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const model = buildPreviewModel(memo);
|
||||
|
||||
expect(model.visualItems).toHaveLength(1);
|
||||
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memo share image utilities", () => {
|
||||
it("builds filenames from memo resource names", () => {
|
||||
expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png");
|
||||
expect(buildMemoShareImageFileName("")).toBe("memo-memo.png");
|
||||
});
|
||||
|
||||
it("clamps preview and dialog widths", () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
|
||||
|
||||
expect(getMemoSharePreviewWidth(100)).toBe(260);
|
||||
expect(getMemoSharePreviewWidth(800)).toBe(520);
|
||||
expect(getMemoShareDialogWidth(520)).toBe(600);
|
||||
expect(getMemoShareRenderWidth(520, 600)).toBe(560);
|
||||
});
|
||||
|
||||
it("uses the viewport when no card width is available", () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
|
||||
|
||||
expect(getMemoSharePreviewWidth(0)).toBe(317);
|
||||
});
|
||||
|
||||
it("keeps only exportable avatar URLs", () => {
|
||||
expect(getMemoSharePreviewAvatarUrl("/avatars/a.png")).toBe("/avatars/a.png");
|
||||
expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
|
||||
expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MermaidBlock } from "@/components/MemoContent/MermaidBlock";
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({
|
||||
userGeneralSetting: { theme: "default" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderMermaid = vi.fn(async () => ({ svg: '<svg data-testid="diagram"></svg>' }));
|
||||
const initializeMermaid = vi.fn();
|
||||
|
||||
vi.mock("mermaid", () => ({
|
||||
default: {
|
||||
initialize: initializeMermaid,
|
||||
render: renderMermaid,
|
||||
},
|
||||
}));
|
||||
|
||||
const codeElement = (content: string) => <code className="language-mermaid">{content}</code>;
|
||||
|
||||
describe("MermaidBlock", () => {
|
||||
it("clears rendered output when code content becomes empty", async () => {
|
||||
const { container, rerender } = render(<MermaidBlock>{codeElement("graph TD; A-->B")}</MermaidBlock>);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).not.toBeNull());
|
||||
|
||||
rerender(<MermaidBlock>{codeElement("")}</MermaidBlock>);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { storeOAuthState, validateOAuthState } from "@/utils/oauth";
|
||||
|
||||
describe("oauth state", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("round-trips the linking user for link flows", async () => {
|
||||
const { state } = await storeOAuthState("identity-providers/google", "link", "/settings", "users/alice");
|
||||
|
||||
expect(validateOAuthState(state)).toEqual({
|
||||
identityProviderName: "identity-providers/google",
|
||||
flowMode: "link",
|
||||
returnUrl: "/settings",
|
||||
linkingUserName: "users/alice",
|
||||
codeVerifier: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults older states to signin without a linking user", () => {
|
||||
sessionStorage.setItem(
|
||||
"oauth_state",
|
||||
JSON.stringify({
|
||||
state: "legacy-state",
|
||||
identityProviderName: "identity-providers/google",
|
||||
timestamp: Date.now(),
|
||||
returnUrl: "/auth",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(validateOAuthState("legacy-state")).toEqual({
|
||||
identityProviderName: "identity-providers/google",
|
||||
flowMode: "signin",
|
||||
returnUrl: "/auth",
|
||||
linkingUserName: undefined,
|
||||
codeVerifier: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false }));
|
||||
const feed = vi.hoisted(() => ({ memos: [] as unknown[] }));
|
||||
|
||||
vi.mock("@/hooks/useMemoQueries", () => ({
|
||||
useInfiniteMemos: () => ({
|
||||
data: { pages: [{ memos: feed.memos, nextPageToken: "" }] },
|
||||
fetchNextPage: vi.fn(async () => undefined),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/MemoFilterContext", () => ({
|
||||
useMemoFilterContext: () => ({ filters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/ViewContext", () => ({
|
||||
useView: () => view,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => (key === "message.no-data" ? "No data found." : key),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
|
||||
MentionResolutionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoFilters", () => ({
|
||||
default: () => <div data-testid="memo-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoEditor", () => ({
|
||||
default: () => <div data-testid="memo-editor" />,
|
||||
}));
|
||||
|
||||
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
|
||||
|
||||
const renderList = (
|
||||
renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />,
|
||||
options: { showMemoEditor?: boolean } = {},
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<PagedMemoList renderer={renderer} showMemoEditor={options.showMemoEditor} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("<PagedMemoList>", () => {
|
||||
beforeEach(() => {
|
||||
view.maxColumns = 1;
|
||||
view.compactMode = false;
|
||||
feed.memos = [];
|
||||
});
|
||||
|
||||
it("uses the tile sprite Placeholder for the empty state", () => {
|
||||
renderList();
|
||||
|
||||
expect(screen.getByText("No data found.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state below the memo editor", () => {
|
||||
renderList(undefined, { showMemoEditor: true });
|
||||
|
||||
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
|
||||
expect(screen.getByText("No data found.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places the empty state in the first grid column", () => {
|
||||
view.maxColumns = 0;
|
||||
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
|
||||
try {
|
||||
renderList(undefined, { showMemoEditor: true });
|
||||
|
||||
const leadingTile = screen.getByText("No data found.").closest(".absolute");
|
||||
expect(leadingTile).not.toBeNull();
|
||||
expect(leadingTile).toContainElement(screen.getByTestId("memo-editor"));
|
||||
} finally {
|
||||
widthSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe("compact policy", () => {
|
||||
beforeEach(() => {
|
||||
feed.memos = [memo];
|
||||
});
|
||||
|
||||
it("threads compact=false at one column with compact mode off", () => {
|
||||
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
|
||||
renderList(renderer);
|
||||
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
|
||||
});
|
||||
|
||||
it("threads compact=true at one column with compact mode on", () => {
|
||||
view.compactMode = true;
|
||||
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
|
||||
renderList(renderer);
|
||||
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
|
||||
});
|
||||
|
||||
it("respects the compact setting in the narrow-width fallback even when columns are allowed", () => {
|
||||
// jsdom measures 0px, so the flow fallback renders and behaves exactly like maxColumns = 1.
|
||||
view.maxColumns = 0;
|
||||
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
|
||||
renderList(renderer);
|
||||
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
|
||||
});
|
||||
|
||||
it("forces compact once the width fits the grid", () => {
|
||||
view.maxColumns = 0;
|
||||
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
|
||||
try {
|
||||
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
|
||||
renderList(renderer);
|
||||
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
|
||||
} finally {
|
||||
widthSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PasswordSignInForm from "@/components/PasswordSignInForm";
|
||||
|
||||
vi.mock("@/auth-state", () => ({ setAccessToken: vi.fn() }));
|
||||
|
||||
vi.mock("@/connect", () => ({
|
||||
authServiceClient: { signIn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ initialize: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useNavigateTo", () => ({
|
||||
default: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("<PasswordSignInForm>", () => {
|
||||
it("does not prefill seeded demo credentials", () => {
|
||||
render(<PasswordSignInForm />);
|
||||
|
||||
expect(screen.getByPlaceholderText("common.username")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("common.password")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import Placeholder from "@/components/Placeholder";
|
||||
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
|
||||
|
||||
describe("<Placeholder>", () => {
|
||||
it("renders the default message for variant=empty", () => {
|
||||
render(<Placeholder variant="empty" />);
|
||||
expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the default message for variant=loading", () => {
|
||||
render(<Placeholder variant="loading" />);
|
||||
expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the default message for variant=noResults", () => {
|
||||
render(<Placeholder variant="noResults" />);
|
||||
expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the default message for variant=notFound", () => {
|
||||
render(<Placeholder variant="notFound" />);
|
||||
expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("overrides the default message when `message` prop is passed", () => {
|
||||
render(<Placeholder variant="empty" message="Custom copy goes here" />);
|
||||
expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
|
||||
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 32px sprite tileset at a crisp 2x display scale", () => {
|
||||
const { container } = render(<Placeholder variant="empty" />);
|
||||
const viewport = screen.getByTestId("placeholder-sprite");
|
||||
const strip = viewport.firstElementChild;
|
||||
|
||||
expect(viewport).toHaveAttribute("aria-hidden", "true");
|
||||
expect(viewport).toHaveStyle({
|
||||
width: "64px",
|
||||
height: "64px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
expect(strip).toHaveAttribute("src", expect.stringMatching(/(\.svg|data:image\/svg\+xml)/));
|
||||
expect(strip).toHaveAttribute("width", expect.stringMatching(/^(128|160|192)$/));
|
||||
expect(strip).toHaveAttribute("height", "32");
|
||||
expect(["256px", "320px", "384px"]).toContain((strip as HTMLElement).style.width);
|
||||
expect(["steps(4)", "steps(5)", "steps(6)"]).toContain((strip as HTMLElement).style.animationTimingFunction);
|
||||
expect(strip).toHaveStyle({
|
||||
height: "64px",
|
||||
imageRendering: "pixelated",
|
||||
});
|
||||
expect(container.firstChild).toHaveClass("max-w-md");
|
||||
});
|
||||
|
||||
it("does not render registry credit strings in the UI", () => {
|
||||
render(<Placeholder variant="empty" />);
|
||||
expect(screen.queryByText(/Memos original ASCII art/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/jgs|Joan Stark/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
|
||||
const { rerender, container } = render(<Placeholder variant="empty" />);
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
|
||||
rerender(<Placeholder variant="loading" />);
|
||||
const live = container.querySelector('[role="status"]');
|
||||
expect(live).not.toBeNull();
|
||||
expect(live).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("renders children below the message when provided", () => {
|
||||
render(
|
||||
<Placeholder variant="notFound">
|
||||
<button type="button">Go home</button>
|
||||
</Placeholder>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges a custom className onto the outer wrapper", () => {
|
||||
const { container } = render(<Placeholder variant="empty" className="custom-test-class" />);
|
||||
expect(container.firstChild).toHaveClass("custom-test-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
|
||||
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
|
||||
|
||||
describe("TILE_SPRITES integrity", () => {
|
||||
it("registers 32px by 32px sprite strips with animation-specific frame counts", () => {
|
||||
expect(TILE_SPRITES.map((sprite) => sprite.name)).toEqual(["OwlBlink", "EagleIdle", "ToucanIdle"]);
|
||||
expect(TILE_SPRITES.map((sprite) => [sprite.name, sprite.frames])).toEqual([
|
||||
["OwlBlink", 5],
|
||||
["EagleIdle", 4],
|
||||
["ToucanIdle", 4],
|
||||
]);
|
||||
|
||||
for (const sprite of TILE_SPRITES) {
|
||||
expect(sprite.name).toMatch(/^[A-Z][A-Za-z]+(Idle|Hop|Blink|Drift|Flutter|Hover|Peck)$/);
|
||||
expect(sprite.frameWidth).toBe(32);
|
||||
expect(sprite.frameHeight).toBe(32);
|
||||
expect(sprite.frames).toBeGreaterThanOrEqual(2);
|
||||
expect(sprite.src).toMatch(/(\.svg|data:image\/svg\+xml)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a registered tile sprite from the pool", () => {
|
||||
const sprite = pickTileSprite();
|
||||
expect(TILE_SPRITES).toContain(sprite);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MESSAGES", () => {
|
||||
it("provides a non-empty message for every variant", () => {
|
||||
for (const variant of Object.keys(DEFAULT_MESSAGES) as PlaceholderVariant[]) {
|
||||
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
|
||||
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PreviewImageDialog from "@/components/PreviewImageDialog";
|
||||
|
||||
vi.mock("@/hooks/useMediaQuery", () => ({
|
||||
__esModule: true,
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
describe("<PreviewImageDialog>", () => {
|
||||
it("provides a dialog description without Radix accessibility warnings", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Missing `Description`"));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps hook order stable when preview items appear after an empty render", () => {
|
||||
const { rerender } = render(<PreviewImageDialog open onOpenChange={vi.fn()} items={[]} />);
|
||||
|
||||
expect(() => {
|
||||
rerender(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByAltText("Preview image 1 of 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows zoom controls for image previews", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reset zoom/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("100%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles image zoom on double click", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByAltText("Preview image 1 of 1");
|
||||
|
||||
fireEvent.doubleClick(image);
|
||||
|
||||
expect(image).toHaveStyle({ transform: "translate3d(0px, 0px, 0) scale(2)" });
|
||||
expect(screen.getByText("200%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("zooms image previews with the wheel", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.wheel(screen.getByTestId("preview-zoom-surface"), { deltaY: -100 });
|
||||
|
||||
expect(screen.getByText("120%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show zoom controls for video previews", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "video-1", kind: "video", sourceUrl: "/video.mp4", posterUrl: "/poster.jpg", filename: "video.mp4" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /zoom in/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /zoom out/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reset zoom/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps previous and next controls available for mobile image galleries", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[
|
||||
{ id: "image-1", kind: "image", sourceUrl: "/image-1.jpg", posterUrl: "/image-1.jpg", filename: "image-1.jpg" },
|
||||
{ id: "image-2", kind: "image", sourceUrl: "/image-2.jpg", posterUrl: "/image-2.jpg", filename: "image-2.jpg" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /previous item/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next item/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTH_REDIRECT_PARAM,
|
||||
buildAuthRoute,
|
||||
getSafeRedirectPath,
|
||||
isPublicRoute,
|
||||
shouldGatePrivateInstance,
|
||||
} from "@/utils/redirect-safety";
|
||||
|
||||
describe("getSafeRedirectPath", () => {
|
||||
it("accepts safe same-origin internal paths", () => {
|
||||
expect(getSafeRedirectPath("/home")).toBe("/home");
|
||||
expect(getSafeRedirectPath("/setting")).toBe("/setting");
|
||||
expect(getSafeRedirectPath("/memos/abc")).toBe("/memos/abc");
|
||||
expect(getSafeRedirectPath("/explore?foo=1")).toBe("/explore?foo=1");
|
||||
expect(getSafeRedirectPath("/explore#anchor")).toBe("/explore#anchor");
|
||||
});
|
||||
|
||||
it("rejects empty and non-string input", () => {
|
||||
expect(getSafeRedirectPath(undefined)).toBeUndefined();
|
||||
expect(getSafeRedirectPath(null)).toBeUndefined();
|
||||
expect(getSafeRedirectPath("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-internal targets", () => {
|
||||
expect(getSafeRedirectPath("//evil.example")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("https://evil.example")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("http://evil.example/home")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("javascript:alert(1)")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("home")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects auth-family targets", () => {
|
||||
expect(getSafeRedirectPath("/auth")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth/callback")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth/signup")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth?code=abc")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not false-match auth-like paths", () => {
|
||||
expect(getSafeRedirectPath("/authors")).toBe("/authors");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuthRoute", () => {
|
||||
it("embeds only safe redirect targets", () => {
|
||||
expect(buildAuthRoute({ redirect: "/home" })).toBe("/auth?redirect=%2Fhome");
|
||||
expect(buildAuthRoute({ redirect: "//evil.example" })).toBe("/auth");
|
||||
expect(buildAuthRoute({ redirect: "/auth/callback" })).toBe("/auth");
|
||||
expect(buildAuthRoute({ redirect: null })).toBe("/auth");
|
||||
});
|
||||
|
||||
it("preserves the reason parameter", () => {
|
||||
expect(buildAuthRoute({ reason: "protected-memo" })).toBe("/auth?reason=protected-memo");
|
||||
expect(buildAuthRoute({ redirect: "/memos/abc", reason: "protected-memo" })).toBe(
|
||||
"/auth?redirect=%2Fmemos%2Fabc&reason=protected-memo",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the canonical redirect query key", () => {
|
||||
expect(AUTH_REDIRECT_PARAM).toBe("redirect");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicRoute", () => {
|
||||
it("identifies anonymous-accessible page prefixes", () => {
|
||||
expect(isPublicRoute("/auth")).toBe(true);
|
||||
expect(isPublicRoute("/auth/signup")).toBe(true);
|
||||
expect(isPublicRoute("/about")).toBe(true);
|
||||
expect(isPublicRoute("/explore")).toBe(true);
|
||||
expect(isPublicRoute("/memos/abc")).toBe(true);
|
||||
expect(isPublicRoute("/memos/shares/abc")).toBe(true);
|
||||
expect(isPublicRoute("/u/steven")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats authenticated-only pages as non-public", () => {
|
||||
expect(isPublicRoute("/home")).toBe(false);
|
||||
expect(isPublicRoute("/setting")).toBe(false);
|
||||
expect(isPublicRoute("/inbox")).toBe(false);
|
||||
expect(isPublicRoute("/attachments")).toBe(false);
|
||||
expect(isPublicRoute("/archived")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldGatePrivateInstance", () => {
|
||||
it("never gates on an open (public) instance", () => {
|
||||
expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/" })).toBe(false);
|
||||
expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/explore" })).toBe(false);
|
||||
});
|
||||
|
||||
it("never gates an authenticated visitor", () => {
|
||||
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: true, pathname: "/explore" })).toBe(false);
|
||||
});
|
||||
|
||||
it("gates anonymous visitors to non-share pages on a private instance", () => {
|
||||
for (const pathname of ["/", "/explore", "/about", "/memos/abc", "/u/steven", "/setting"]) {
|
||||
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps share links reachable for anonymous visitors on a private instance", () => {
|
||||
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname: "/memos/shares/token123" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
||||
|
||||
const renderMarkdown = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkTag]}>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
describe("remarkTag", () => {
|
||||
it("does not turn URL fragments inside autolinks into tags", () => {
|
||||
const html = renderMarkdown("https://github.com/dmtrKovalenko/fff#pi-agent-extension\n\nProject #memo-tag");
|
||||
|
||||
expect(html).toContain('href="https://github.com/dmtrKovalenko/fff#pi-agent-extension"');
|
||||
expect(html).not.toContain('data-tag="pi-agent-extension"');
|
||||
expect(html).toContain('data-tag="memo-tag"');
|
||||
});
|
||||
|
||||
it("does not turn link text or reference link fragments into tags", () => {
|
||||
const html = renderMarkdown(
|
||||
[
|
||||
"[release #notes](https://example.com/releases#release-notes)",
|
||||
"[**section #heading**](https://example.com/docs#section-heading)",
|
||||
"",
|
||||
"[reference #anchor][docs]",
|
||||
"",
|
||||
"[docs]: https://example.com/docs#reference-anchor",
|
||||
"",
|
||||
"Outside #memo-tag",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-tag="notes"');
|
||||
expect(html).not.toContain('data-tag="heading"');
|
||||
expect(html).not.toContain('data-tag="image"');
|
||||
expect(html).not.toContain('data-tag="anchor"');
|
||||
expect(html).not.toContain('data-tag="release-notes"');
|
||||
expect(html).not.toContain('data-tag="section-heading"');
|
||||
expect(html).not.toContain('data-tag="preview"');
|
||||
expect(html).not.toContain('data-tag="reference-anchor"');
|
||||
expect(html).toContain('data-tag="memo-tag"');
|
||||
});
|
||||
|
||||
it("continues to turn formatted text outside links into tags", () => {
|
||||
const html = renderMarkdown("**#urgent** and _#later_");
|
||||
|
||||
expect(html).toContain('data-tag="urgent"');
|
||||
expect(html).toContain('data-tag="later"');
|
||||
});
|
||||
|
||||
it("does not turn a backslash-escaped \\#tag into a tag, but still tags an unescaped one", () => {
|
||||
const html = renderMarkdown("\\#NAS is my server and a #real tag");
|
||||
|
||||
// Escaped: rendered as the literal text "#NAS", never a tag pill.
|
||||
expect(html).not.toContain('data-tag="NAS"');
|
||||
expect(html).toContain("#NAS");
|
||||
// Unescaped neighbour is unaffected.
|
||||
expect(html).toContain('data-tag="real"');
|
||||
});
|
||||
|
||||
it("escapes only the marked hash when escaped and unescaped tags share a node", () => {
|
||||
const html = renderMarkdown("\\#first then #second");
|
||||
|
||||
expect(html).not.toContain('data-tag="first"');
|
||||
expect(html).toContain("#first");
|
||||
expect(html).toContain('data-tag="second"');
|
||||
});
|
||||
|
||||
it("tags a whole word containing combining marks", () => {
|
||||
// Malayalam കവിത = ka, va, vowel-sign-i (U+0D3F, a spacing combining mark),
|
||||
// ta. The vowel sign is a \p{M} character, so the tag must not stop at കവ.
|
||||
const html = renderMarkdown("#കവിത");
|
||||
|
||||
expect(html).toContain('data-tag="കവിത"');
|
||||
expect(html).not.toContain('data-tag="കവ"');
|
||||
});
|
||||
|
||||
it("still tags a hash that shares a text node with an entity reference", () => {
|
||||
// The source slice ("...&...") differs from the decoded value, so the
|
||||
// escape-aware path bows out and the tag is detected the original way.
|
||||
const html = renderMarkdown("Tom & Jerry #cartoon");
|
||||
|
||||
expect(html).toContain('data-tag="cartoon"');
|
||||
expect(html).toContain("Tom & Jerry");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { isValidElement } from "react";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeConfig, ROUTES } from "@/router";
|
||||
import { RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
|
||||
// Walk the nested route config and find the first route with the given path,
|
||||
// starting from the provided roots. Returns undefined if nothing matches.
|
||||
function findByPath(routes: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routes) {
|
||||
if (route.path === path) return route;
|
||||
const hit = route.children ? findByPath(route.children, path) : undefined;
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function elementType(route: RouteObject | undefined): unknown {
|
||||
if (!route?.element || !isValidElement(route.element)) return undefined;
|
||||
return route.element.type;
|
||||
}
|
||||
|
||||
function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unknown): boolean {
|
||||
const walk = (subtree: RouteObject[], ancestorGuards: unknown[]): boolean => {
|
||||
for (const route of subtree) {
|
||||
const nextAncestors = [...ancestorGuards];
|
||||
const type = elementType(route);
|
||||
if (type) nextAncestors.push(type);
|
||||
if (route.path === path) {
|
||||
return nextAncestors.includes(guardType);
|
||||
}
|
||||
if (route.children && walk(route.children, nextAncestors)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return walk(routes, []);
|
||||
}
|
||||
|
||||
describe("router configuration", () => {
|
||||
it("keeps /auth/callback outside the guest-only guard", () => {
|
||||
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
|
||||
// must not short-circuit the OAuth callback via RequireGuestRoute.
|
||||
expect(hasAncestorOfType(routeConfig, "callback", RequireGuestRoute)).toBe(false);
|
||||
});
|
||||
|
||||
it("wraps the remaining /auth children in RequireGuestRoute", () => {
|
||||
for (const path of ["", "admin", "signup"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps authenticated-only pages in RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves public pages outside RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes an accessible /auth/callback route definition", () => {
|
||||
expect(findByPath(routeConfig, "callback")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// With `globals: false`, @testing-library/react does not auto-register a
|
||||
// cleanup hook, so unmount rendered trees between tests explicitly. This keeps
|
||||
// `screen.getByTestId` from seeing DOM from prior tests in the same file.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ProseMirror probes layout APIs jsdom doesn't implement.
|
||||
if (typeof document !== "undefined") {
|
||||
if (!document.elementFromPoint) {
|
||||
document.elementFromPoint = () => null;
|
||||
}
|
||||
if (typeof Range !== "undefined" && !Range.prototype.getClientRects) {
|
||||
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
|
||||
Range.prototype.getBoundingClientRect = () =>
|
||||
({ x: 0, y: 0, top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0, toJSON: () => ({}) }) as DOMRect;
|
||||
}
|
||||
// CodeMirror probes additional layout APIs jsdom doesn't implement while measuring.
|
||||
if (typeof Element !== "undefined" && !Element.prototype.getClientRects) {
|
||||
Element.prototype.getClientRects = () => [] as unknown as DOMRectList;
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom runs with an opaque origin (no URL set), so its built-in localStorage
|
||||
// implementation throws SecurityError on every access. Install a Map-backed shim
|
||||
// so any test that touches localStorage directly can call getItem/setItem/removeItem/clear
|
||||
// without additional per-file setup. Tests that need special behavior (hooks.test.tsx,
|
||||
// memo-editor-cache.test.ts) override this via Object.defineProperty / vi.stubGlobal.
|
||||
if (typeof globalThis.localStorage === "undefined" || typeof globalThis.localStorage.clear !== "function") {
|
||||
let _store = new Map<string, string>();
|
||||
const localStorageShim: Storage = {
|
||||
get length() {
|
||||
return _store.size;
|
||||
},
|
||||
getItem: (key: string) => _store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => _store.set(key, value),
|
||||
removeItem: (key: string) => _store.delete(key),
|
||||
clear: () => {
|
||||
_store = new Map<string, string>();
|
||||
},
|
||||
key: (index: number) => Array.from(_store.keys())[index] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: localStorageShim,
|
||||
});
|
||||
}
|
||||
|
||||
// Defensive shim: `@/auth-state` constructs a BroadcastChannel at module load
|
||||
// to coordinate token refreshes across tabs. jsdom historically has not shipped
|
||||
// BroadcastChannel, so any future test that transitively imports auth-state
|
||||
// would otherwise throw. Current tests avoid that import path on purpose, but
|
||||
// installing the shim keeps authoring new tests frictionless. No-op when jsdom
|
||||
// already provides an implementation.
|
||||
if (typeof globalThis.BroadcastChannel === "undefined") {
|
||||
class NoopBroadcastChannel {
|
||||
readonly name: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
postMessage(_data: unknown): void {}
|
||||
|
||||
close(): void {}
|
||||
|
||||
addEventListener(): void {}
|
||||
|
||||
removeEventListener(): void {}
|
||||
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error — attach the shim to the global scope for tests.
|
||||
globalThis.BroadcastChannel = NoopBroadcastChannel;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COMPACT_TOOLBAR_WIDTH, isCompactWidth } from "@/components/MemoEditor/hooks/useElementWidth";
|
||||
|
||||
describe("isCompactWidth", () => {
|
||||
it("treats an unmeasured (0) width as not compact (full layout default)", () => {
|
||||
expect(isCompactWidth(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("is compact below the threshold", () => {
|
||||
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH - 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("is not compact at or above the threshold", () => {
|
||||
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH)).toBe(false);
|
||||
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH + 200)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoSave } from "@/components/MemoEditor/hooks/useAutoSave";
|
||||
import { cacheService } from "@/components/MemoEditor/services/cacheService";
|
||||
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
|
||||
|
||||
// Probe surfaces the store's dispatch/actions plus the autosave API so tests can
|
||||
// drive content changes the way the editor does and assert on cache writes.
|
||||
let api: {
|
||||
dispatch: ReturnType<typeof useEditorContext>["dispatch"];
|
||||
actions: ReturnType<typeof useEditorContext>["actions"];
|
||||
discardDraft: () => void;
|
||||
};
|
||||
|
||||
function Probe({ username, cacheKey, enabled }: { username: string; cacheKey?: string; enabled?: boolean }) {
|
||||
const { dispatch, actions } = useEditorContext();
|
||||
const { discardDraft } = useAutoSave(username, cacheKey, enabled);
|
||||
api = { dispatch, actions, discardDraft };
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useAutoSave (store-subscribed)", () => {
|
||||
let saveSpy: ReturnType<typeof vi.spyOn>;
|
||||
let saveNowSpy: ReturnType<typeof vi.spyOn>;
|
||||
let clearSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
saveSpy = vi.spyOn(cacheService, "save").mockImplementation(() => {});
|
||||
saveNowSpy = vi.spyOn(cacheService, "saveNow").mockImplementation(() => {});
|
||||
clearSpy = vi.spyOn(cacheService, "clear").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists content to the draft cache when content changes", () => {
|
||||
render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k" enabled />
|
||||
</EditorProvider>,
|
||||
);
|
||||
const key = cacheService.key("users/steven", "k");
|
||||
saveSpy.mockClear(); // ignore the mount-time persist of the initial empty content
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("hello world"));
|
||||
});
|
||||
expect(saveSpy).toHaveBeenCalledWith(key, "hello world");
|
||||
});
|
||||
|
||||
it("does not persist when disabled", () => {
|
||||
render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k" enabled={false} />
|
||||
</EditorProvider>,
|
||||
);
|
||||
saveSpy.mockClear();
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("ignored"));
|
||||
});
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discardDraft clears the cache and suppresses the unmount flush", () => {
|
||||
const { unmount } = render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k2" enabled />
|
||||
</EditorProvider>,
|
||||
);
|
||||
const key = cacheService.key("users/steven", "k2");
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("draft body"));
|
||||
});
|
||||
act(() => {
|
||||
api.discardDraft();
|
||||
});
|
||||
expect(clearSpy).toHaveBeenCalledWith(key);
|
||||
|
||||
saveNowSpy.mockClear();
|
||||
unmount();
|
||||
// The just-discarded content equals the latest content, so the unmount
|
||||
// flush must NOT re-persist it as a stale draft.
|
||||
expect(saveNowSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import VideoPoster from "@/components/VideoPoster";
|
||||
|
||||
describe("<VideoPoster>", () => {
|
||||
it("renders a mobile-friendly video fallback before a frame is captured", () => {
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback");
|
||||
expect(video).toHaveAttribute("preload", "auto");
|
||||
expect(video).toHaveAttribute("playsinline");
|
||||
expect((video as HTMLVideoElement).muted).toBe(true);
|
||||
expect(video).toHaveClass("object-cover");
|
||||
});
|
||||
|
||||
it("renders a captured frame as an image poster", async () => {
|
||||
const drawImage = vi.fn();
|
||||
const toDataURL = vi.fn(() => "data:image/jpeg;base64,poster");
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === "canvas") {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => ({ drawImage })),
|
||||
toDataURL,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}
|
||||
|
||||
return originalCreateElement(tagName, options);
|
||||
});
|
||||
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "videoWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(video, "videoHeight", { configurable: true, value: 360 });
|
||||
|
||||
fireEvent.loadedData(video);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "data:image/jpeg;base64,poster");
|
||||
});
|
||||
expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 640, 360);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useView, ViewProvider } from "@/contexts/ViewContext";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "memos-view-setting";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ViewProvider>{children}</ViewProvider>;
|
||||
|
||||
const persisted = () => JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}");
|
||||
|
||||
describe("ViewContext maxColumns setting", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("defaults to a single column", () => {
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
expect(result.current.maxColumns).toBe(1);
|
||||
});
|
||||
|
||||
it("updates and persists the column ceiling", () => {
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
act(() => result.current.setMaxColumns(0));
|
||||
|
||||
expect(result.current.maxColumns).toBe(0);
|
||||
expect(persisted().maxColumns).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a persisted column count on init", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 2 }));
|
||||
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
expect(result.current.maxColumns).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to a single column for an invalid persisted value", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 7 }));
|
||||
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
expect(result.current.maxColumns).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user