From bdb0185db8e33c337de326812b029dd9e973b907 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 03:13:26 +0200 Subject: [PATCH] test(frontend): unit-test columnSort and linkify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 22 vitest cases for two pure helpers that had none: - columnSort: numeric-vs-locale comparison, asc/desc, no-mutation, the null/junk coercion in parseSortState, and the unsorted→asc→desc→unsorted click cycle. - linkify: http/www/bare-domain URLs, the version-number non-match, the ": @handle" mention (handle linked, prefix kept as text), alias→canonical base URL, bare @handle/#hashtag left plain, and multi-link ordering. Nodes are inspected structurally, so the existing node-env vitest needs no DOM. useCardPager (a hook) is deferred to when component/hook test infra lands with the backend pytest sprint. --- frontend/src/lib/columnSort.test.ts | 86 +++++++++++++++++++++++++++++ frontend/src/lib/linkify.test.ts | 73 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 frontend/src/lib/columnSort.test.ts create mode 100644 frontend/src/lib/linkify.test.ts diff --git a/frontend/src/lib/columnSort.test.ts b/frontend/src/lib/columnSort.test.ts new file mode 100644 index 00000000..4219c176 --- /dev/null +++ b/frontend/src/lib/columnSort.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { sortRows, parseSortState, cycleSort, type SortState } from "./columnSort"; + +interface Row { + name: string; + views: number; +} +const rows: Row[] = [ + { name: "beta", views: 30 }, + { name: "alpha", views: 100 }, + { name: "gamma", views: 5 }, +]; +const columns = [ + { key: "name", sortValue: (r: Row) => r.name }, + { key: "views", sortValue: (r: Row) => r.views }, + { key: "nosort" }, // a column with no sortValue +]; + +describe("sortRows", () => { + it("returns the same array reference when there is no sort", () => { + expect(sortRows(rows, columns, null)).toBe(rows); + }); + + it("returns the input unchanged for a column that has no sortValue", () => { + expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows); + }); + + it("sorts numbers by numeric difference, not string order", () => { + // string order would put "100" before "30"; numeric must not. + const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views); + expect(asc).toEqual([5, 30, 100]); + const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views); + expect(desc).toEqual([100, 30, 5]); + }); + + it("sorts strings with locale compare, ascending and descending", () => { + expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([ + "alpha", + "beta", + "gamma", + ]); + expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([ + "gamma", + "beta", + "alpha", + ]); + }); + + it("does not mutate the input array", () => { + const before = rows.map((r) => r.name); + sortRows(rows, columns, { key: "name", dir: "asc" }); + expect(rows.map((r) => r.name)).toEqual(before); + }); +}); + +describe("parseSortState", () => { + it("accepts a well-formed state", () => { + expect(parseSortState({ key: "views", dir: "desc" })).toEqual({ key: "views", dir: "desc" }); + }); + + it.each([ + ["null", null], + ["a string", "views"], + ["missing dir", { key: "views" }], + ["a bad dir", { key: "views", dir: "sideways" }], + ["a non-string key", { key: 3, dir: "asc" }], + ])("coerces %s to null rather than passing junk to the comparator", (_label, raw) => { + expect(parseSortState(raw)).toBeNull(); + }); +}); + +describe("cycleSort", () => { + it("walks unsorted → asc → desc → unsorted for the same key", () => { + let s: SortState = null; + s = cycleSort(s, "views"); + expect(s).toEqual({ key: "views", dir: "asc" }); + s = cycleSort(s, "views"); + expect(s).toEqual({ key: "views", dir: "desc" }); + s = cycleSort(s, "views"); + expect(s).toBeNull(); + }); + + it("jumps straight to ascending when a different column is clicked", () => { + expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" }); + }); +}); diff --git a/frontend/src/lib/linkify.test.ts b/frontend/src/lib/linkify.test.ts new file mode 100644 index 00000000..2a7125eb --- /dev/null +++ b/frontend/src/lib/linkify.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { isValidElement, type ReactNode } from "react"; +import { linkify } from "./linkify"; + +// linkify returns React nodes without rendering, so we can inspect them structurally in node env: +// an element carries { href, children:label }, everything else is text held in a Fragment. +function links(nodes: ReactNode[]): { href: string; label: string }[] { + return nodes + .filter( + (n): n is React.ReactElement<{ href: string; children: string }> => + isValidElement(n) && (n as React.ReactElement).type === "a", + ) + .map((n) => ({ href: n.props.href, label: n.props.children })); +} + +describe("linkify", () => { + it("leaves plain prose untouched (no anchors)", () => { + expect(links(linkify("just some text, nothing to see"))).toEqual([]); + }); + + it("links an explicit http(s) URL to itself", () => { + expect(links(linkify("see https://example.com/watch?v=1 now"))).toEqual([ + { href: "https://example.com/watch?v=1", label: "https://example.com/watch?v=1" }, + ]); + }); + + it("gives a www.* host an https:// href but keeps the bare label", () => { + expect(links(linkify("at www.example.com/foo"))).toEqual([ + { href: "https://www.example.com/foo", label: "www.example.com/foo" }, + ]); + }); + + it("links a bare domain.tld/path", () => { + expect(links(linkify("go to example.com/foo please"))).toEqual([ + { href: "https://example.com/foo", label: "example.com/foo" }, + ]); + }); + + it("does not mistake a version number for a bare URL", () => { + // letters-only TLD + a required path keep 1.2.3 out (no path, numeric last segment). + expect(links(linkify("upgraded to 1.2.3 today"))).toEqual([]); + }); + + it("links only the handle of a ': @handle' mention, keeping the label as text", () => { + const out = linkify("follow x: @jane please"); + expect(links(out)).toEqual([{ href: "https://x.com/jane", label: "@jane" }]); + // the "x: " prefix survives as text, not swallowed into the link. + const text = out + .filter((n) => typeof n === "string" || (isValidElement(n) && n.type !== "a")) + .map((n) => (isValidElement(n) ? (n.props as { children?: string }).children : n)) + .join(""); + expect(text).toContain("x: "); + }); + + it("maps a platform alias to its canonical base URL", () => { + expect(links(linkify("ig: @foo"))).toEqual([ + { href: "https://instagram.com/foo", label: "@foo" }, + ]); + }); + + it("leaves a bare @handle with no platform, and #hashtags, as plain text", () => { + expect(links(linkify("hi @nobody and #hashtag"))).toEqual([]); + }); + + it("handles several links in one string in order", () => { + const out = links(linkify("https://a.com/x then youtube: @chan then b.org/y")); + expect(out.map((l) => l.href)).toEqual([ + "https://a.com/x", + "https://www.youtube.com/@chan", + "https://b.org/y", + ]); + }); +});