diff --git a/deno.jsonc b/deno.jsonc index 2b6a093a..4bc7c5ac 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -195,9 +195,13 @@ "description": "Run and serve the site for development", "command": "deno task lume -s", }, - "test": { - "description": "Run tests", - "command": "deno test -A --doc --ignore=README.md --ignore=./docs/ --ignore=./dist/", + "test:doc": { + "description": "Run documentation tests", + "command": "deno test src -A --doc --ignore=README.md", + }, + "test:integration": { + "description": "Run integration tests", + "command": "deno test tests -A --ignore=README.md", }, }, "compilerOptions": { diff --git a/src/common/cid.js b/src/common/cid.js index 931736ad..ba76b871 100644 --- a/src/common/cid.js +++ b/src/common/cid.js @@ -4,6 +4,25 @@ import { equals, toSha256 } from "@atcute/uint8array"; /** * @param {0x55 | 0x71} code * @param {Uint8Array} data + * + * @example Returns a non-empty base32 CID string, consistent for same input, different for different inputs + * ```js + * import { create } from "~/common/cid.js"; + * + * const data = new TextEncoder().encode("hello world"); + * const cid = await create(0x55, data); + * if (typeof cid !== "string" || cid.length === 0) throw new Error("CID should be a non-empty string"); + * if (!/^[a-z2-7]+$/.test(cid)) throw new Error("CID should be base32-encoded"); + * + * const cid2 = await create(0x55, data); + * if (cid !== cid2) throw new Error("same input should produce same CID"); + * + * const cidDiff = await create(0x55, new TextEncoder().encode("world")); + * if (cid === cidDiff) throw new Error("different input should produce different CID"); + * + * const cidCodec = await create(0x71, data); + * if (cid === cidCodec) throw new Error("different codec should produce different CID"); + * ``` */ export async function create(code, data) { const cid = await CID.create(code, data); @@ -13,6 +32,16 @@ export async function create(code, data) { /** * @param {Uint8Array} data * @param {string} expected + * + * @example Returns true for matching data and false for mismatched data + * ```js + * import { create, verify } from "~/common/cid.js"; + * + * const data = new TextEncoder().encode("hello"); + * const cid = await create(0x55, data); + * if (!await verify(data, cid)) throw new Error("should verify matching data"); + * if (await verify(new TextEncoder().encode("world"), cid)) throw new Error("should not verify mismatched data"); + * ``` */ export async function verify(data, expected) { const expectedCid = CID.fromString(expected); diff --git a/src/common/compare.js b/src/common/compare.js index 5010d4cd..0af7c18a 100644 --- a/src/common/compare.js +++ b/src/common/compare.js @@ -3,6 +3,31 @@ import deepDiff from "@fry69/deep-diff"; /** * @param {any} a * @param {any} b + * + * @example Returns true for identical primitives and false for different ones + * ```js + * import { diff } from "~/common/compare.js"; + * + * if (!diff(1, 1)) throw new Error("identical primitives should be equal"); + * if (diff(1, 2)) throw new Error("different primitives should not be equal"); + * ``` + * + * @example Returns true for deeply equal objects and false for different ones + * ```js + * import { diff } from "~/common/compare.js"; + * + * if (!diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })) throw new Error("deeply equal objects should be equal"); + * if (diff({ a: 1 }, { a: 2 })) throw new Error("objects with different values should not be equal"); + * if (diff({ a: 1 }, { b: 1 })) throw new Error("objects with different keys should not be equal"); + * ``` + * + * @example Returns true for identical arrays and false for different ones + * ```js + * import { diff } from "~/common/compare.js"; + * + * if (!diff([1, 2, 3], [1, 2, 3])) throw new Error("identical arrays should be equal"); + * if (diff([1, 2], [1, 3])) throw new Error("arrays with different elements should not be equal"); + * ``` */ export function diff(a, b) { return !deepDiff(a, b); @@ -11,6 +36,24 @@ export function diff(a, b) { /** * @param {any} a * @param {any} b + * + * @example Returns true for same primitive value, false for different + * ```js + * import { strictEquality } from "~/common/compare.js"; + * + * if (!strictEquality(42, 42)) throw new Error("same primitives should be strictly equal"); + * if (strictEquality(42, 43)) throw new Error("different primitives should not be strictly equal"); + * if (strictEquality(null, undefined)) throw new Error("null and undefined should not be strictly equal"); + * ``` + * + * @example Returns false for same-content objects (reference inequality) + * ```js + * import { strictEquality } from "~/common/compare.js"; + * + * if (strictEquality({ a: 1 }, { a: 1 })) throw new Error("different object references should not be strictly equal"); + * const obj = { a: 1 }; + * if (!strictEquality(obj, obj)) throw new Error("same object reference should be strictly equal"); + * ``` */ export function strictEquality(a, b) { return a === b; diff --git a/src/common/facets/category.js b/src/common/facets/category.js index ef237734..511fca9d 100644 --- a/src/common/facets/category.js +++ b/src/common/facets/category.js @@ -5,6 +5,20 @@ /** * @param {Facet} facet * @returns {string} + * + * @example Returns accent-twist-4 for prelude kind and accent-twist-2 for others + * ```js + * import { color } from "~/common/facets/category.js"; + * + * const prelude = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" }); + * if (prelude !== "var(--accent-twist-4)") throw new Error("prelude should return accent-twist-4"); + * + * const interactive = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" }); + * if (interactive !== "var(--accent-twist-2)") throw new Error("interactive should return accent-twist-2"); + * + * const undef = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); + * if (undef !== "var(--accent-twist-2)") throw new Error("undefined kind should return accent-twist-2"); + * ``` */ export function color(facet) { switch (facet.kind) { @@ -18,6 +32,15 @@ export function color(facet) { /** * @param {Facet} facet * @returns {string} + * + * @example Returns 'feature' for prelude kind and 'interface' for others + * ```js + * import { name } from "~/common/facets/category.js"; + * + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" }) !== "feature") throw new Error("prelude should return 'feature'"); + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" }) !== "interface") throw new Error("interactive should return 'interface'"); + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }) !== "interface") throw new Error("undefined kind should return 'interface'"); + * ``` */ export function name(facet) { // return facet.kind ?? "interactive"; diff --git a/src/common/facets/constants.js b/src/common/facets/constants.js index a1ccf534..e2d4509b 100644 --- a/src/common/facets/constants.js +++ b/src/common/facets/constants.js @@ -1,3 +1,11 @@ +/** + * @example TYPE is the ATProto type string for facets + * ```js + * import { TYPE } from "~/common/facets/constants.js"; + * + * if (TYPE !== "sh.diffuse.output.facet") throw new Error(`expected "sh.diffuse.output.facet", got "${TYPE}"`); + * ``` + */ export const TYPE = /** @type {const} */ ("sh.diffuse.output.facet"); export const STARTING_SET_DISABLED = [ diff --git a/src/common/facets/utils.js b/src/common/facets/utils.js index d7371822..d1c3454a 100644 --- a/src/common/facets/utils.js +++ b/src/common/facets/utils.js @@ -10,6 +10,50 @@ import * as CID from "../cid.js"; /** * @param {{ description?: string; kind: string | undefined; name: string; tags?: string[]; uri: string }} _args * @param {{ fetchHTML: boolean }} options + * + * @example Creates a facet with correct $type, name, uri, id, and timestamps + * ```js + * import { facetFromURI } from "~/common/facets/utils.js"; + * + * const facet = await facetFromURI({ name: "My Facet", uri: "facets/test/index.html", kind: undefined, description: undefined }, { fetchHTML: false }); + * + * if (facet.$type !== "sh.diffuse.output.facet") throw new Error("$type should be sh.diffuse.output.facet"); + * if (facet.name !== "My Facet") throw new Error("name should be preserved"); + * if (facet.uri !== "facets/test/index.html") throw new Error("uri should be preserved"); + * if (typeof facet.id !== "string" || facet.id.length === 0) throw new Error("id should be a non-empty string"); + * if (!facet.createdAt) throw new Error("createdAt should be set"); + * if (facet.createdAt !== facet.updatedAt) throw new Error("createdAt and updatedAt should match"); + * if (new Date(facet.createdAt).toISOString() !== facet.createdAt) throw new Error("createdAt should be a valid ISO string"); + * ``` + * + * @example fetchHTML false leaves html and cid undefined; kind is validated + * ```js + * import { facetFromURI } from "~/common/facets/utils.js"; + * + * const base = { name: "Test", uri: "test.html", description: undefined }; + * + * const noHtml = await facetFromURI({ ...base, kind: undefined }, { fetchHTML: false }); + * if (noHtml.html !== undefined) throw new Error("html should be undefined when fetchHTML is false"); + * if (noHtml.cid !== undefined) throw new Error("cid should be undefined when fetchHTML is false"); + * + * const prelude = await facetFromURI({ ...base, kind: "prelude" }, { fetchHTML: false }); + * if (prelude.kind !== "prelude") throw new Error("prelude kind should be preserved"); + * + * const interactive = await facetFromURI({ ...base, kind: "interactive" }, { fetchHTML: false }); + * if (interactive.kind !== "interactive") throw new Error("interactive kind should be preserved"); + * + * const unknown = await facetFromURI({ ...base, kind: "unknown" }, { fetchHTML: false }); + * if (unknown.kind !== undefined) throw new Error("unrecognised kind should be set to undefined"); + * ``` + * + * @example Generates unique ids across calls + * ```js + * import { facetFromURI } from "~/common/facets/utils.js"; + * + * const a = await facetFromURI({ name: "A", uri: "a.html", kind: undefined, description: undefined }, { fetchHTML: false }); + * const b = await facetFromURI({ name: "B", uri: "b.html", kind: undefined, description: undefined }, { fetchHTML: false }); + * if (a.id === b.id) throw new Error("ids should be unique across calls"); + * ``` */ export async function facetFromURI( { description, kind, name, tags, uri }, diff --git a/src/common/output.js b/src/common/output.js index 33e06253..55204b50 100644 --- a/src/common/output.js +++ b/src/common/output.js @@ -8,6 +8,31 @@ import { effect } from "~/common/signal.js"; * @template T * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T }> }} output * @returns {Promise} + * + * @example Resolves immediately when collection is already loaded + * ```js + * import { data } from "~/common/output.js"; + * import { signal } from "~/common/signal.js"; + * + * const col = signal({ state: "loaded", data: ["a", "b"] }); + * const result = await data({ collection: col.get }); + * if (result.join(",") !== "a,b") throw new Error("expected ['a', 'b']"); + * ``` + * + * @example Waits for collection to transition to loaded + * ```js + * import { data } from "~/common/output.js"; + * import { signal } from "~/common/signal.js"; + * + * const col = signal({ state: "loading" }); + * const promise = data({ collection: col.get }); + * + * await Promise.resolve(); + * col.set({ state: "loaded", data: [1, 2, 3] }); + * + * const result = await promise; + * if (result.join(",") !== "1,2,3") throw new Error("expected [1, 2, 3]"); + * ``` */ export async function data(output) { return await new Promise((resolve) => { diff --git a/src/common/playlist.js b/src/common/playlist.js index 8112b924..357921c5 100644 --- a/src/common/playlist.js +++ b/src/common/playlist.js @@ -9,6 +9,50 @@ import { compareTimestamps } from "~/common/temporal.js"; * * @param {Track[]} tracks * @param {PlaylistItem[]} playlistItems + * + * @example Returns only tracks matching playlist criteria + * ```js + * import { filterByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, + * { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, + * ]; + * const items = [ + * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, + * ]; + * const result = filterByPlaylist(tracks, items); + * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); + * ``` + * + * @example Returns empty array when no tracks match or no items given + * ```js + * import { filterByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, + * ]; + * const noMatch = filterByPlaylist(tracks, [ + * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, + * ]); + * if (noMatch.length !== 0) throw new Error("expected no matches"); + * + * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); + * ``` + * + * @example Applies transformations before comparing + * ```js + * import { filterByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [{ $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }]; + * const items = [{ + * $type: "sh.diffuse.output.playlistItem", + * id: "i1", + * playlist: "p", + * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], + * }]; + * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); + * ``` */ export function filterByPlaylist(tracks, playlistItems) { // Group playlist items by criteria shape, building a Set index per shape. @@ -65,6 +109,35 @@ export function filterByPlaylist(tracks, playlistItems) { * Bundle playlist items into their respective playlists. * * @param {PlaylistItem[]} items + * + * @example Groups items by playlist name and tracks ordered/unordered state + * ```js + * import { gather } from "~/common/playlist.js"; + * + * const items = [ + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, + * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, + * ]; + * const map = gather(items); + * if (map.get("Rock").items.length !== 2) throw new Error("expected 2 rock items"); + * if (map.get("Pop").items.length !== 1) throw new Error("expected 1 pop item"); + * if (map.get("My Playlist") !== undefined) { + * // separate test: preserves playlist name + * } + * + * const unordered = gather([ + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, + * ]); + * if (!unordered.get("Mix").unordered) throw new Error("playlist without positionedAfter should be unordered"); + * + * const ordered = gather([ + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, + * ]); + * if (ordered.get("Mix").unordered) throw new Error("playlist with positionedAfter should be ordered"); + * ``` */ export function gather(items) { /** @@ -97,6 +170,33 @@ export function gather(items) { * * @param {Track} track * @param {PlaylistItem} item + * + * @example Returns true when all criteria match, false when any criterion fails + * ```js + * import { match } from "~/common/playlist.js"; + * + * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A", title: "Song A" } }; + * const item = { + * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", + * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], + * }; + * if (!match(track, item)) throw new Error("should match when all criteria pass"); + * + * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; + * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); + * ``` + * + * @example Applies transformations before comparing + * ```js + * import { match } from "~/common/playlist.js"; + * + * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A" } }; + * const item = { + * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", + * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], + * }; + * if (!match(track, item)) throw new Error("transformation should match lowercase"); + * ``` */ export function match(track, item) { return item.criteria.every((c) => { @@ -129,6 +229,41 @@ export function match(track, item) { * * @param {PlaylistItem[]} items * @returns {PlaylistItem[]} + * + * @example Sorts a linked list in order and appends orphaned items at end + * ```js + * import { sort } from "~/common/playlist.js"; + * + * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }]; + * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); + * + * const linked = [ + * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, + * ]; + * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); + * + * const withOrphan = [ + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, + * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, + * ]; + * const sorted = sort(withOrphan); + * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); + * ``` + * + * @example Sorts multiple heads by updatedAt ascending + * ```js + * import { sort } from "~/common/playlist.js"; + * + * const items = [ + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" }, + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" }, + * ]; + * const result = sort(items); + * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); + * ``` */ export function sort(items) { if (items.length <= 1) return items; diff --git a/src/common/signal.js b/src/common/signal.js index 029df4a1..142ff6dd 100644 --- a/src/common/signal.js +++ b/src/common/signal.js @@ -13,6 +13,23 @@ export * from "alien-signals"; /** * @param {function(): void} fn + * + * @example Defers effect execution until batch completes + * ```js + * import { signal, effect, batch } from "~/common/signal.js"; + * + * const a = signal(0); + * const b = signal(0); + * const values = []; + * + * effect(() => { values.push(a.get() + b.get()); }); + * + * const before = [...values]; // [0] + * batch(() => { a.set(1); b.set(2); }); + * + * if (before.join(",") !== "0") throw new Error("expected [0] before batch"); + * if (values.join(",") !== "0,3") throw new Error("expected exactly one update after batch, got " + values.join(",")); + * ``` */ export const batch = (fn) => { startBatch(); @@ -28,6 +45,38 @@ export const batch = (fn) => { * @param {T} initialValue * @param {{ compare?: (a: T, b: T) => boolean }} [options] * @returns {Signal} + * + * @example get/set and value getter/setter return and update the value + * ```js + * import { signal } from "~/common/signal.js"; + * + * const s = signal(42); + * if (s.get() !== 42) throw new Error("get should return initial value"); + * if (s.value !== 42) throw new Error("value getter should return initial value"); + * + * s.set(99); + * if (s.get() !== 99) throw new Error("get should return updated value"); + * + * s.value = "b"; + * if (s.value !== "b") throw new Error("value setter should update value"); + * ``` + * + * @example compare option skips update when values are equal by custom comparator + * ```js + * import { signal, effect } from "~/common/signal.js"; + * + * let runCount = 0; + * const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); + * + * effect(() => { s.get(); runCount++; }); + * + * const before = runCount; + * s.set({ x: 1 }); // same by compare + * if (runCount !== before) throw new Error("effect should not re-run when value is equal by compare"); + * + * s.set({ x: 2 }); // different by compare + * if (runCount !== before + 1) throw new Error("effect should re-run when value differs by compare"); + * ``` */ export function signal(initialValue, options) { const s = alienSignal(initialValue); @@ -53,6 +102,30 @@ export function signal(initialValue, options) { * @template T * @param {function(): T} fn * @returns {T} + * + * @example Reads a signal without tracking it as a dependency + * ```js + * import { signal, effect, untracked } from "~/common/signal.js"; + * + * const a = signal(1); + * const b = signal(10); + * let runCount = 0; + * + * effect(() => { + * a.get(); // tracked + * untracked(() => b.get()); // not tracked + * runCount++; + * }); + * + * const before = runCount; // 1 + * b.set(20); // should NOT re-run effect + * if (runCount !== before) throw new Error("untracked read should not trigger re-run"); + * + * a.set(2); // SHOULD re-run effect + * if (runCount !== before + 1) throw new Error("tracked read should trigger re-run"); + * + * if (untracked(() => a.get()) !== 2) throw new Error("untracked should return the value"); + * ``` */ export const untracked = (fn) => { const sub = setActiveSub(void 0); @@ -67,6 +140,14 @@ export const untracked = (fn) => { * @template T * @param {function(): Promise} fn * @returns {Promise} + * + * @example Returns the resolved value from the async callback + * ```js + * import { untrackedAsync } from "~/common/signal.js"; + * + * const result = await untrackedAsync(async () => 99); + * if (result !== 99) throw new Error("untrackedAsync should return resolved value"); + * ``` */ export const untrackedAsync = async (fn) => { const sub = setActiveSub(void 0); diff --git a/src/common/temporal.js b/src/common/temporal.js index 60764613..364bca7f 100644 --- a/src/common/temporal.js +++ b/src/common/temporal.js @@ -4,6 +4,23 @@ export const Temporal = /** @type {any} */ (globalThis).Temporal ?? /** * @param {string} a * @param {string} b + * + * @example Returns 0 for equal timestamps, negative for earlier, positive for later + * ```js + * import { compareTimestamps } from "~/common/temporal.js"; + * + * const eq = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z"); + * if (eq !== 0) throw new Error("equal timestamps should return 0"); + * + * const earlier = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-06-01T00:00:00.000Z"); + * if (earlier >= 0) throw new Error("earlier timestamp should return negative"); + * + * const later = compareTimestamps("2024-06-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z"); + * if (later <= 0) throw new Error("later timestamp should return positive"); + * + * const ms = compareTimestamps("2024-01-01T00:00:00.001Z", "2024-01-01T00:00:00.000Z"); + * if (ms <= 0) throw new Error("millisecond precision should be respected"); + * ``` */ export function compareTimestamps(a, b) { return Temporal.Instant.compare( diff --git a/src/common/track.js b/src/common/track.js index b5c23299..2d2bb274 100644 --- a/src/common/track.js +++ b/src/common/track.js @@ -6,6 +6,23 @@ import * as URI from "fast-uri"; /** * @param {string} uri + * + * @example Strips path and query from a URI + * ```js + * import { trackURIBase } from "~/common/track.js"; + * + * const r1 = trackURIBase("https://example.com/music/track.mp3"); + * if (r1 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r1}"`); + * + * const r2 = trackURIBase("https://example.com/track.mp3?token=abc"); + * if (r2 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r2}"`); + * + * const r3 = trackURIBase("https://example.com"); + * if (r3 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r3}"`); + * + * const r4 = trackURIBase("s3://my-bucket/path/to/track.flac"); + * if (r4 !== "s3://my-bucket") throw new Error(`expected "s3://my-bucket", got "${r4}"`); + * ``` */ export function trackURIBase(uri) { const p = URI.parse(uri); @@ -16,6 +33,27 @@ export function trackURIBase(uri) { /** * @param {Track[]} tracks + * + * @example Returns a deduplicated set of base URIs + * ```js + * import { uniqueTrackURIs } from "~/common/track.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, + * ]; + * const set = uniqueTrackURIs(tracks); + * if (!set.has("https://example.com/")) throw new Error("expected base URI in set"); + * if (set.size !== 1) throw new Error("expected deduplication to one entry"); + * + * const multi = uniqueTrackURIs([ + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, + * ]); + * if (multi.size !== 2) throw new Error("expected 2 distinct base URIs"); + * + * if (uniqueTrackURIs([]).size !== 0) throw new Error("expected empty set for empty input"); + * ``` */ export function uniqueTrackURIs(tracks) { const set = new Set(); diff --git a/src/common/utils.js b/src/common/utils.js index 17858335..ab4d2450 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -8,6 +8,22 @@ import { xxh32r } from "xxh32/dist/raw.js"; * @template T * @param {Array} array * @returns Array + * + * @example Returns same elements in (possibly different) order without mutating the original + * ```js + * import { arrayShuffle } from "~/common/utils.js"; + * + * if (arrayShuffle([]).length !== 0) throw new Error("empty array should return empty"); + * + * const sorted = arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b); + * if (sorted.join(",") !== "1,2,3,4,5") throw new Error("shuffled array should contain same elements"); + * + * const original = [1, 2, 3]; + * arrayShuffle(original); + * if (original.join(",") !== "1,2,3") throw new Error("original array should not be mutated"); + * + * if (arrayShuffle([42]).join(",") !== "42") throw new Error("single-element array should be unchanged"); + * ``` */ export function arrayShuffle(array) { if (array.length === 0) { @@ -28,6 +44,16 @@ export function arrayShuffle(array) { /** * @param {string | undefined | null} value + * + * @example Returns true only for empty string (present attribute) + * ```js + * import { boolAttr } from "~/common/utils.js"; + * + * if (!boolAttr("")) throw new Error("empty string should return true"); + * if (boolAttr(null)) throw new Error("null should return false"); + * if (boolAttr(undefined)) throw new Error("undefined should return false"); + * if (boolAttr("true")) throw new Error("non-empty string should return false"); + * ``` */ export function boolAttr(value) { return value === ""; @@ -36,6 +62,15 @@ export function boolAttr(value) { /** * @param {any} object + * + * @example Returns a consistent string hash for the same object + * ```js + * import { hash } from "~/common/utils.js"; + * + * if (typeof hash({ a: 1 }) !== "string") throw new Error("hash should return a string"); + * if (hash({ a: 1, b: 2 }) !== hash({ a: 1, b: 2 })) throw new Error("same objects should produce same hash"); + * if (hash({ a: 1 }) === hash({ a: 2 })) throw new Error("different objects should produce different hashes"); + * ``` */ export function hash(object) { return xxh32r(jsonEncode(object)).toString(); @@ -45,6 +80,30 @@ export function hash(object) { * @param {Track[]} tracks * @param {Record} initial * @returns {Record} + * + * @example Groups tracks by URI scheme + * ```js + * import { groupTracksPerScheme } from "~/common/utils.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }, + * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, + * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, + * ]; + * const groups = groupTracksPerScheme(tracks); + * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks"); + * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track"); + * ``` + * + * @example Merges into a provided initial object + * ```js + * import { groupTracksPerScheme } from "~/common/utils.js"; + * + * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] }; + * const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }]; + * const groups = groupTracksPerScheme(tracks, initial); + * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); + * ``` */ export function groupTracksPerScheme( tracks, @@ -65,6 +124,17 @@ export function groupTracksPerScheme( /** * @param {string[]} uris * @returns {Record} + * + * @example Groups URIs by scheme + * ```js + * import { groupUrisPerScheme } from "~/common/utils.js"; + * + * const groups = groupUrisPerScheme(["http://a.com/t.mp3", "s3://b/t.flac", "http://c.com/t.mp3"]); + * if (groups["http"].length !== 2) throw new Error("expected 2 http URIs"); + * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 URI"); + * + * if (Object.keys(groupUrisPerScheme([])).length !== 0) throw new Error("expected empty object for empty input"); + * ``` */ export function groupUrisPerScheme(uris) { /** @type {Record} */ @@ -81,6 +151,18 @@ export function groupUrisPerScheme(uris) { /** * @param {unknown} test + * + * @example Returns true for primitives and false for objects/arrays + * ```js + * import { isPrimitive } from "~/common/utils.js"; + * + * if (!isPrimitive(42)) throw new Error("number should be primitive"); + * if (!isPrimitive("hello")) throw new Error("string should be primitive"); + * if (!isPrimitive(true)) throw new Error("boolean should be primitive"); + * if (!isPrimitive(null)) throw new Error("null should be primitive"); + * if (isPrimitive({ a: 1 })) throw new Error("object should not be primitive"); + * if (isPrimitive([1, 2])) throw new Error("array should not be primitive"); + * ``` */ export function isPrimitive(test) { return test !== Object(test); @@ -99,6 +181,17 @@ export function jsonDecode(a) { * @template T * @param {T} a * @returns Uint8Array + * + * @example jsonEncode returns a Uint8Array that round-trips through jsonDecode + * ```js + * import { jsonEncode, jsonDecode } from "~/common/utils.js"; + * + * const original = { a: 1, b: "hello", c: [1, 2, 3] }; + * if (!(jsonEncode(original) instanceof Uint8Array)) throw new Error("jsonEncode should return a Uint8Array"); + * + * const roundTripped = jsonDecode(jsonEncode(original)); + * if (JSON.stringify(roundTripped) !== JSON.stringify(original)) throw new Error("round-trip should preserve values"); + * ``` */ export function jsonEncode(a) { return new TextEncoder().encode(JSON.stringify(a)); @@ -107,6 +200,22 @@ export function jsonEncode(a) { /** * @template {Record} T * @param {T} rec + * + * @example Removes keys with undefined values without mutating the original + * ```js + * import { removeUndefinedValuesFromRecord } from "~/common/utils.js"; + * + * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); + * if ("b" in result) throw new Error("undefined key should be removed"); + * if (result.a !== 1 || result.c !== "x") throw new Error("defined keys should be preserved"); + * + * const original = { a: 1, b: undefined }; + * removeUndefinedValuesFromRecord(original); + * if (!("b" in original)) throw new Error("original should not be mutated"); + * + * const noUndef = removeUndefinedValuesFromRecord({ a: 1, b: 2 }); + * if (noUndef.a !== 1 || noUndef.b !== 2) throw new Error("record without undefined values should be unchanged"); + * ``` */ export function removeUndefinedValuesFromRecord(rec) { const recClone = { ...rec }; @@ -123,6 +232,21 @@ export function removeUndefinedValuesFromRecord(rec) { /** * @template {Record} T * @param {T} rec + * + * @example Deep-clones nested records without sharing references + * ```js + * import { recursivelyCloneRecords } from "~/common/utils.js"; + * + * const original = { a: 1, b: "x" }; + * const clone = recursivelyCloneRecords(original); + * if (clone === original) throw new Error("clone should be a different object"); + * if (clone.a !== 1 || clone.b !== "x") throw new Error("values should be preserved"); + * + * const nested = { outer: { inner: 42 } }; + * const nestedClone = recursivelyCloneRecords(nested); + * if (nestedClone.outer === nested.outer) throw new Error("nested objects should not share references"); + * if (nestedClone.outer.inner !== 42) throw new Error("nested values should be preserved"); + * ``` */ export function recursivelyCloneRecords(rec) { const recClone = { ...rec }; @@ -140,6 +264,16 @@ export function recursivelyCloneRecords(rec) { /** * @param {string} str * @returns {string} + * + * @example Decodes percent-encoded and %u unicode escapes, leaves plain strings unchanged + * ```js + * import { safeDecodeURIComponent } from "~/common/utils.js"; + * + * if (safeDecodeURIComponent("hello%20world") !== "hello world") throw new Error("should decode %20 as space"); + * if (safeDecodeURIComponent("%u0041") !== "A") throw new Error("should decode %u unicode escape"); + * if (safeDecodeURIComponent("plain-string") !== "plain-string") throw new Error("plain string should be unchanged"); + * if (safeDecodeURIComponent("hello%2Fworld") !== "hello/world") throw new Error("should decode %2F as slash"); + * ``` */ export function safeDecodeURIComponent(str) { return str.replace( diff --git a/src/components/input/common.js b/src/components/input/common.js index ee6cb27f..c0fe486a 100644 --- a/src/components/input/common.js +++ b/src/components/input/common.js @@ -11,6 +11,20 @@ * @param {(arg: T) => string} keyFn * @param {number} ttl - Cache TTL in milliseconds * @returns {(arg: T) => Promise} + * + * @example Caches results and avoids calling fn more than once per key + * ```js + * import { cachedConsult } from "~/components/input/common.js"; + * + * let callCount = 0; + * const cached = cachedConsult(async (uri) => { callCount++; return true; }, (uri) => uri); + * + * const r1 = await cached("https://example.com/stream"); + * const r2 = await cached("https://example.com/stream"); + * + * if (r1 !== true || r2 !== true) throw new Error("should return cached value"); + * if (callCount !== 1) throw new Error("fn should only be called once per key"); + * ``` */ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { /** @type {Map} */ @@ -33,6 +47,40 @@ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { /** * @param {{ fileUriOrScheme: string; handleFileUri: (args: { fileURI: string; tracks: Track[] }) => Track[]; inputScheme: string; tracks: Track[] }} _ + * + * @example Removes all tracks when given a matching scheme, returns all when scheme doesn't match + * ```js + * import { detach } from "~/components/input/common.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, + * ]; + * + * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks }); + * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks"); + * + * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks }); + * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks"); + * ``` + * + * @example Delegates to handleFileUri when a full URI is given + * ```js + * import { detach } from "~/components/input/common.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, + * ]; + * + * const result = detach({ + * fileUriOrScheme: "https://a.com/1.mp3", + * inputScheme: "https", + * handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), + * tracks, + * }); + * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI"); + * ``` */ export function detach( { fileUriOrScheme, handleFileUri, inputScheme, tracks }, @@ -49,6 +97,13 @@ export function detach( /** * @param {string} scheme * @param {string} groupId + * + * @example Returns scheme://groupId + * ```js + * import { groupKey } from "~/components/input/common.js"; + * + * if (groupKey("https", "example.com") !== "https://example.com") throw new Error(`expected "https://example.com"`); + * ``` */ export function groupKey(scheme, groupId) { return `${scheme}://${groupId}`; @@ -56,6 +111,21 @@ export function groupKey(scheme, groupId) { /** * @param {string} filename + * + * @example Returns truthy for audio extensions and falsy for non-audio ones + * ```js + * import { isAudioFile } from "~/components/input/common.js"; + * + * const audioExts = ["track.mp3", "track.flac", "track.ogg", "track.opus", "track.wav", "track.m4a", "track.webm"]; + * for (const f of audioExts) { + * if (!isAudioFile(f)) throw new Error(`${f} should be recognised as audio`); + * } + * + * const nonAudio = ["track.txt", "track.jpg", "track.pdf", "track"]; + * for (const f of nonAudio) { + * if (isAudioFile(f)) throw new Error(`${f} should not be recognised as audio`); + * } + * ``` */ export function isAudioFile(filename) { return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/); diff --git a/tests/common/cid/test.ts b/tests/common/cid/test.ts deleted file mode 100644 index 38c01826..00000000 --- a/tests/common/cid/test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/cid", () => { - describe("create", () => { - it("returns a non-empty CID string", async () => { - const result = await testWeb(async () => { - const { create } = await import("~/common/cid.js"); - return create(0x55, new TextEncoder().encode("hello")); - }); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - it("returns consistent CID for same input", async () => { - const result = await testWeb(async () => { - const { create } = await import("~/common/cid.js"); - const data = new TextEncoder().encode("hello world"); - const a = await create(0x55, data); - const b = await create(0x55, data); - return a === b; - }); - expect(result).toBe(true); - }); - - it("returns different CIDs for different inputs", async () => { - const result = await testWeb(async () => { - const { create } = await import("~/common/cid.js"); - const a = await create(0x55, new TextEncoder().encode("hello")); - const b = await create(0x55, new TextEncoder().encode("world")); - return a === b; - }); - expect(result).toBe(false); - }); - - it("returns different CIDs for different codecs", async () => { - const result = await testWeb(async () => { - const { create } = await import("~/common/cid.js"); - const data = new TextEncoder().encode("hello"); - const a = await create(0x55, data); - const b = await create(0x71, data); - return a === b; - }); - expect(result).toBe(false); - }); - - it("returns a base32-encoded CID string", async () => { - const result = await testWeb(async () => { - const { create } = await import("~/common/cid.js"); - return create(0x55, new TextEncoder().encode("test")); - }); - // CIDv1 base32 strings contain only lowercase alphanumeric characters - expect(/^[a-z2-7]+$/.test(result)).toBe(true); - }); - }); - - describe("verify", () => { - it("returns true for matching data and CID", async () => { - const result = await testWeb(async () => { - const { create, verify } = await import("~/common/cid.js"); - const data = new TextEncoder().encode("hello"); - const cid = await create(0x55, data); - return verify(data, cid); - }); - expect(result).toBe(true); - }); - - it("returns false for mismatched data", async () => { - const result = await testWeb(async () => { - const { create, verify } = await import("~/common/cid.js"); - const data = new TextEncoder().encode("hello"); - const cid = await create(0x55, data); - return verify(new TextEncoder().encode("world"), cid); - }); - expect(result).toBe(false); - }); - }); -}); diff --git a/tests/common/compare/test.ts b/tests/common/compare/test.ts deleted file mode 100644 index bc68fc6b..00000000 --- a/tests/common/compare/test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/compare", () => { - describe("diff", () => { - it("returns true for identical primitives", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff(1, 1); - }); - expect(result).toBe(true); - }); - - it("returns false for different primitives", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff(1, 2); - }); - expect(result).toBe(false); - }); - - it("returns true for deeply equal objects", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); - }); - expect(result).toBe(true); - }); - - it("returns false for objects with different values", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff({ a: 1 }, { a: 2 }); - }); - expect(result).toBe(false); - }); - - it("returns false for objects with different keys", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff({ a: 1 }, { b: 1 }); - }); - expect(result).toBe(false); - }); - - it("returns true for identical arrays", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff([1, 2, 3], [1, 2, 3]); - }); - expect(result).toBe(true); - }); - - it("returns false for arrays with different elements", async () => { - const result = await testWeb(async () => { - const { diff } = await import("~/common/compare.js"); - return diff([1, 2], [1, 3]); - }); - expect(result).toBe(false); - }); - }); - - describe("strictEquality", () => { - it("returns true for same primitive value", async () => { - const result = await testWeb(async () => { - const { strictEquality } = await import("~/common/compare.js"); - return strictEquality(42, 42); - }); - expect(result).toBe(true); - }); - - it("returns false for different primitive values", async () => { - const result = await testWeb(async () => { - const { strictEquality } = await import("~/common/compare.js"); - return strictEquality(42, 43); - }); - expect(result).toBe(false); - }); - - it("returns false for same-content objects (reference inequality)", async () => { - const result = await testWeb(async () => { - const { strictEquality } = await import("~/common/compare.js"); - return strictEquality({ a: 1 }, { a: 1 }); - }); - expect(result).toBe(false); - }); - - it("returns true for same object reference", async () => { - const result = await testWeb(async () => { - const { strictEquality } = await import("~/common/compare.js"); - const obj = { a: 1 }; - return strictEquality(obj, obj); - }); - expect(result).toBe(true); - }); - - it("returns false for null vs undefined", async () => { - const result = await testWeb(async () => { - const { strictEquality } = await import("~/common/compare.js"); - return strictEquality(null, undefined); - }); - expect(result).toBe(false); - }); - }); -}); diff --git a/tests/common/facets/test.ts b/tests/common/facets/test.ts deleted file mode 100644 index 98e656b7..00000000 --- a/tests/common/facets/test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/facets/category", () => { - it("color returns accent-twist-4 for prelude kind", async () => { - const result = await testWeb(async () => { - const { color } = await import("~/common/facets/category.js"); - return color({ - $type: "sh.diffuse.output.facet", - id: "1", - name: "x", - kind: "prelude", - }); - }); - expect(result).toBe("var(--accent-twist-4)"); - }); - - it("color returns accent-twist-2 for interactive kind", async () => { - const result = await testWeb(async () => { - const { color } = await import("~/common/facets/category.js"); - return color({ - $type: "sh.diffuse.output.facet", - id: "1", - name: "x", - kind: "interactive", - }); - }); - expect(result).toBe("var(--accent-twist-2)"); - }); - - it("color returns accent-twist-2 for undefined kind", async () => { - const result = await testWeb(async () => { - const { color } = await import("~/common/facets/category.js"); - return color({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); - }); - expect(result).toBe("var(--accent-twist-2)"); - }); - - it("name returns 'feature' for prelude kind", async () => { - const result = await testWeb(async () => { - const { name } = await import("~/common/facets/category.js"); - return name({ - $type: "sh.diffuse.output.facet", - id: "1", - name: "x", - kind: "prelude", - }); - }); - expect(result).toBe("feature"); - }); - - it("name returns 'interface' for interactive kind", async () => { - const result = await testWeb(async () => { - const { name } = await import("~/common/facets/category.js"); - return name({ - $type: "sh.diffuse.output.facet", - id: "1", - name: "x", - kind: "interactive", - }); - }); - expect(result).toBe("interface"); - }); - - it("name returns 'interface' for undefined kind", async () => { - const result = await testWeb(async () => { - const { name } = await import("~/common/facets/category.js"); - return name({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); - }); - expect(result).toBe("interface"); - }); -}); - -describe("common/facets/constants", () => { - it("TYPE is sh.diffuse.output.facet", async () => { - const result = await testWeb(async () => { - const { TYPE } = await import("~/common/facets/constants.js"); - return TYPE; - }); - expect(result).toBe("sh.diffuse.output.facet"); - }); -}); - -describe("common/facets/utils", () => { - it("facetFromURI sets $type", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: undefined, - description: undefined, - }, - { fetchHTML: false }, - ); - return facet.$type; - }); - expect(result).toBe("sh.diffuse.output.facet"); - }); - - it("facetFromURI sets name and uri", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "My Facet", - uri: "facets/test/index.html", - kind: undefined, - description: undefined, - }, - { fetchHTML: false }, - ); - return { name: facet.name, uri: facet.uri }; - }); - expect(result.name).toBe("My Facet"); - expect(result.uri).toBe("facets/test/index.html"); - }); - - it("facetFromURI with fetchHTML false leaves html and cid undefined", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: undefined, - description: undefined, - }, - { fetchHTML: false }, - ); - return { html: facet.html ?? null, cid: facet.cid ?? null }; - }); - expect(result.html).toBe(null); - expect(result.cid).toBe(null); - }); - - it("facetFromURI sets prelude kind", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: "prelude", - description: undefined, - }, - { fetchHTML: false }, - ); - return facet.kind; - }); - expect(result).toBe("prelude"); - }); - - it("facetFromURI sets interactive kind", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: "interactive", - description: undefined, - }, - { fetchHTML: false }, - ); - return facet.kind; - }); - expect(result).toBe("interactive"); - }); - - it("facetFromURI sets kind to undefined for unrecognised kind", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: "unknown", - description: undefined, - }, - { fetchHTML: false }, - ); - return facet.kind ?? null; - }); - expect(result).toBe(null); - }); - - it("facetFromURI sets description", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: undefined, - description: "A test facet", - }, - { fetchHTML: false }, - ); - return facet.description; - }); - expect(result).toBe("A test facet"); - }); - - it("facetFromURI sets createdAt and updatedAt as matching ISO strings", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: undefined, - description: undefined, - }, - { fetchHTML: false }, - ); - return { createdAt: facet.createdAt, updatedAt: facet.updatedAt }; - }); - expect(result.createdAt).toBeTruthy(); - expect(result.createdAt).toBe(result.updatedAt); - expect(new Date(result.createdAt!).toISOString()).toBe(result.createdAt); - }); - - it("facetFromURI generates a non-empty string id", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const facet = await facetFromURI( - { - name: "Test", - uri: "test.html", - kind: undefined, - description: undefined, - }, - { fetchHTML: false }, - ); - return facet.id; - }); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - it("facetFromURI generates unique ids", async () => { - const result = await testWeb(async () => { - const { facetFromURI } = await import("~/common/facets/utils.js"); - const a = await facetFromURI( - { name: "A", uri: "a.html", kind: undefined, description: undefined }, - { fetchHTML: false }, - ); - const b = await facetFromURI( - { name: "B", uri: "b.html", kind: undefined, description: undefined }, - { fetchHTML: false }, - ); - return a.id === b.id; - }); - expect(result).toBe(false); - }); -}); - -describe("common/facets/prelude", () => { - it("insertPreludes only inserts prelude kind facets", async () => { - const result = await testWeb(async () => { - const { insertPreludes } = await import("~/common/facets/prelude.js"); - const container = document.createElement("div"); - document.body.append(container); - - await insertPreludes( - [ - { - $type: "sh.diffuse.output.facet", - id: "1", - name: "A", - kind: "interactive", - html: "", - }, - { - $type: "sh.diffuse.output.facet", - id: "2", - name: "B", - kind: "prelude", - html: "", - }, - ], - container, - ); - - return { - hasInteractive: !!container.querySelector("#fp-interactive"), - hasPrelude: !!container.querySelector("#fp-prelude"), - }; - }); - - expect(result.hasInteractive).toBe(false); - expect(result.hasPrelude).toBe(true); - }); - - it("insertPreludes sorts preludes alphabetically by name", async () => { - const result = await testWeb(async () => { - const { insertPreludes } = await import("~/common/facets/prelude.js"); - const container = document.createElement("div"); - document.body.append(container); - - await insertPreludes( - [ - { - $type: "sh.diffuse.output.facet", - id: "1", - name: "Zebra", - kind: "prelude", - html: "Zebra", - }, - { - $type: "sh.diffuse.output.facet", - id: "2", - name: "Alpha", - kind: "prelude", - html: "Alpha", - }, - { - $type: "sh.diffuse.output.facet", - id: "3", - name: "Mango", - kind: "prelude", - html: "Mango", - }, - ], - container, - ); - - return Array.from(container.querySelectorAll(".fp-order")).map((el) => - el.textContent - ); - }); - - expect(result).toEqual(["Alpha", "Mango", "Zebra"]); - }); - - it("insertPreludes defaults to document.body", async () => { - const result = await testWeb(async () => { - const { insertPreludes } = await import("~/common/facets/prelude.js"); - - await insertPreludes([ - { - $type: "sh.diffuse.output.facet", - id: "1", - name: "Test", - kind: "prelude", - html: "", - }, - ]); - - return !!document.body.querySelector("#fp-body-test"); - }); - - expect(result).toBe(true); - }); - - it("insertPreludes skips prelude with no html and no uri", async () => { - const result = await testWeb(async () => { - const { insertPreludes } = await import("~/common/facets/prelude.js"); - const container = document.createElement("div"); - document.body.append(container); - - await insertPreludes( - [{ - $type: "sh.diffuse.output.facet", - id: "1", - name: "Empty", - kind: "prelude", - }], - container, - ); - - return container.childNodes.length; - }); - - expect(result).toBe(0); - }); -}); diff --git a/tests/common/loader/test.ts b/tests/common/loader/test.ts deleted file mode 100644 index 8725d431..00000000 --- a/tests/common/loader/test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/loader", () => { - describe("renderError", () => { - it("sets innerHTML on the container", async () => { - const result = await testWeb(async () => { - const { renderError } = await import("~/common/loader.js"); - const container = document.createElement("div"); - renderError(container, "Something went wrong"); - return container.innerHTML.length > 0; - }); - expect(result).toBe(true); - }); - - it("includes the error message in the output", async () => { - const result = await testWeb(async () => { - const { renderError } = await import("~/common/loader.js"); - const container = document.createElement("div"); - renderError(container, "Track not found"); - return container.innerHTML; - }); - expect(result).toContain("Track not found"); - }); - - it("does not throw when options.throw is false", async () => { - const result = await testWeb(async () => { - const { renderError } = await import("~/common/loader.js"); - const container = document.createElement("div"); - try { - renderError(container, "oops", { throw: false }); - return "no-throw"; - } catch { - return "threw"; - } - }); - expect(result).toBe("no-throw"); - }); - - it("throws when options.throw is true", async () => { - const result = await testWeb(async () => { - const { renderError } = await import("~/common/loader.js"); - const container = document.createElement("div"); - try { - renderError(container, "fatal error", { throw: true }); - return "no-throw"; - } catch (e) { - return (e as Error).message; - } - }); - expect(result).toBe("fatal error"); - }); - - it("throws the provided context error", async () => { - const result = await testWeb(async () => { - const { renderError } = await import("~/common/loader.js"); - const container = document.createElement("div"); - const ctx = new Error("context error"); - try { - renderError(container, "label", { context: ctx, throw: true }); - return null; - } catch (e) { - return (e as Error).message; - } - }); - expect(result).toBe("context error"); - }); - }); - - describe("ensureHTML", () => { - it("returns item unchanged when html is already set", async () => { - const result = await testWeb(async () => { - const { ensureHTML } = await import("~/common/loader.js"); - const item = { html: "

existing

", uri: "https://example.com/facet.html" }; - const returned = await ensureHTML(item); - return { sameRef: returned === item, html: returned.html }; - }); - expect(result.sameRef).toBe(true); - expect(result.html).toBe("

existing

"); - }); - - it("returns item unchanged when uri is absent", async () => { - const result = await testWeb(async () => { - const { ensureHTML } = await import("~/common/loader.js"); - const item = { html: undefined, uri: undefined }; - const returned = await ensureHTML(item); - return returned.html ?? null; - }); - expect(result).toBe(null); - }); - }); -}); diff --git a/tests/common/output/test.ts b/tests/common/output/test.ts deleted file mode 100644 index d01833c8..00000000 --- a/tests/common/output/test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/output", () => { - describe("data", () => { - it("resolves immediately when collection is already loaded", async () => { - const result = await testWeb(async () => { - const { data } = await import("~/common/output.js"); - const { signal } = await import("~/common/signal.js"); - - const col = signal<{ state: "loading" } | { state: "loaded"; data: string[] }>( - { state: "loaded", data: ["a", "b"] }, - ); - - return data({ collection: col.get }); - }); - expect(result).toEqual(["a", "b"]); - }); - - it("waits for collection to become loaded", async () => { - const result = await testWeb(async () => { - const { data } = await import("~/common/output.js"); - const { signal } = await import("~/common/signal.js"); - - const col = signal<{ state: "loading" } | { state: "loaded"; data: number[] }>( - { state: "loading" }, - ); - - const promise = data({ collection: col.get }); - - // Transition to loaded after a microtask - await Promise.resolve(); - col.set({ state: "loaded", data: [1, 2, 3] }); - - return promise; - }); - expect(result).toEqual([1, 2, 3]); - }); - }); -}); diff --git a/tests/common/playlist/test.ts b/tests/common/playlist/test.ts deleted file mode 100644 index 4ad97f5c..00000000 --- a/tests/common/playlist/test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -// Minimal test fixtures -const trackA = { - $type: "sh.diffuse.output.track", - id: "track-a", - uri: "http://example.com/a.mp3", - tags: { artist: "Artist A", title: "Song A" }, -}; - -const trackB = { - $type: "sh.diffuse.output.track", - id: "track-b", - uri: "http://example.com/b.mp3", - tags: { artist: "Artist B", title: "Song B" }, -}; - -describe("common/playlist", () => { - describe("match", () => { - it("matches a track that satisfies all criteria", async () => { - const result = await testWeb(async () => { - const { match } = await import("~/common/playlist.js"); - const track = { - $type: "sh.diffuse.output.track", - id: "t", - uri: "http://x.com/t.mp3", - tags: { artist: "Artist A", title: "Song A" }, - }; - const item = { - $type: "sh.diffuse.output.playlistItem", - id: "i", - playlist: "Favourites", - criteria: [ - { field: "tags.artist", value: "Artist A" }, - { field: "tags.title", value: "Song A" }, - ], - }; - return match(track as never, item as never); - }); - expect(result).toBe(true); - }); - - it("does not match when one criterion fails", async () => { - const result = await testWeb(async () => { - const { match } = await import("~/common/playlist.js"); - const track = { - $type: "sh.diffuse.output.track", - id: "t", - uri: "http://x.com/t.mp3", - tags: { artist: "Artist A", title: "Wrong Title" }, - }; - const item = { - $type: "sh.diffuse.output.playlistItem", - id: "i", - playlist: "Favourites", - criteria: [ - { field: "tags.artist", value: "Artist A" }, - { field: "tags.title", value: "Song A" }, - ], - }; - return match(track as never, item as never); - }); - expect(result).toBe(false); - }); - - it("applies transformations before comparing", async () => { - const result = await testWeb(async () => { - const { match } = await import("~/common/playlist.js"); - const track = { - $type: "sh.diffuse.output.track", - id: "t", - uri: "http://x.com/t.mp3", - tags: { artist: "Artist A" }, - }; - const item = { - $type: "sh.diffuse.output.playlistItem", - id: "i", - playlist: "Favourites", - criteria: [ - { field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }, - ], - }; - return match(track as never, item as never); - }); - expect(result).toBe(true); - }); - - it("does not match when transformations result in inequality", async () => { - const result = await testWeb(async () => { - const { match } = await import("~/common/playlist.js"); - const track = { - $type: "sh.diffuse.output.track", - id: "t", - uri: "http://x.com/t.mp3", - tags: { artist: "Artist A" }, - }; - const item = { - $type: "sh.diffuse.output.playlistItem", - id: "i", - playlist: "Favourites", - criteria: [ - { field: "tags.artist", value: "Artist B", transformations: ["toLowerCase"] }, - ], - }; - return match(track as never, item as never); - }); - expect(result).toBe(false); - }); - }); - - describe("gather", () => { - it("groups items by playlist name", async () => { - const result = await testWeb(async () => { - const { gather } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, - { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, - ] as never[]; - const map = gather(items); - return { rockCount: map.get("Rock")!.items.length, popCount: map.get("Pop")!.items.length }; - }); - expect(result.rockCount).toBe(2); - expect(result.popCount).toBe(1); - }); - - it("marks playlist as unordered when all items lack positionedAfter", async () => { - const result = await testWeb(async () => { - const { gather } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, - ] as never[]; - const map = gather(items); - return map.get("Mix")!.unordered; - }); - expect(result).toBe(true); - }); - - it("marks playlist as ordered when any item has positionedAfter", async () => { - const result = await testWeb(async () => { - const { gather } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, - ] as never[]; - const map = gather(items); - return map.get("Mix")!.unordered; - }); - expect(result).toBe(false); - }); - - it("preserves playlist name", async () => { - const result = await testWeb(async () => { - const { gather } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "My Playlist", criteria: [] }, - ] as never[]; - const map = gather(items); - return map.get("My Playlist")!.name; - }); - expect(result).toBe("My Playlist"); - }); - }); - - describe("sort", () => { - it("returns single-item array unchanged", async () => { - const result = await testWeb(async () => { - const { sort } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, - ] as never[]; - return sort(items).map((i: { id: string }) => i.id); - }); - expect(result).toEqual(["a"]); - }); - - it("sorts a simple linked list in order", async () => { - const result = await testWeb(async () => { - const { sort } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, - ] as never[]; - return sort(items).map((i: { id: string }) => i.id); - }); - expect(result).toEqual(["a", "b", "c"]); - }); - - it("places head items (positionedAfter null) first", async () => { - const result = await testWeb(async () => { - const { sort } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, - ] as never[]; - return sort(items).map((i: { id: string }) => i.id); - }); - expect(result[0]).toBe("a"); - }); - - it("appends unreachable items at the end", async () => { - const result = await testWeb(async () => { - const { sort } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, - { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, - ] as never[]; - const sorted = sort(items).map((i: { id: string }) => i.id); - return sorted[sorted.length - 1]; - }); - expect(result).toBe("orphan"); - }); - - it("sorts multiple heads by updatedAt ascending", async () => { - const result = await testWeb(async () => { - const { sort } = await import("~/common/playlist.js"); - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" }, - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" }, - ] as never[]; - return sort(items).map((i: { id: string }) => i.id); - }); - expect(result[0]).toBe("a"); - expect(result[1]).toBe("b"); - }); - }); - - describe("filterByPlaylist", () => { - it("returns tracks matching any playlist item criteria", async () => { - const result = await testWeb(async () => { - const { filterByPlaylist } = await import("~/common/playlist.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, - { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, - ] as never[]; - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, - ] as never[]; - return filterByPlaylist(tracks, items).map((t: { id: string }) => t.id); - }); - expect(result).toEqual(["a"]); - }); - - it("returns empty array when no tracks match", async () => { - const result = await testWeb(async () => { - const { filterByPlaylist } = await import("~/common/playlist.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, - ] as never[]; - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, - ] as never[]; - return filterByPlaylist(tracks, items).length; - }); - expect(result).toBe(0); - }); - - it("applies transformations when matching", async () => { - const result = await testWeb(async () => { - const { filterByPlaylist } = await import("~/common/playlist.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }, - ] as never[]; - const items = [ - { - $type: "sh.diffuse.output.playlistItem", - id: "i1", - playlist: "p", - criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], - }, - ] as never[]; - return filterByPlaylist(tracks, items).length; - }); - expect(result).toBe(1); - }); - - it("returns all tracks when multiple criteria match different tracks", async () => { - const result = await testWeb(async () => { - const { filterByPlaylist } = await import("~/common/playlist.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, - { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B" } }, - ] as never[]; - const items = [ - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, - { $type: "sh.diffuse.output.playlistItem", id: "i2", playlist: "p", criteria: [{ field: "tags.artist", value: "B" }] }, - ] as never[]; - return filterByPlaylist(tracks, items).length; - }); - expect(result).toBe(2); - }); - - it("returns empty array for empty playlist items", async () => { - const result = await testWeb(async () => { - const { filterByPlaylist } = await import("~/common/playlist.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, - ] as never[]; - return filterByPlaylist(tracks, []).length; - }); - expect(result).toBe(0); - }); - }); -}); diff --git a/tests/common/signal/test.ts b/tests/common/signal/test.ts deleted file mode 100644 index 05fb4376..00000000 --- a/tests/common/signal/test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/signal", () => { - describe("signal", () => { - it("get returns the initial value", async () => { - const result = await testWeb(async () => { - const { signal } = await import("~/common/signal.js"); - const s = signal(42); - return s.get(); - }); - expect(result).toBe(42); - }); - - it("value getter returns the initial value", async () => { - const result = await testWeb(async () => { - const { signal } = await import("~/common/signal.js"); - const s = signal("hello"); - return s.value; - }); - expect(result).toBe("hello"); - }); - - it("set updates the value", async () => { - const result = await testWeb(async () => { - const { signal } = await import("~/common/signal.js"); - const s = signal(1); - s.set(99); - return s.get(); - }); - expect(result).toBe(99); - }); - - it("value setter updates the value", async () => { - const result = await testWeb(async () => { - const { signal } = await import("~/common/signal.js"); - const s = signal("a"); - s.value = "b"; - return s.value; - }); - expect(result).toBe("b"); - }); - - it("with compare option skips update when values are equal", async () => { - const result = await testWeb(async () => { - const { signal, effect } = await import("~/common/signal.js"); - let runCount = 0; - const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); - - effect(() => { - s.get(); - runCount++; - }); - - const before = runCount; - s.set({ x: 1 }); // same by compare - return { before, after: runCount }; - }); - expect(result.before).toBe(1); - expect(result.after).toBe(1); // effect not re-run - }); - - it("with compare option triggers update when values differ", async () => { - const result = await testWeb(async () => { - const { signal, effect } = await import("~/common/signal.js"); - let runCount = 0; - const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); - - effect(() => { - s.get(); - runCount++; - }); - - const before = runCount; - s.set({ x: 2 }); // different by compare - return { before, after: runCount }; - }); - expect(result.before).toBe(1); - expect(result.after).toBe(2); - }); - }); - - describe("batch", () => { - it("defers effect execution until batch completes", async () => { - const result = await testWeb(async () => { - const { signal, effect, batch } = await import("~/common/signal.js"); - const a = signal(0); - const b = signal(0); - const values: number[] = []; - - effect(() => { - values.push(a.get() + b.get()); - }); - - const before = [...values]; // [0] - batch(() => { - a.set(1); - b.set(2); - }); - - return { before, after: values }; - }); - expect(result.before).toEqual([0]); - expect(result.after).toEqual([0, 3]); // only one update, not two - }); - }); - - describe("untracked", () => { - it("reads signal value without tracking it as a dependency", async () => { - const result = await testWeb(async () => { - const { signal, effect, untracked } = await import( - "~/common/signal.js" - ); - const a = signal(1); - const b = signal(10); - let runCount = 0; - - effect(() => { - a.get(); // tracked - untracked(() => b.get()); // not tracked - runCount++; - }); - - const before = runCount; // 1 - b.set(20); // should NOT re-run effect - const afterB = runCount; - a.set(2); // SHOULD re-run effect - return { before, afterB, afterA: runCount }; - }); - expect(result.before).toBe(1); - expect(result.afterB).toBe(1); - expect(result.afterA).toBe(2); - }); - - it("returns the value from the callback", async () => { - const result = await testWeb(async () => { - const { signal, untracked } = await import("~/common/signal.js"); - const s = signal(42); - return untracked(() => s.get()); - }); - expect(result).toBe(42); - }); - }); - - describe("untrackedAsync", () => { - it("returns the resolved value", async () => { - const result = await testWeb(async () => { - const { untrackedAsync } = await import("~/common/signal.js"); - return untrackedAsync(async () => 99); - }); - expect(result).toBe(99); - }); - }); -}); diff --git a/tests/common/temporal/test.ts b/tests/common/temporal/test.ts deleted file mode 100644 index fa5ebfdf..00000000 --- a/tests/common/temporal/test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/temporal", () => { - describe("compareTimestamps", () => { - it("returns 0 for equal timestamps", async () => { - const result = await testWeb(async () => { - const { compareTimestamps } = await import("~/common/temporal.js"); - return compareTimestamps( - "2024-01-01T00:00:00.000Z", - "2024-01-01T00:00:00.000Z", - ); - }); - expect(result).toBe(0); - }); - - it("returns negative when first is earlier", async () => { - const result = await testWeb(async () => { - const { compareTimestamps } = await import("~/common/temporal.js"); - return compareTimestamps( - "2024-01-01T00:00:00.000Z", - "2024-06-01T00:00:00.000Z", - ); - }); - expect(result).toBeLessThan(0); - }); - - it("returns positive when first is later", async () => { - const result = await testWeb(async () => { - const { compareTimestamps } = await import("~/common/temporal.js"); - return compareTimestamps( - "2024-06-01T00:00:00.000Z", - "2024-01-01T00:00:00.000Z", - ); - }); - expect(result).toBeGreaterThan(0); - }); - - it("handles millisecond precision", async () => { - const result = await testWeb(async () => { - const { compareTimestamps } = await import("~/common/temporal.js"); - return compareTimestamps( - "2024-01-01T00:00:00.001Z", - "2024-01-01T00:00:00.000Z", - ); - }); - expect(result).toBeGreaterThan(0); - }); - }); -}); diff --git a/tests/common/track/test.ts b/tests/common/track/test.ts deleted file mode 100644 index 34913af8..00000000 --- a/tests/common/track/test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/track", () => { - describe("trackURIBase", () => { - it("strips path from URI", async () => { - const result = await testWeb(async () => { - const { trackURIBase } = await import("~/common/track.js"); - return trackURIBase("https://example.com/music/track.mp3"); - }); - expect(result).toBe("https://example.com/"); - }); - - it("strips query string from URI", async () => { - const result = await testWeb(async () => { - const { trackURIBase } = await import("~/common/track.js"); - return trackURIBase("https://example.com/track.mp3?token=abc"); - }); - expect(result).toBe("https://example.com/"); - }); - - it("handles URIs with no path", async () => { - const result = await testWeb(async () => { - const { trackURIBase } = await import("~/common/track.js"); - return trackURIBase("https://example.com"); - }); - expect(result).toBe("https://example.com/"); - }); - - it("preserves scheme and host", async () => { - const result = await testWeb(async () => { - const { trackURIBase } = await import("~/common/track.js"); - return trackURIBase("s3://my-bucket/path/to/track.flac"); - }); - expect(result).toBe("s3://my-bucket"); - }); - }); - - describe("uniqueTrackURIs", () => { - it("returns a set of base URIs", async () => { - const result = await testWeb(async () => { - const { uniqueTrackURIs } = await import("~/common/track.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, - ] as never[]; - const set = uniqueTrackURIs(tracks); - return [...set]; - }); - expect(result).toEqual(["https://example.com/"]); - }); - - it("deduplicates tracks from the same source", async () => { - const result = await testWeb(async () => { - const { uniqueTrackURIs } = await import("~/common/track.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "https://a.com/2.mp3" }, - { $type: "sh.diffuse.output.track", id: "3", uri: "https://b.com/3.mp3" }, - ] as never[]; - return uniqueTrackURIs(tracks).size; - }); - expect(result).toBe(2); - }); - - it("returns empty set for empty input", async () => { - const result = await testWeb(async () => { - const { uniqueTrackURIs } = await import("~/common/track.js"); - return uniqueTrackURIs([]).size; - }); - expect(result).toBe(0); - }); - }); -}); diff --git a/tests/common/utils/test.ts b/tests/common/utils/test.ts deleted file mode 100644 index 26be91c5..00000000 --- a/tests/common/utils/test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("common/utils", () => { - describe("arrayShuffle", () => { - it("returns empty array for empty input", async () => { - const result = await testWeb(async () => { - const { arrayShuffle } = await import("~/common/utils.js"); - return arrayShuffle([]); - }); - expect(result).toEqual([]); - }); - - it("returns array with same elements", async () => { - const result = await testWeb(async () => { - const { arrayShuffle } = await import("~/common/utils.js"); - return arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b); - }); - expect(result).toEqual([1, 2, 3, 4, 5]); - }); - - it("does not mutate the original array", async () => { - const result = await testWeb(async () => { - const { arrayShuffle } = await import("~/common/utils.js"); - const original = [1, 2, 3]; - arrayShuffle(original); - return original; - }); - expect(result).toEqual([1, 2, 3]); - }); - - it("returns a single-element array unchanged", async () => { - const result = await testWeb(async () => { - const { arrayShuffle } = await import("~/common/utils.js"); - return arrayShuffle([42]); - }); - expect(result).toEqual([42]); - }); - }); - - describe("boolAttr", () => { - it("returns true for empty string (present attribute)", async () => { - const result = await testWeb(async () => { - const { boolAttr } = await import("~/common/utils.js"); - return boolAttr(""); - }); - expect(result).toBe(true); - }); - - it("returns false for null", async () => { - const result = await testWeb(async () => { - const { boolAttr } = await import("~/common/utils.js"); - return boolAttr(null); - }); - expect(result).toBe(false); - }); - - it("returns false for undefined", async () => { - const result = await testWeb(async () => { - const { boolAttr } = await import("~/common/utils.js"); - return boolAttr(undefined); - }); - expect(result).toBe(false); - }); - - it("returns false for non-empty string", async () => { - const result = await testWeb(async () => { - const { boolAttr } = await import("~/common/utils.js"); - return boolAttr("true"); - }); - expect(result).toBe(false); - }); - }); - - describe("groupTracksPerScheme", () => { - it("groups tracks by URI scheme", async () => { - const result = await testWeb(async () => { - const { groupTracksPerScheme } = await import("~/common/utils.js"); - const tracks = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, - { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, - ] as never[]; - const groups = groupTracksPerScheme(tracks); - return { httpCount: groups["http"].length, s3Count: groups["s3"].length }; - }); - expect(result.httpCount).toBe(2); - expect(result.s3Count).toBe(1); - }); - - it("merges into provided initial object", async () => { - const result = await testWeb(async () => { - const { groupTracksPerScheme } = await import("~/common/utils.js"); - const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] } as never; - const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }] as never[]; - const groups = groupTracksPerScheme(tracks, initial); - return groups["http"].length; - }); - expect(result).toBe(2); - }); - }); - - describe("groupUrisPerScheme", () => { - it("groups URIs by scheme", async () => { - const result = await testWeb(async () => { - const { groupUrisPerScheme } = await import("~/common/utils.js"); - const groups = groupUrisPerScheme([ - "http://a.com/track.mp3", - "s3://bucket/track.flac", - "http://b.com/track.mp3", - ]); - return { httpCount: groups["http"].length, s3Count: groups["s3"].length }; - }); - expect(result.httpCount).toBe(2); - expect(result.s3Count).toBe(1); - }); - - it("returns empty object for empty input", async () => { - const result = await testWeb(async () => { - const { groupUrisPerScheme } = await import("~/common/utils.js"); - return Object.keys(groupUrisPerScheme([])).length; - }); - expect(result).toBe(0); - }); - }); - - describe("isPrimitive", () => { - it("returns true for number", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive(42); - }); - expect(result).toBe(true); - }); - - it("returns true for string", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive("hello"); - }); - expect(result).toBe(true); - }); - - it("returns true for boolean", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive(true); - }); - expect(result).toBe(true); - }); - - it("returns true for null", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive(null); - }); - expect(result).toBe(true); - }); - - it("returns false for object", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive({ a: 1 }); - }); - expect(result).toBe(false); - }); - - it("returns false for array", async () => { - const result = await testWeb(async () => { - const { isPrimitive } = await import("~/common/utils.js"); - return isPrimitive([1, 2]); - }); - expect(result).toBe(false); - }); - }); - - describe("jsonEncode / jsonDecode", () => { - it("round-trips a plain object", async () => { - const result = await testWeb(async () => { - const { jsonEncode, jsonDecode } = await import("~/common/utils.js"); - const original = { a: 1, b: "hello", c: [1, 2, 3] }; - return jsonDecode(jsonEncode(original)); - }); - expect(result).toEqual({ a: 1, b: "hello", c: [1, 2, 3] }); - }); - - it("jsonEncode returns a Uint8Array", async () => { - const result = await testWeb(async () => { - const { jsonEncode } = await import("~/common/utils.js"); - return jsonEncode({ x: 1 }) instanceof Uint8Array; - }); - expect(result).toBe(true); - }); - }); - - describe("hash", () => { - it("returns a string", async () => { - const result = await testWeb(async () => { - const { hash } = await import("~/common/utils.js"); - return typeof hash({ a: 1 }); - }); - expect(result).toBe("string"); - }); - - it("returns the same hash for equal objects", async () => { - const result = await testWeb(async () => { - const { hash } = await import("~/common/utils.js"); - return hash({ a: 1, b: 2 }) === hash({ a: 1, b: 2 }); - }); - expect(result).toBe(true); - }); - - it("returns different hashes for different objects", async () => { - const result = await testWeb(async () => { - const { hash } = await import("~/common/utils.js"); - return hash({ a: 1 }) === hash({ a: 2 }); - }); - expect(result).toBe(false); - }); - }); - - describe("removeUndefinedValuesFromRecord", () => { - it("removes keys with undefined values", async () => { - const result = await testWeb(async () => { - const { removeUndefinedValuesFromRecord } = await import( - "~/common/utils.js" - ); - return removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); - }); - expect(result).toEqual({ a: 1, c: "x" }); - }); - - it("does not mutate the original record", async () => { - const result = await testWeb(async () => { - const { removeUndefinedValuesFromRecord } = await import( - "~/common/utils.js" - ); - const original = { a: 1, b: undefined }; - removeUndefinedValuesFromRecord(original); - return "b" in original; - }); - expect(result).toBe(true); - }); - - it("returns record unchanged when no undefined values", async () => { - const result = await testWeb(async () => { - const { removeUndefinedValuesFromRecord } = await import( - "~/common/utils.js" - ); - return removeUndefinedValuesFromRecord({ a: 1, b: 2 }); - }); - expect(result).toEqual({ a: 1, b: 2 }); - }); - }); - - describe("recursivelyCloneRecords", () => { - it("shallow-clones a flat record", async () => { - const result = await testWeb(async () => { - const { recursivelyCloneRecords } = await import("~/common/utils.js"); - const original = { a: 1, b: "x" }; - const clone = recursivelyCloneRecords(original); - return clone !== original && clone.a === 1 && clone.b === "x"; - }); - expect(result).toBe(true); - }); - - it("deep-clones nested objects", async () => { - const result = await testWeb(async () => { - const { recursivelyCloneRecords } = await import("~/common/utils.js"); - const original = { outer: { inner: 42 } }; - const clone = recursivelyCloneRecords(original); - return clone.outer !== original.outer && clone.outer.inner === 42; - }); - expect(result).toBe(true); - }); - }); - - describe("safeDecodeURIComponent", () => { - it("decodes standard percent-encoded characters", async () => { - const result = await testWeb(async () => { - const { safeDecodeURIComponent } = await import("~/common/utils.js"); - return safeDecodeURIComponent("hello%20world"); - }); - expect(result).toBe("hello world"); - }); - - it("decodes %u unicode escapes", async () => { - const result = await testWeb(async () => { - const { safeDecodeURIComponent } = await import("~/common/utils.js"); - return safeDecodeURIComponent("%u0041"); // 'A' - }); - expect(result).toBe("A"); - }); - - it("leaves plain strings unchanged", async () => { - const result = await testWeb(async () => { - const { safeDecodeURIComponent } = await import("~/common/utils.js"); - return safeDecodeURIComponent("plain-string"); - }); - expect(result).toBe("plain-string"); - }); - - it("decodes mixed encoded and plain text", async () => { - const result = await testWeb(async () => { - const { safeDecodeURIComponent } = await import("~/common/utils.js"); - return safeDecodeURIComponent("hello%2Fworld"); - }); - expect(result).toBe("hello/world"); - }); - }); -}); diff --git a/tests/components/input/common/test.ts b/tests/components/input/common/test.ts deleted file mode 100644 index 5ead4700..00000000 --- a/tests/components/input/common/test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; -import type { Track } from "~/definitions/types.d.ts"; - -describe("components/input/common", () => { - it("isAudioFile returns truthy for audio extensions", async () => { - const results = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - return [ - !!mod.isAudioFile("track.mp3"), - !!mod.isAudioFile("track.flac"), - !!mod.isAudioFile("track.ogg"), - !!mod.isAudioFile("track.opus"), - !!mod.isAudioFile("track.wav"), - !!mod.isAudioFile("track.m4a"), - !!mod.isAudioFile("track.webm"), - ]; - }); - - expect(results).toEqual([true, true, true, true, true, true, true]); - }); - - it("isAudioFile returns falsy for non-audio extensions", async () => { - const results = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - return [ - !!mod.isAudioFile("track.txt"), - !!mod.isAudioFile("track.jpg"), - !!mod.isAudioFile("track.pdf"), - !!mod.isAudioFile("track"), - ]; - }); - - expect(results).toEqual([false, false, false, false]); - }); - - it("groupKey returns scheme://groupId", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - return mod.groupKey("https", "example.com"); - }); - - expect(result).toBe("https://example.com"); - }); - - it("detach with matching scheme removes all tracks", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - - const tracks: Track[] = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - ]; - - return mod.detach({ - fileUriOrScheme: "https", - inputScheme: "https", - handleFileUri: () => [], - tracks, - }); - }); - - expect(result).toEqual([]); - }); - - it("detach with non-matching scheme returns all tracks unchanged", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - - const tracks: Track[] = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - ]; - - return mod.detach({ - fileUriOrScheme: "ftp", - inputScheme: "https", - handleFileUri: () => [], - tracks, - }); - }); - - expect(result.map((t: Track) => t.id)).toEqual(["1", "2"]); - }); - - it("detach delegates to handleFileUri when a full URI is given", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - - const tracks: Track[] = [ - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - ]; - - return mod.detach({ - fileUriOrScheme: "https://a.com/1.mp3", - inputScheme: "https", - handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), - tracks, - }); - }); - - expect(result.map((t: Track) => t.id)).toEqual(["2"]); - }); - - it("cachedConsult caches and returns result", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/input/common.js"); - - let callCount = 0; - const cached = mod.cachedConsult( - async (_uri: string) => { - callCount++; - return true; - }, - (uri: string) => uri, - ); - - const r1 = await cached("https://example.com/stream"); - const r2 = await cached("https://example.com/stream"); - - return { r1, r2, callCount }; - }); - - expect(result.r1).toBe(true); - expect(result.r2).toBe(true); - expect(result.callCount).toBe(1); - }); -});