A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: split integration and unit tests

+654 -1745
+7 -3
deno.jsonc
··· 195 195 "description": "Run and serve the site for development", 196 196 "command": "deno task lume -s", 197 197 }, 198 - "test": { 199 - "description": "Run tests", 200 - "command": "deno test -A --doc --ignore=README.md --ignore=./docs/ --ignore=./dist/", 198 + "test:doc": { 199 + "description": "Run documentation tests", 200 + "command": "deno test src -A --doc --ignore=README.md", 201 + }, 202 + "test:integration": { 203 + "description": "Run integration tests", 204 + "command": "deno test tests -A --ignore=README.md", 201 205 }, 202 206 }, 203 207 "compilerOptions": {
+29
src/common/cid.js
··· 4 4 /** 5 5 * @param {0x55 | 0x71} code 6 6 * @param {Uint8Array<any>} data 7 + * 8 + * @example Returns a non-empty base32 CID string, consistent for same input, different for different inputs 9 + * ```js 10 + * import { create } from "~/common/cid.js"; 11 + * 12 + * const data = new TextEncoder().encode("hello world"); 13 + * const cid = await create(0x55, data); 14 + * if (typeof cid !== "string" || cid.length === 0) throw new Error("CID should be a non-empty string"); 15 + * if (!/^[a-z2-7]+$/.test(cid)) throw new Error("CID should be base32-encoded"); 16 + * 17 + * const cid2 = await create(0x55, data); 18 + * if (cid !== cid2) throw new Error("same input should produce same CID"); 19 + * 20 + * const cidDiff = await create(0x55, new TextEncoder().encode("world")); 21 + * if (cid === cidDiff) throw new Error("different input should produce different CID"); 22 + * 23 + * const cidCodec = await create(0x71, data); 24 + * if (cid === cidCodec) throw new Error("different codec should produce different CID"); 25 + * ``` 7 26 */ 8 27 export async function create(code, data) { 9 28 const cid = await CID.create(code, data); ··· 13 32 /** 14 33 * @param {Uint8Array<any>} data 15 34 * @param {string} expected 35 + * 36 + * @example Returns true for matching data and false for mismatched data 37 + * ```js 38 + * import { create, verify } from "~/common/cid.js"; 39 + * 40 + * const data = new TextEncoder().encode("hello"); 41 + * const cid = await create(0x55, data); 42 + * if (!await verify(data, cid)) throw new Error("should verify matching data"); 43 + * if (await verify(new TextEncoder().encode("world"), cid)) throw new Error("should not verify mismatched data"); 44 + * ``` 16 45 */ 17 46 export async function verify(data, expected) { 18 47 const expectedCid = CID.fromString(expected);
+43
src/common/compare.js
··· 3 3 /** 4 4 * @param {any} a 5 5 * @param {any} b 6 + * 7 + * @example Returns true for identical primitives and false for different ones 8 + * ```js 9 + * import { diff } from "~/common/compare.js"; 10 + * 11 + * if (!diff(1, 1)) throw new Error("identical primitives should be equal"); 12 + * if (diff(1, 2)) throw new Error("different primitives should not be equal"); 13 + * ``` 14 + * 15 + * @example Returns true for deeply equal objects and false for different ones 16 + * ```js 17 + * import { diff } from "~/common/compare.js"; 18 + * 19 + * if (!diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })) throw new Error("deeply equal objects should be equal"); 20 + * if (diff({ a: 1 }, { a: 2 })) throw new Error("objects with different values should not be equal"); 21 + * if (diff({ a: 1 }, { b: 1 })) throw new Error("objects with different keys should not be equal"); 22 + * ``` 23 + * 24 + * @example Returns true for identical arrays and false for different ones 25 + * ```js 26 + * import { diff } from "~/common/compare.js"; 27 + * 28 + * if (!diff([1, 2, 3], [1, 2, 3])) throw new Error("identical arrays should be equal"); 29 + * if (diff([1, 2], [1, 3])) throw new Error("arrays with different elements should not be equal"); 30 + * ``` 6 31 */ 7 32 export function diff(a, b) { 8 33 return !deepDiff(a, b); ··· 11 36 /** 12 37 * @param {any} a 13 38 * @param {any} b 39 + * 40 + * @example Returns true for same primitive value, false for different 41 + * ```js 42 + * import { strictEquality } from "~/common/compare.js"; 43 + * 44 + * if (!strictEquality(42, 42)) throw new Error("same primitives should be strictly equal"); 45 + * if (strictEquality(42, 43)) throw new Error("different primitives should not be strictly equal"); 46 + * if (strictEquality(null, undefined)) throw new Error("null and undefined should not be strictly equal"); 47 + * ``` 48 + * 49 + * @example Returns false for same-content objects (reference inequality) 50 + * ```js 51 + * import { strictEquality } from "~/common/compare.js"; 52 + * 53 + * if (strictEquality({ a: 1 }, { a: 1 })) throw new Error("different object references should not be strictly equal"); 54 + * const obj = { a: 1 }; 55 + * if (!strictEquality(obj, obj)) throw new Error("same object reference should be strictly equal"); 56 + * ``` 14 57 */ 15 58 export function strictEquality(a, b) { 16 59 return a === b;
+23
src/common/facets/category.js
··· 5 5 /** 6 6 * @param {Facet} facet 7 7 * @returns {string} 8 + * 9 + * @example Returns accent-twist-4 for prelude kind and accent-twist-2 for others 10 + * ```js 11 + * import { color } from "~/common/facets/category.js"; 12 + * 13 + * const prelude = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" }); 14 + * if (prelude !== "var(--accent-twist-4)") throw new Error("prelude should return accent-twist-4"); 15 + * 16 + * const interactive = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" }); 17 + * if (interactive !== "var(--accent-twist-2)") throw new Error("interactive should return accent-twist-2"); 18 + * 19 + * const undef = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); 20 + * if (undef !== "var(--accent-twist-2)") throw new Error("undefined kind should return accent-twist-2"); 21 + * ``` 8 22 */ 9 23 export function color(facet) { 10 24 switch (facet.kind) { ··· 18 32 /** 19 33 * @param {Facet} facet 20 34 * @returns {string} 35 + * 36 + * @example Returns 'feature' for prelude kind and 'interface' for others 37 + * ```js 38 + * import { name } from "~/common/facets/category.js"; 39 + * 40 + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" }) !== "feature") throw new Error("prelude should return 'feature'"); 41 + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" }) !== "interface") throw new Error("interactive should return 'interface'"); 42 + * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }) !== "interface") throw new Error("undefined kind should return 'interface'"); 43 + * ``` 21 44 */ 22 45 export function name(facet) { 23 46 // return facet.kind ?? "interactive";
+8
src/common/facets/constants.js
··· 1 + /** 2 + * @example TYPE is the ATProto type string for facets 3 + * ```js 4 + * import { TYPE } from "~/common/facets/constants.js"; 5 + * 6 + * if (TYPE !== "sh.diffuse.output.facet") throw new Error(`expected "sh.diffuse.output.facet", got "${TYPE}"`); 7 + * ``` 8 + */ 1 9 export const TYPE = /** @type {const} */ ("sh.diffuse.output.facet"); 2 10 3 11 export const STARTING_SET_DISABLED = [
+44
src/common/facets/utils.js
··· 10 10 /** 11 11 * @param {{ description?: string; kind: string | undefined; name: string; tags?: string[]; uri: string }} _args 12 12 * @param {{ fetchHTML: boolean }} options 13 + * 14 + * @example Creates a facet with correct $type, name, uri, id, and timestamps 15 + * ```js 16 + * import { facetFromURI } from "~/common/facets/utils.js"; 17 + * 18 + * const facet = await facetFromURI({ name: "My Facet", uri: "facets/test/index.html", kind: undefined, description: undefined }, { fetchHTML: false }); 19 + * 20 + * if (facet.$type !== "sh.diffuse.output.facet") throw new Error("$type should be sh.diffuse.output.facet"); 21 + * if (facet.name !== "My Facet") throw new Error("name should be preserved"); 22 + * if (facet.uri !== "facets/test/index.html") throw new Error("uri should be preserved"); 23 + * if (typeof facet.id !== "string" || facet.id.length === 0) throw new Error("id should be a non-empty string"); 24 + * if (!facet.createdAt) throw new Error("createdAt should be set"); 25 + * if (facet.createdAt !== facet.updatedAt) throw new Error("createdAt and updatedAt should match"); 26 + * if (new Date(facet.createdAt).toISOString() !== facet.createdAt) throw new Error("createdAt should be a valid ISO string"); 27 + * ``` 28 + * 29 + * @example fetchHTML false leaves html and cid undefined; kind is validated 30 + * ```js 31 + * import { facetFromURI } from "~/common/facets/utils.js"; 32 + * 33 + * const base = { name: "Test", uri: "test.html", description: undefined }; 34 + * 35 + * const noHtml = await facetFromURI({ ...base, kind: undefined }, { fetchHTML: false }); 36 + * if (noHtml.html !== undefined) throw new Error("html should be undefined when fetchHTML is false"); 37 + * if (noHtml.cid !== undefined) throw new Error("cid should be undefined when fetchHTML is false"); 38 + * 39 + * const prelude = await facetFromURI({ ...base, kind: "prelude" }, { fetchHTML: false }); 40 + * if (prelude.kind !== "prelude") throw new Error("prelude kind should be preserved"); 41 + * 42 + * const interactive = await facetFromURI({ ...base, kind: "interactive" }, { fetchHTML: false }); 43 + * if (interactive.kind !== "interactive") throw new Error("interactive kind should be preserved"); 44 + * 45 + * const unknown = await facetFromURI({ ...base, kind: "unknown" }, { fetchHTML: false }); 46 + * if (unknown.kind !== undefined) throw new Error("unrecognised kind should be set to undefined"); 47 + * ``` 48 + * 49 + * @example Generates unique ids across calls 50 + * ```js 51 + * import { facetFromURI } from "~/common/facets/utils.js"; 52 + * 53 + * const a = await facetFromURI({ name: "A", uri: "a.html", kind: undefined, description: undefined }, { fetchHTML: false }); 54 + * const b = await facetFromURI({ name: "B", uri: "b.html", kind: undefined, description: undefined }, { fetchHTML: false }); 55 + * if (a.id === b.id) throw new Error("ids should be unique across calls"); 56 + * ``` 13 57 */ 14 58 export async function facetFromURI( 15 59 { description, kind, name, tags, uri },
+25
src/common/output.js
··· 8 8 * @template T 9 9 * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T }> }} output 10 10 * @returns {Promise<T>} 11 + * 12 + * @example Resolves immediately when collection is already loaded 13 + * ```js 14 + * import { data } from "~/common/output.js"; 15 + * import { signal } from "~/common/signal.js"; 16 + * 17 + * const col = signal({ state: "loaded", data: ["a", "b"] }); 18 + * const result = await data({ collection: col.get }); 19 + * if (result.join(",") !== "a,b") throw new Error("expected ['a', 'b']"); 20 + * ``` 21 + * 22 + * @example Waits for collection to transition to loaded 23 + * ```js 24 + * import { data } from "~/common/output.js"; 25 + * import { signal } from "~/common/signal.js"; 26 + * 27 + * const col = signal({ state: "loading" }); 28 + * const promise = data({ collection: col.get }); 29 + * 30 + * await Promise.resolve(); 31 + * col.set({ state: "loaded", data: [1, 2, 3] }); 32 + * 33 + * const result = await promise; 34 + * if (result.join(",") !== "1,2,3") throw new Error("expected [1, 2, 3]"); 35 + * ``` 11 36 */ 12 37 export async function data(output) { 13 38 return await new Promise((resolve) => {
+135
src/common/playlist.js
··· 9 9 * 10 10 * @param {Track[]} tracks 11 11 * @param {PlaylistItem[]} playlistItems 12 + * 13 + * @example Returns only tracks matching playlist criteria 14 + * ```js 15 + * import { filterByPlaylist } from "~/common/playlist.js"; 16 + * 17 + * const tracks = [ 18 + * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, 19 + * { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, 20 + * ]; 21 + * const items = [ 22 + * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, 23 + * ]; 24 + * const result = filterByPlaylist(tracks, items); 25 + * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); 26 + * ``` 27 + * 28 + * @example Returns empty array when no tracks match or no items given 29 + * ```js 30 + * import { filterByPlaylist } from "~/common/playlist.js"; 31 + * 32 + * const tracks = [ 33 + * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, 34 + * ]; 35 + * const noMatch = filterByPlaylist(tracks, [ 36 + * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, 37 + * ]); 38 + * if (noMatch.length !== 0) throw new Error("expected no matches"); 39 + * 40 + * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); 41 + * ``` 42 + * 43 + * @example Applies transformations before comparing 44 + * ```js 45 + * import { filterByPlaylist } from "~/common/playlist.js"; 46 + * 47 + * const tracks = [{ $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }]; 48 + * const items = [{ 49 + * $type: "sh.diffuse.output.playlistItem", 50 + * id: "i1", 51 + * playlist: "p", 52 + * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], 53 + * }]; 54 + * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); 55 + * ``` 12 56 */ 13 57 export function filterByPlaylist(tracks, playlistItems) { 14 58 // Group playlist items by criteria shape, building a Set index per shape. ··· 65 109 * Bundle playlist items into their respective playlists. 66 110 * 67 111 * @param {PlaylistItem[]} items 112 + * 113 + * @example Groups items by playlist name and tracks ordered/unordered state 114 + * ```js 115 + * import { gather } from "~/common/playlist.js"; 116 + * 117 + * const items = [ 118 + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 119 + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, 120 + * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 121 + * ]; 122 + * const map = gather(items); 123 + * if (map.get("Rock").items.length !== 2) throw new Error("expected 2 rock items"); 124 + * if (map.get("Pop").items.length !== 1) throw new Error("expected 1 pop item"); 125 + * if (map.get("My Playlist") !== undefined) { 126 + * // separate test: preserves playlist name 127 + * } 128 + * 129 + * const unordered = gather([ 130 + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, 131 + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, 132 + * ]); 133 + * if (!unordered.get("Mix").unordered) throw new Error("playlist without positionedAfter should be unordered"); 134 + * 135 + * const ordered = gather([ 136 + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, 137 + * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, 138 + * ]); 139 + * if (ordered.get("Mix").unordered) throw new Error("playlist with positionedAfter should be ordered"); 140 + * ``` 68 141 */ 69 142 export function gather(items) { 70 143 /** ··· 97 170 * 98 171 * @param {Track} track 99 172 * @param {PlaylistItem} item 173 + * 174 + * @example Returns true when all criteria match, false when any criterion fails 175 + * ```js 176 + * import { match } from "~/common/playlist.js"; 177 + * 178 + * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A", title: "Song A" } }; 179 + * const item = { 180 + * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 181 + * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], 182 + * }; 183 + * if (!match(track, item)) throw new Error("should match when all criteria pass"); 184 + * 185 + * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; 186 + * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); 187 + * ``` 188 + * 189 + * @example Applies transformations before comparing 190 + * ```js 191 + * import { match } from "~/common/playlist.js"; 192 + * 193 + * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A" } }; 194 + * const item = { 195 + * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 196 + * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], 197 + * }; 198 + * if (!match(track, item)) throw new Error("transformation should match lowercase"); 199 + * ``` 100 200 */ 101 201 export function match(track, item) { 102 202 return item.criteria.every((c) => { ··· 129 229 * 130 230 * @param {PlaylistItem[]} items 131 231 * @returns {PlaylistItem[]} 232 + * 233 + * @example Sorts a linked list in order and appends orphaned items at end 234 + * ```js 235 + * import { sort } from "~/common/playlist.js"; 236 + * 237 + * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }]; 238 + * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); 239 + * 240 + * const linked = [ 241 + * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, 242 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 243 + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 244 + * ]; 245 + * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); 246 + * 247 + * const withOrphan = [ 248 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 249 + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 250 + * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, 251 + * ]; 252 + * const sorted = sort(withOrphan); 253 + * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); 254 + * ``` 255 + * 256 + * @example Sorts multiple heads by updatedAt ascending 257 + * ```js 258 + * import { sort } from "~/common/playlist.js"; 259 + * 260 + * const items = [ 261 + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" }, 262 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" }, 263 + * ]; 264 + * const result = sort(items); 265 + * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); 266 + * ``` 132 267 */ 133 268 export function sort(items) { 134 269 if (items.length <= 1) return items;
+81
src/common/signal.js
··· 13 13 14 14 /** 15 15 * @param {function(): void} fn 16 + * 17 + * @example Defers effect execution until batch completes 18 + * ```js 19 + * import { signal, effect, batch } from "~/common/signal.js"; 20 + * 21 + * const a = signal(0); 22 + * const b = signal(0); 23 + * const values = []; 24 + * 25 + * effect(() => { values.push(a.get() + b.get()); }); 26 + * 27 + * const before = [...values]; // [0] 28 + * batch(() => { a.set(1); b.set(2); }); 29 + * 30 + * if (before.join(",") !== "0") throw new Error("expected [0] before batch"); 31 + * if (values.join(",") !== "0,3") throw new Error("expected exactly one update after batch, got " + values.join(",")); 32 + * ``` 16 33 */ 17 34 export const batch = (fn) => { 18 35 startBatch(); ··· 28 45 * @param {T} initialValue 29 46 * @param {{ compare?: (a: T, b: T) => boolean }} [options] 30 47 * @returns {Signal<T>} 48 + * 49 + * @example get/set and value getter/setter return and update the value 50 + * ```js 51 + * import { signal } from "~/common/signal.js"; 52 + * 53 + * const s = signal(42); 54 + * if (s.get() !== 42) throw new Error("get should return initial value"); 55 + * if (s.value !== 42) throw new Error("value getter should return initial value"); 56 + * 57 + * s.set(99); 58 + * if (s.get() !== 99) throw new Error("get should return updated value"); 59 + * 60 + * s.value = "b"; 61 + * if (s.value !== "b") throw new Error("value setter should update value"); 62 + * ``` 63 + * 64 + * @example compare option skips update when values are equal by custom comparator 65 + * ```js 66 + * import { signal, effect } from "~/common/signal.js"; 67 + * 68 + * let runCount = 0; 69 + * const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); 70 + * 71 + * effect(() => { s.get(); runCount++; }); 72 + * 73 + * const before = runCount; 74 + * s.set({ x: 1 }); // same by compare 75 + * if (runCount !== before) throw new Error("effect should not re-run when value is equal by compare"); 76 + * 77 + * s.set({ x: 2 }); // different by compare 78 + * if (runCount !== before + 1) throw new Error("effect should re-run when value differs by compare"); 79 + * ``` 31 80 */ 32 81 export function signal(initialValue, options) { 33 82 const s = alienSignal(initialValue); ··· 53 102 * @template T 54 103 * @param {function(): T} fn 55 104 * @returns {T} 105 + * 106 + * @example Reads a signal without tracking it as a dependency 107 + * ```js 108 + * import { signal, effect, untracked } from "~/common/signal.js"; 109 + * 110 + * const a = signal(1); 111 + * const b = signal(10); 112 + * let runCount = 0; 113 + * 114 + * effect(() => { 115 + * a.get(); // tracked 116 + * untracked(() => b.get()); // not tracked 117 + * runCount++; 118 + * }); 119 + * 120 + * const before = runCount; // 1 121 + * b.set(20); // should NOT re-run effect 122 + * if (runCount !== before) throw new Error("untracked read should not trigger re-run"); 123 + * 124 + * a.set(2); // SHOULD re-run effect 125 + * if (runCount !== before + 1) throw new Error("tracked read should trigger re-run"); 126 + * 127 + * if (untracked(() => a.get()) !== 2) throw new Error("untracked should return the value"); 128 + * ``` 56 129 */ 57 130 export const untracked = (fn) => { 58 131 const sub = setActiveSub(void 0); ··· 67 140 * @template T 68 141 * @param {function(): Promise<T>} fn 69 142 * @returns {Promise<T>} 143 + * 144 + * @example Returns the resolved value from the async callback 145 + * ```js 146 + * import { untrackedAsync } from "~/common/signal.js"; 147 + * 148 + * const result = await untrackedAsync(async () => 99); 149 + * if (result !== 99) throw new Error("untrackedAsync should return resolved value"); 150 + * ``` 70 151 */ 71 152 export const untrackedAsync = async (fn) => { 72 153 const sub = setActiveSub(void 0);
+17
src/common/temporal.js
··· 4 4 /** 5 5 * @param {string} a 6 6 * @param {string} b 7 + * 8 + * @example Returns 0 for equal timestamps, negative for earlier, positive for later 9 + * ```js 10 + * import { compareTimestamps } from "~/common/temporal.js"; 11 + * 12 + * const eq = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z"); 13 + * if (eq !== 0) throw new Error("equal timestamps should return 0"); 14 + * 15 + * const earlier = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-06-01T00:00:00.000Z"); 16 + * if (earlier >= 0) throw new Error("earlier timestamp should return negative"); 17 + * 18 + * const later = compareTimestamps("2024-06-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z"); 19 + * if (later <= 0) throw new Error("later timestamp should return positive"); 20 + * 21 + * const ms = compareTimestamps("2024-01-01T00:00:00.001Z", "2024-01-01T00:00:00.000Z"); 22 + * if (ms <= 0) throw new Error("millisecond precision should be respected"); 23 + * ``` 7 24 */ 8 25 export function compareTimestamps(a, b) { 9 26 return Temporal.Instant.compare(
+38
src/common/track.js
··· 6 6 7 7 /** 8 8 * @param {string} uri 9 + * 10 + * @example Strips path and query from a URI 11 + * ```js 12 + * import { trackURIBase } from "~/common/track.js"; 13 + * 14 + * const r1 = trackURIBase("https://example.com/music/track.mp3"); 15 + * if (r1 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r1}"`); 16 + * 17 + * const r2 = trackURIBase("https://example.com/track.mp3?token=abc"); 18 + * if (r2 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r2}"`); 19 + * 20 + * const r3 = trackURIBase("https://example.com"); 21 + * if (r3 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r3}"`); 22 + * 23 + * const r4 = trackURIBase("s3://my-bucket/path/to/track.flac"); 24 + * if (r4 !== "s3://my-bucket") throw new Error(`expected "s3://my-bucket", got "${r4}"`); 25 + * ``` 9 26 */ 10 27 export function trackURIBase(uri) { 11 28 const p = URI.parse(uri); ··· 16 33 17 34 /** 18 35 * @param {Track[]} tracks 36 + * 37 + * @example Returns a deduplicated set of base URIs 38 + * ```js 39 + * import { uniqueTrackURIs } from "~/common/track.js"; 40 + * 41 + * const tracks = [ 42 + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, 43 + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, 44 + * ]; 45 + * const set = uniqueTrackURIs(tracks); 46 + * if (!set.has("https://example.com/")) throw new Error("expected base URI in set"); 47 + * if (set.size !== 1) throw new Error("expected deduplication to one entry"); 48 + * 49 + * const multi = uniqueTrackURIs([ 50 + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 51 + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 52 + * ]); 53 + * if (multi.size !== 2) throw new Error("expected 2 distinct base URIs"); 54 + * 55 + * if (uniqueTrackURIs([]).size !== 0) throw new Error("expected empty set for empty input"); 56 + * ``` 19 57 */ 20 58 export function uniqueTrackURIs(tracks) { 21 59 const set = new Set();
+134
src/common/utils.js
··· 8 8 * @template T 9 9 * @param {Array<T>} array 10 10 * @returns Array<T> 11 + * 12 + * @example Returns same elements in (possibly different) order without mutating the original 13 + * ```js 14 + * import { arrayShuffle } from "~/common/utils.js"; 15 + * 16 + * if (arrayShuffle([]).length !== 0) throw new Error("empty array should return empty"); 17 + * 18 + * const sorted = arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b); 19 + * if (sorted.join(",") !== "1,2,3,4,5") throw new Error("shuffled array should contain same elements"); 20 + * 21 + * const original = [1, 2, 3]; 22 + * arrayShuffle(original); 23 + * if (original.join(",") !== "1,2,3") throw new Error("original array should not be mutated"); 24 + * 25 + * if (arrayShuffle([42]).join(",") !== "42") throw new Error("single-element array should be unchanged"); 26 + * ``` 11 27 */ 12 28 export function arrayShuffle(array) { 13 29 if (array.length === 0) { ··· 28 44 29 45 /** 30 46 * @param {string | undefined | null} value 47 + * 48 + * @example Returns true only for empty string (present attribute) 49 + * ```js 50 + * import { boolAttr } from "~/common/utils.js"; 51 + * 52 + * if (!boolAttr("")) throw new Error("empty string should return true"); 53 + * if (boolAttr(null)) throw new Error("null should return false"); 54 + * if (boolAttr(undefined)) throw new Error("undefined should return false"); 55 + * if (boolAttr("true")) throw new Error("non-empty string should return false"); 56 + * ``` 31 57 */ 32 58 export function boolAttr(value) { 33 59 return value === ""; ··· 36 62 37 63 /** 38 64 * @param {any} object 65 + * 66 + * @example Returns a consistent string hash for the same object 67 + * ```js 68 + * import { hash } from "~/common/utils.js"; 69 + * 70 + * if (typeof hash({ a: 1 }) !== "string") throw new Error("hash should return a string"); 71 + * if (hash({ a: 1, b: 2 }) !== hash({ a: 1, b: 2 })) throw new Error("same objects should produce same hash"); 72 + * if (hash({ a: 1 }) === hash({ a: 2 })) throw new Error("different objects should produce different hashes"); 73 + * ``` 39 74 */ 40 75 export function hash(object) { 41 76 return xxh32r(jsonEncode(object)).toString(); ··· 45 80 * @param {Track[]} tracks 46 81 * @param {Record<string, Track[]>} initial 47 82 * @returns {Record<string, Track[]>} 83 + * 84 + * @example Groups tracks by URI scheme 85 + * ```js 86 + * import { groupTracksPerScheme } from "~/common/utils.js"; 87 + * 88 + * const tracks = [ 89 + * { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }, 90 + * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, 91 + * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, 92 + * ]; 93 + * const groups = groupTracksPerScheme(tracks); 94 + * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks"); 95 + * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track"); 96 + * ``` 97 + * 98 + * @example Merges into a provided initial object 99 + * ```js 100 + * import { groupTracksPerScheme } from "~/common/utils.js"; 101 + * 102 + * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] }; 103 + * const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }]; 104 + * const groups = groupTracksPerScheme(tracks, initial); 105 + * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); 106 + * ``` 48 107 */ 49 108 export function groupTracksPerScheme( 50 109 tracks, ··· 65 124 /** 66 125 * @param {string[]} uris 67 126 * @returns {Record<string, string[]>} 127 + * 128 + * @example Groups URIs by scheme 129 + * ```js 130 + * import { groupUrisPerScheme } from "~/common/utils.js"; 131 + * 132 + * const groups = groupUrisPerScheme(["http://a.com/t.mp3", "s3://b/t.flac", "http://c.com/t.mp3"]); 133 + * if (groups["http"].length !== 2) throw new Error("expected 2 http URIs"); 134 + * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 URI"); 135 + * 136 + * if (Object.keys(groupUrisPerScheme([])).length !== 0) throw new Error("expected empty object for empty input"); 137 + * ``` 68 138 */ 69 139 export function groupUrisPerScheme(uris) { 70 140 /** @type {Record<string, string[]>} */ ··· 81 151 82 152 /** 83 153 * @param {unknown} test 154 + * 155 + * @example Returns true for primitives and false for objects/arrays 156 + * ```js 157 + * import { isPrimitive } from "~/common/utils.js"; 158 + * 159 + * if (!isPrimitive(42)) throw new Error("number should be primitive"); 160 + * if (!isPrimitive("hello")) throw new Error("string should be primitive"); 161 + * if (!isPrimitive(true)) throw new Error("boolean should be primitive"); 162 + * if (!isPrimitive(null)) throw new Error("null should be primitive"); 163 + * if (isPrimitive({ a: 1 })) throw new Error("object should not be primitive"); 164 + * if (isPrimitive([1, 2])) throw new Error("array should not be primitive"); 165 + * ``` 84 166 */ 85 167 export function isPrimitive(test) { 86 168 return test !== Object(test); ··· 99 181 * @template T 100 182 * @param {T} a 101 183 * @returns Uint8Array 184 + * 185 + * @example jsonEncode returns a Uint8Array that round-trips through jsonDecode 186 + * ```js 187 + * import { jsonEncode, jsonDecode } from "~/common/utils.js"; 188 + * 189 + * const original = { a: 1, b: "hello", c: [1, 2, 3] }; 190 + * if (!(jsonEncode(original) instanceof Uint8Array)) throw new Error("jsonEncode should return a Uint8Array"); 191 + * 192 + * const roundTripped = jsonDecode(jsonEncode(original)); 193 + * if (JSON.stringify(roundTripped) !== JSON.stringify(original)) throw new Error("round-trip should preserve values"); 194 + * ``` 102 195 */ 103 196 export function jsonEncode(a) { 104 197 return new TextEncoder().encode(JSON.stringify(a)); ··· 107 200 /** 108 201 * @template {Record<string, any>} T 109 202 * @param {T} rec 203 + * 204 + * @example Removes keys with undefined values without mutating the original 205 + * ```js 206 + * import { removeUndefinedValuesFromRecord } from "~/common/utils.js"; 207 + * 208 + * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); 209 + * if ("b" in result) throw new Error("undefined key should be removed"); 210 + * if (result.a !== 1 || result.c !== "x") throw new Error("defined keys should be preserved"); 211 + * 212 + * const original = { a: 1, b: undefined }; 213 + * removeUndefinedValuesFromRecord(original); 214 + * if (!("b" in original)) throw new Error("original should not be mutated"); 215 + * 216 + * const noUndef = removeUndefinedValuesFromRecord({ a: 1, b: 2 }); 217 + * if (noUndef.a !== 1 || noUndef.b !== 2) throw new Error("record without undefined values should be unchanged"); 218 + * ``` 110 219 */ 111 220 export function removeUndefinedValuesFromRecord(rec) { 112 221 const recClone = { ...rec }; ··· 123 232 /** 124 233 * @template {Record<string, any>} T 125 234 * @param {T} rec 235 + * 236 + * @example Deep-clones nested records without sharing references 237 + * ```js 238 + * import { recursivelyCloneRecords } from "~/common/utils.js"; 239 + * 240 + * const original = { a: 1, b: "x" }; 241 + * const clone = recursivelyCloneRecords(original); 242 + * if (clone === original) throw new Error("clone should be a different object"); 243 + * if (clone.a !== 1 || clone.b !== "x") throw new Error("values should be preserved"); 244 + * 245 + * const nested = { outer: { inner: 42 } }; 246 + * const nestedClone = recursivelyCloneRecords(nested); 247 + * if (nestedClone.outer === nested.outer) throw new Error("nested objects should not share references"); 248 + * if (nestedClone.outer.inner !== 42) throw new Error("nested values should be preserved"); 249 + * ``` 126 250 */ 127 251 export function recursivelyCloneRecords(rec) { 128 252 const recClone = { ...rec }; ··· 140 264 /** 141 265 * @param {string} str 142 266 * @returns {string} 267 + * 268 + * @example Decodes percent-encoded and %u unicode escapes, leaves plain strings unchanged 269 + * ```js 270 + * import { safeDecodeURIComponent } from "~/common/utils.js"; 271 + * 272 + * if (safeDecodeURIComponent("hello%20world") !== "hello world") throw new Error("should decode %20 as space"); 273 + * if (safeDecodeURIComponent("%u0041") !== "A") throw new Error("should decode %u unicode escape"); 274 + * if (safeDecodeURIComponent("plain-string") !== "plain-string") throw new Error("plain string should be unchanged"); 275 + * if (safeDecodeURIComponent("hello%2Fworld") !== "hello/world") throw new Error("should decode %2F as slash"); 276 + * ``` 143 277 */ 144 278 export function safeDecodeURIComponent(str) { 145 279 return str.replace(
+70
src/components/input/common.js
··· 11 11 * @param {(arg: T) => string} keyFn 12 12 * @param {number} ttl - Cache TTL in milliseconds 13 13 * @returns {(arg: T) => Promise<boolean>} 14 + * 15 + * @example Caches results and avoids calling fn more than once per key 16 + * ```js 17 + * import { cachedConsult } from "~/components/input/common.js"; 18 + * 19 + * let callCount = 0; 20 + * const cached = cachedConsult(async (uri) => { callCount++; return true; }, (uri) => uri); 21 + * 22 + * const r1 = await cached("https://example.com/stream"); 23 + * const r2 = await cached("https://example.com/stream"); 24 + * 25 + * if (r1 !== true || r2 !== true) throw new Error("should return cached value"); 26 + * if (callCount !== 1) throw new Error("fn should only be called once per key"); 27 + * ``` 14 28 */ 15 29 export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { 16 30 /** @type {Map<string, { value: boolean; expiry: number }>} */ ··· 33 47 34 48 /** 35 49 * @param {{ fileUriOrScheme: string; handleFileUri: (args: { fileURI: string; tracks: Track[] }) => Track[]; inputScheme: string; tracks: Track[] }} _ 50 + * 51 + * @example Removes all tracks when given a matching scheme, returns all when scheme doesn't match 52 + * ```js 53 + * import { detach } from "~/components/input/common.js"; 54 + * 55 + * const tracks = [ 56 + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 57 + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 58 + * ]; 59 + * 60 + * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks }); 61 + * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks"); 62 + * 63 + * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks }); 64 + * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks"); 65 + * ``` 66 + * 67 + * @example Delegates to handleFileUri when a full URI is given 68 + * ```js 69 + * import { detach } from "~/components/input/common.js"; 70 + * 71 + * const tracks = [ 72 + * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 73 + * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 74 + * ]; 75 + * 76 + * const result = detach({ 77 + * fileUriOrScheme: "https://a.com/1.mp3", 78 + * inputScheme: "https", 79 + * handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), 80 + * tracks, 81 + * }); 82 + * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI"); 83 + * ``` 36 84 */ 37 85 export function detach( 38 86 { fileUriOrScheme, handleFileUri, inputScheme, tracks }, ··· 49 97 /** 50 98 * @param {string} scheme 51 99 * @param {string} groupId 100 + * 101 + * @example Returns scheme://groupId 102 + * ```js 103 + * import { groupKey } from "~/components/input/common.js"; 104 + * 105 + * if (groupKey("https", "example.com") !== "https://example.com") throw new Error(`expected "https://example.com"`); 106 + * ``` 52 107 */ 53 108 export function groupKey(scheme, groupId) { 54 109 return `${scheme}://${groupId}`; ··· 56 111 57 112 /** 58 113 * @param {string} filename 114 + * 115 + * @example Returns truthy for audio extensions and falsy for non-audio ones 116 + * ```js 117 + * import { isAudioFile } from "~/components/input/common.js"; 118 + * 119 + * const audioExts = ["track.mp3", "track.flac", "track.ogg", "track.opus", "track.wav", "track.m4a", "track.webm"]; 120 + * for (const f of audioExts) { 121 + * if (!isAudioFile(f)) throw new Error(`${f} should be recognised as audio`); 122 + * } 123 + * 124 + * const nonAudio = ["track.txt", "track.jpg", "track.pdf", "track"]; 125 + * for (const f of nonAudio) { 126 + * if (isAudioFile(f)) throw new Error(`${f} should not be recognised as audio`); 127 + * } 128 + * ``` 59 129 */ 60 130 export function isAudioFile(filename) { 61 131 return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/);
-80
tests/common/cid/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/cid", () => { 7 - describe("create", () => { 8 - it("returns a non-empty CID string", async () => { 9 - const result = await testWeb(async () => { 10 - const { create } = await import("~/common/cid.js"); 11 - return create(0x55, new TextEncoder().encode("hello")); 12 - }); 13 - expect(typeof result).toBe("string"); 14 - expect(result.length).toBeGreaterThan(0); 15 - }); 16 - 17 - it("returns consistent CID for same input", async () => { 18 - const result = await testWeb(async () => { 19 - const { create } = await import("~/common/cid.js"); 20 - const data = new TextEncoder().encode("hello world"); 21 - const a = await create(0x55, data); 22 - const b = await create(0x55, data); 23 - return a === b; 24 - }); 25 - expect(result).toBe(true); 26 - }); 27 - 28 - it("returns different CIDs for different inputs", async () => { 29 - const result = await testWeb(async () => { 30 - const { create } = await import("~/common/cid.js"); 31 - const a = await create(0x55, new TextEncoder().encode("hello")); 32 - const b = await create(0x55, new TextEncoder().encode("world")); 33 - return a === b; 34 - }); 35 - expect(result).toBe(false); 36 - }); 37 - 38 - it("returns different CIDs for different codecs", async () => { 39 - const result = await testWeb(async () => { 40 - const { create } = await import("~/common/cid.js"); 41 - const data = new TextEncoder().encode("hello"); 42 - const a = await create(0x55, data); 43 - const b = await create(0x71, data); 44 - return a === b; 45 - }); 46 - expect(result).toBe(false); 47 - }); 48 - 49 - it("returns a base32-encoded CID string", async () => { 50 - const result = await testWeb(async () => { 51 - const { create } = await import("~/common/cid.js"); 52 - return create(0x55, new TextEncoder().encode("test")); 53 - }); 54 - // CIDv1 base32 strings contain only lowercase alphanumeric characters 55 - expect(/^[a-z2-7]+$/.test(result)).toBe(true); 56 - }); 57 - }); 58 - 59 - describe("verify", () => { 60 - it("returns true for matching data and CID", async () => { 61 - const result = await testWeb(async () => { 62 - const { create, verify } = await import("~/common/cid.js"); 63 - const data = new TextEncoder().encode("hello"); 64 - const cid = await create(0x55, data); 65 - return verify(data, cid); 66 - }); 67 - expect(result).toBe(true); 68 - }); 69 - 70 - it("returns false for mismatched data", async () => { 71 - const result = await testWeb(async () => { 72 - const { create, verify } = await import("~/common/cid.js"); 73 - const data = new TextEncoder().encode("hello"); 74 - const cid = await create(0x55, data); 75 - return verify(new TextEncoder().encode("world"), cid); 76 - }); 77 - expect(result).toBe(false); 78 - }); 79 - }); 80 - });
-107
tests/common/compare/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/compare", () => { 7 - describe("diff", () => { 8 - it("returns true for identical primitives", async () => { 9 - const result = await testWeb(async () => { 10 - const { diff } = await import("~/common/compare.js"); 11 - return diff(1, 1); 12 - }); 13 - expect(result).toBe(true); 14 - }); 15 - 16 - it("returns false for different primitives", async () => { 17 - const result = await testWeb(async () => { 18 - const { diff } = await import("~/common/compare.js"); 19 - return diff(1, 2); 20 - }); 21 - expect(result).toBe(false); 22 - }); 23 - 24 - it("returns true for deeply equal objects", async () => { 25 - const result = await testWeb(async () => { 26 - const { diff } = await import("~/common/compare.js"); 27 - return diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); 28 - }); 29 - expect(result).toBe(true); 30 - }); 31 - 32 - it("returns false for objects with different values", async () => { 33 - const result = await testWeb(async () => { 34 - const { diff } = await import("~/common/compare.js"); 35 - return diff({ a: 1 }, { a: 2 }); 36 - }); 37 - expect(result).toBe(false); 38 - }); 39 - 40 - it("returns false for objects with different keys", async () => { 41 - const result = await testWeb(async () => { 42 - const { diff } = await import("~/common/compare.js"); 43 - return diff({ a: 1 }, { b: 1 }); 44 - }); 45 - expect(result).toBe(false); 46 - }); 47 - 48 - it("returns true for identical arrays", async () => { 49 - const result = await testWeb(async () => { 50 - const { diff } = await import("~/common/compare.js"); 51 - return diff([1, 2, 3], [1, 2, 3]); 52 - }); 53 - expect(result).toBe(true); 54 - }); 55 - 56 - it("returns false for arrays with different elements", async () => { 57 - const result = await testWeb(async () => { 58 - const { diff } = await import("~/common/compare.js"); 59 - return diff([1, 2], [1, 3]); 60 - }); 61 - expect(result).toBe(false); 62 - }); 63 - }); 64 - 65 - describe("strictEquality", () => { 66 - it("returns true for same primitive value", async () => { 67 - const result = await testWeb(async () => { 68 - const { strictEquality } = await import("~/common/compare.js"); 69 - return strictEquality(42, 42); 70 - }); 71 - expect(result).toBe(true); 72 - }); 73 - 74 - it("returns false for different primitive values", async () => { 75 - const result = await testWeb(async () => { 76 - const { strictEquality } = await import("~/common/compare.js"); 77 - return strictEquality(42, 43); 78 - }); 79 - expect(result).toBe(false); 80 - }); 81 - 82 - it("returns false for same-content objects (reference inequality)", async () => { 83 - const result = await testWeb(async () => { 84 - const { strictEquality } = await import("~/common/compare.js"); 85 - return strictEquality({ a: 1 }, { a: 1 }); 86 - }); 87 - expect(result).toBe(false); 88 - }); 89 - 90 - it("returns true for same object reference", async () => { 91 - const result = await testWeb(async () => { 92 - const { strictEquality } = await import("~/common/compare.js"); 93 - const obj = { a: 1 }; 94 - return strictEquality(obj, obj); 95 - }); 96 - expect(result).toBe(true); 97 - }); 98 - 99 - it("returns false for null vs undefined", async () => { 100 - const result = await testWeb(async () => { 101 - const { strictEquality } = await import("~/common/compare.js"); 102 - return strictEquality(null, undefined); 103 - }); 104 - expect(result).toBe(false); 105 - }); 106 - }); 107 - });
-381
tests/common/facets/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/facets/category", () => { 7 - it("color returns accent-twist-4 for prelude kind", async () => { 8 - const result = await testWeb(async () => { 9 - const { color } = await import("~/common/facets/category.js"); 10 - return color({ 11 - $type: "sh.diffuse.output.facet", 12 - id: "1", 13 - name: "x", 14 - kind: "prelude", 15 - }); 16 - }); 17 - expect(result).toBe("var(--accent-twist-4)"); 18 - }); 19 - 20 - it("color returns accent-twist-2 for interactive kind", async () => { 21 - const result = await testWeb(async () => { 22 - const { color } = await import("~/common/facets/category.js"); 23 - return color({ 24 - $type: "sh.diffuse.output.facet", 25 - id: "1", 26 - name: "x", 27 - kind: "interactive", 28 - }); 29 - }); 30 - expect(result).toBe("var(--accent-twist-2)"); 31 - }); 32 - 33 - it("color returns accent-twist-2 for undefined kind", async () => { 34 - const result = await testWeb(async () => { 35 - const { color } = await import("~/common/facets/category.js"); 36 - return color({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); 37 - }); 38 - expect(result).toBe("var(--accent-twist-2)"); 39 - }); 40 - 41 - it("name returns 'feature' for prelude kind", async () => { 42 - const result = await testWeb(async () => { 43 - const { name } = await import("~/common/facets/category.js"); 44 - return name({ 45 - $type: "sh.diffuse.output.facet", 46 - id: "1", 47 - name: "x", 48 - kind: "prelude", 49 - }); 50 - }); 51 - expect(result).toBe("feature"); 52 - }); 53 - 54 - it("name returns 'interface' for interactive kind", async () => { 55 - const result = await testWeb(async () => { 56 - const { name } = await import("~/common/facets/category.js"); 57 - return name({ 58 - $type: "sh.diffuse.output.facet", 59 - id: "1", 60 - name: "x", 61 - kind: "interactive", 62 - }); 63 - }); 64 - expect(result).toBe("interface"); 65 - }); 66 - 67 - it("name returns 'interface' for undefined kind", async () => { 68 - const result = await testWeb(async () => { 69 - const { name } = await import("~/common/facets/category.js"); 70 - return name({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }); 71 - }); 72 - expect(result).toBe("interface"); 73 - }); 74 - }); 75 - 76 - describe("common/facets/constants", () => { 77 - it("TYPE is sh.diffuse.output.facet", async () => { 78 - const result = await testWeb(async () => { 79 - const { TYPE } = await import("~/common/facets/constants.js"); 80 - return TYPE; 81 - }); 82 - expect(result).toBe("sh.diffuse.output.facet"); 83 - }); 84 - }); 85 - 86 - describe("common/facets/utils", () => { 87 - it("facetFromURI sets $type", async () => { 88 - const result = await testWeb(async () => { 89 - const { facetFromURI } = await import("~/common/facets/utils.js"); 90 - const facet = await facetFromURI( 91 - { 92 - name: "Test", 93 - uri: "test.html", 94 - kind: undefined, 95 - description: undefined, 96 - }, 97 - { fetchHTML: false }, 98 - ); 99 - return facet.$type; 100 - }); 101 - expect(result).toBe("sh.diffuse.output.facet"); 102 - }); 103 - 104 - it("facetFromURI sets name and uri", async () => { 105 - const result = await testWeb(async () => { 106 - const { facetFromURI } = await import("~/common/facets/utils.js"); 107 - const facet = await facetFromURI( 108 - { 109 - name: "My Facet", 110 - uri: "facets/test/index.html", 111 - kind: undefined, 112 - description: undefined, 113 - }, 114 - { fetchHTML: false }, 115 - ); 116 - return { name: facet.name, uri: facet.uri }; 117 - }); 118 - expect(result.name).toBe("My Facet"); 119 - expect(result.uri).toBe("facets/test/index.html"); 120 - }); 121 - 122 - it("facetFromURI with fetchHTML false leaves html and cid undefined", async () => { 123 - const result = await testWeb(async () => { 124 - const { facetFromURI } = await import("~/common/facets/utils.js"); 125 - const facet = await facetFromURI( 126 - { 127 - name: "Test", 128 - uri: "test.html", 129 - kind: undefined, 130 - description: undefined, 131 - }, 132 - { fetchHTML: false }, 133 - ); 134 - return { html: facet.html ?? null, cid: facet.cid ?? null }; 135 - }); 136 - expect(result.html).toBe(null); 137 - expect(result.cid).toBe(null); 138 - }); 139 - 140 - it("facetFromURI sets prelude kind", async () => { 141 - const result = await testWeb(async () => { 142 - const { facetFromURI } = await import("~/common/facets/utils.js"); 143 - const facet = await facetFromURI( 144 - { 145 - name: "Test", 146 - uri: "test.html", 147 - kind: "prelude", 148 - description: undefined, 149 - }, 150 - { fetchHTML: false }, 151 - ); 152 - return facet.kind; 153 - }); 154 - expect(result).toBe("prelude"); 155 - }); 156 - 157 - it("facetFromURI sets interactive kind", async () => { 158 - const result = await testWeb(async () => { 159 - const { facetFromURI } = await import("~/common/facets/utils.js"); 160 - const facet = await facetFromURI( 161 - { 162 - name: "Test", 163 - uri: "test.html", 164 - kind: "interactive", 165 - description: undefined, 166 - }, 167 - { fetchHTML: false }, 168 - ); 169 - return facet.kind; 170 - }); 171 - expect(result).toBe("interactive"); 172 - }); 173 - 174 - it("facetFromURI sets kind to undefined for unrecognised kind", async () => { 175 - const result = await testWeb(async () => { 176 - const { facetFromURI } = await import("~/common/facets/utils.js"); 177 - const facet = await facetFromURI( 178 - { 179 - name: "Test", 180 - uri: "test.html", 181 - kind: "unknown", 182 - description: undefined, 183 - }, 184 - { fetchHTML: false }, 185 - ); 186 - return facet.kind ?? null; 187 - }); 188 - expect(result).toBe(null); 189 - }); 190 - 191 - it("facetFromURI sets description", async () => { 192 - const result = await testWeb(async () => { 193 - const { facetFromURI } = await import("~/common/facets/utils.js"); 194 - const facet = await facetFromURI( 195 - { 196 - name: "Test", 197 - uri: "test.html", 198 - kind: undefined, 199 - description: "A test facet", 200 - }, 201 - { fetchHTML: false }, 202 - ); 203 - return facet.description; 204 - }); 205 - expect(result).toBe("A test facet"); 206 - }); 207 - 208 - it("facetFromURI sets createdAt and updatedAt as matching ISO strings", async () => { 209 - const result = await testWeb(async () => { 210 - const { facetFromURI } = await import("~/common/facets/utils.js"); 211 - const facet = await facetFromURI( 212 - { 213 - name: "Test", 214 - uri: "test.html", 215 - kind: undefined, 216 - description: undefined, 217 - }, 218 - { fetchHTML: false }, 219 - ); 220 - return { createdAt: facet.createdAt, updatedAt: facet.updatedAt }; 221 - }); 222 - expect(result.createdAt).toBeTruthy(); 223 - expect(result.createdAt).toBe(result.updatedAt); 224 - expect(new Date(result.createdAt!).toISOString()).toBe(result.createdAt); 225 - }); 226 - 227 - it("facetFromURI generates a non-empty string id", async () => { 228 - const result = await testWeb(async () => { 229 - const { facetFromURI } = await import("~/common/facets/utils.js"); 230 - const facet = await facetFromURI( 231 - { 232 - name: "Test", 233 - uri: "test.html", 234 - kind: undefined, 235 - description: undefined, 236 - }, 237 - { fetchHTML: false }, 238 - ); 239 - return facet.id; 240 - }); 241 - expect(typeof result).toBe("string"); 242 - expect(result.length).toBeGreaterThan(0); 243 - }); 244 - 245 - it("facetFromURI generates unique ids", async () => { 246 - const result = await testWeb(async () => { 247 - const { facetFromURI } = await import("~/common/facets/utils.js"); 248 - const a = await facetFromURI( 249 - { name: "A", uri: "a.html", kind: undefined, description: undefined }, 250 - { fetchHTML: false }, 251 - ); 252 - const b = await facetFromURI( 253 - { name: "B", uri: "b.html", kind: undefined, description: undefined }, 254 - { fetchHTML: false }, 255 - ); 256 - return a.id === b.id; 257 - }); 258 - expect(result).toBe(false); 259 - }); 260 - }); 261 - 262 - describe("common/facets/prelude", () => { 263 - it("insertPreludes only inserts prelude kind facets", async () => { 264 - const result = await testWeb(async () => { 265 - const { insertPreludes } = await import("~/common/facets/prelude.js"); 266 - const container = document.createElement("div"); 267 - document.body.append(container); 268 - 269 - await insertPreludes( 270 - [ 271 - { 272 - $type: "sh.diffuse.output.facet", 273 - id: "1", 274 - name: "A", 275 - kind: "interactive", 276 - html: "<span id='fp-interactive'></span>", 277 - }, 278 - { 279 - $type: "sh.diffuse.output.facet", 280 - id: "2", 281 - name: "B", 282 - kind: "prelude", 283 - html: "<span id='fp-prelude'></span>", 284 - }, 285 - ], 286 - container, 287 - ); 288 - 289 - return { 290 - hasInteractive: !!container.querySelector("#fp-interactive"), 291 - hasPrelude: !!container.querySelector("#fp-prelude"), 292 - }; 293 - }); 294 - 295 - expect(result.hasInteractive).toBe(false); 296 - expect(result.hasPrelude).toBe(true); 297 - }); 298 - 299 - it("insertPreludes sorts preludes alphabetically by name", async () => { 300 - const result = await testWeb(async () => { 301 - const { insertPreludes } = await import("~/common/facets/prelude.js"); 302 - const container = document.createElement("div"); 303 - document.body.append(container); 304 - 305 - await insertPreludes( 306 - [ 307 - { 308 - $type: "sh.diffuse.output.facet", 309 - id: "1", 310 - name: "Zebra", 311 - kind: "prelude", 312 - html: "<span class='fp-order'>Zebra</span>", 313 - }, 314 - { 315 - $type: "sh.diffuse.output.facet", 316 - id: "2", 317 - name: "Alpha", 318 - kind: "prelude", 319 - html: "<span class='fp-order'>Alpha</span>", 320 - }, 321 - { 322 - $type: "sh.diffuse.output.facet", 323 - id: "3", 324 - name: "Mango", 325 - kind: "prelude", 326 - html: "<span class='fp-order'>Mango</span>", 327 - }, 328 - ], 329 - container, 330 - ); 331 - 332 - return Array.from(container.querySelectorAll(".fp-order")).map((el) => 333 - el.textContent 334 - ); 335 - }); 336 - 337 - expect(result).toEqual(["Alpha", "Mango", "Zebra"]); 338 - }); 339 - 340 - it("insertPreludes defaults to document.body", async () => { 341 - const result = await testWeb(async () => { 342 - const { insertPreludes } = await import("~/common/facets/prelude.js"); 343 - 344 - await insertPreludes([ 345 - { 346 - $type: "sh.diffuse.output.facet", 347 - id: "1", 348 - name: "Test", 349 - kind: "prelude", 350 - html: "<span id='fp-body-test'></span>", 351 - }, 352 - ]); 353 - 354 - return !!document.body.querySelector("#fp-body-test"); 355 - }); 356 - 357 - expect(result).toBe(true); 358 - }); 359 - 360 - it("insertPreludes skips prelude with no html and no uri", async () => { 361 - const result = await testWeb(async () => { 362 - const { insertPreludes } = await import("~/common/facets/prelude.js"); 363 - const container = document.createElement("div"); 364 - document.body.append(container); 365 - 366 - await insertPreludes( 367 - [{ 368 - $type: "sh.diffuse.output.facet", 369 - id: "1", 370 - name: "Empty", 371 - kind: "prelude", 372 - }], 373 - container, 374 - ); 375 - 376 - return container.childNodes.length; 377 - }); 378 - 379 - expect(result).toBe(0); 380 - }); 381 - });
-94
tests/common/loader/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/loader", () => { 7 - describe("renderError", () => { 8 - it("sets innerHTML on the container", async () => { 9 - const result = await testWeb(async () => { 10 - const { renderError } = await import("~/common/loader.js"); 11 - const container = document.createElement("div"); 12 - renderError(container, "Something went wrong"); 13 - return container.innerHTML.length > 0; 14 - }); 15 - expect(result).toBe(true); 16 - }); 17 - 18 - it("includes the error message in the output", async () => { 19 - const result = await testWeb(async () => { 20 - const { renderError } = await import("~/common/loader.js"); 21 - const container = document.createElement("div"); 22 - renderError(container, "Track not found"); 23 - return container.innerHTML; 24 - }); 25 - expect(result).toContain("Track not found"); 26 - }); 27 - 28 - it("does not throw when options.throw is false", async () => { 29 - const result = await testWeb(async () => { 30 - const { renderError } = await import("~/common/loader.js"); 31 - const container = document.createElement("div"); 32 - try { 33 - renderError(container, "oops", { throw: false }); 34 - return "no-throw"; 35 - } catch { 36 - return "threw"; 37 - } 38 - }); 39 - expect(result).toBe("no-throw"); 40 - }); 41 - 42 - it("throws when options.throw is true", async () => { 43 - const result = await testWeb(async () => { 44 - const { renderError } = await import("~/common/loader.js"); 45 - const container = document.createElement("div"); 46 - try { 47 - renderError(container, "fatal error", { throw: true }); 48 - return "no-throw"; 49 - } catch (e) { 50 - return (e as Error).message; 51 - } 52 - }); 53 - expect(result).toBe("fatal error"); 54 - }); 55 - 56 - it("throws the provided context error", async () => { 57 - const result = await testWeb(async () => { 58 - const { renderError } = await import("~/common/loader.js"); 59 - const container = document.createElement("div"); 60 - const ctx = new Error("context error"); 61 - try { 62 - renderError(container, "label", { context: ctx, throw: true }); 63 - return null; 64 - } catch (e) { 65 - return (e as Error).message; 66 - } 67 - }); 68 - expect(result).toBe("context error"); 69 - }); 70 - }); 71 - 72 - describe("ensureHTML", () => { 73 - it("returns item unchanged when html is already set", async () => { 74 - const result = await testWeb(async () => { 75 - const { ensureHTML } = await import("~/common/loader.js"); 76 - const item = { html: "<p>existing</p>", uri: "https://example.com/facet.html" }; 77 - const returned = await ensureHTML(item); 78 - return { sameRef: returned === item, html: returned.html }; 79 - }); 80 - expect(result.sameRef).toBe(true); 81 - expect(result.html).toBe("<p>existing</p>"); 82 - }); 83 - 84 - it("returns item unchanged when uri is absent", async () => { 85 - const result = await testWeb(async () => { 86 - const { ensureHTML } = await import("~/common/loader.js"); 87 - const item = { html: undefined, uri: undefined }; 88 - const returned = await ensureHTML(item); 89 - return returned.html ?? null; 90 - }); 91 - expect(result).toBe(null); 92 - }); 93 - }); 94 - });
-42
tests/common/output/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/output", () => { 7 - describe("data", () => { 8 - it("resolves immediately when collection is already loaded", async () => { 9 - const result = await testWeb(async () => { 10 - const { data } = await import("~/common/output.js"); 11 - const { signal } = await import("~/common/signal.js"); 12 - 13 - const col = signal<{ state: "loading" } | { state: "loaded"; data: string[] }>( 14 - { state: "loaded", data: ["a", "b"] }, 15 - ); 16 - 17 - return data({ collection: col.get }); 18 - }); 19 - expect(result).toEqual(["a", "b"]); 20 - }); 21 - 22 - it("waits for collection to become loaded", async () => { 23 - const result = await testWeb(async () => { 24 - const { data } = await import("~/common/output.js"); 25 - const { signal } = await import("~/common/signal.js"); 26 - 27 - const col = signal<{ state: "loading" } | { state: "loaded"; data: number[] }>( 28 - { state: "loading" }, 29 - ); 30 - 31 - const promise = data({ collection: col.get }); 32 - 33 - // Transition to loaded after a microtask 34 - await Promise.resolve(); 35 - col.set({ state: "loaded", data: [1, 2, 3] }); 36 - 37 - return promise; 38 - }); 39 - expect(result).toEqual([1, 2, 3]); 40 - }); 41 - }); 42 - });
-310
tests/common/playlist/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - // Minimal test fixtures 7 - const trackA = { 8 - $type: "sh.diffuse.output.track", 9 - id: "track-a", 10 - uri: "http://example.com/a.mp3", 11 - tags: { artist: "Artist A", title: "Song A" }, 12 - }; 13 - 14 - const trackB = { 15 - $type: "sh.diffuse.output.track", 16 - id: "track-b", 17 - uri: "http://example.com/b.mp3", 18 - tags: { artist: "Artist B", title: "Song B" }, 19 - }; 20 - 21 - describe("common/playlist", () => { 22 - describe("match", () => { 23 - it("matches a track that satisfies all criteria", async () => { 24 - const result = await testWeb(async () => { 25 - const { match } = await import("~/common/playlist.js"); 26 - const track = { 27 - $type: "sh.diffuse.output.track", 28 - id: "t", 29 - uri: "http://x.com/t.mp3", 30 - tags: { artist: "Artist A", title: "Song A" }, 31 - }; 32 - const item = { 33 - $type: "sh.diffuse.output.playlistItem", 34 - id: "i", 35 - playlist: "Favourites", 36 - criteria: [ 37 - { field: "tags.artist", value: "Artist A" }, 38 - { field: "tags.title", value: "Song A" }, 39 - ], 40 - }; 41 - return match(track as never, item as never); 42 - }); 43 - expect(result).toBe(true); 44 - }); 45 - 46 - it("does not match when one criterion fails", async () => { 47 - const result = await testWeb(async () => { 48 - const { match } = await import("~/common/playlist.js"); 49 - const track = { 50 - $type: "sh.diffuse.output.track", 51 - id: "t", 52 - uri: "http://x.com/t.mp3", 53 - tags: { artist: "Artist A", title: "Wrong Title" }, 54 - }; 55 - const item = { 56 - $type: "sh.diffuse.output.playlistItem", 57 - id: "i", 58 - playlist: "Favourites", 59 - criteria: [ 60 - { field: "tags.artist", value: "Artist A" }, 61 - { field: "tags.title", value: "Song A" }, 62 - ], 63 - }; 64 - return match(track as never, item as never); 65 - }); 66 - expect(result).toBe(false); 67 - }); 68 - 69 - it("applies transformations before comparing", async () => { 70 - const result = await testWeb(async () => { 71 - const { match } = await import("~/common/playlist.js"); 72 - const track = { 73 - $type: "sh.diffuse.output.track", 74 - id: "t", 75 - uri: "http://x.com/t.mp3", 76 - tags: { artist: "Artist A" }, 77 - }; 78 - const item = { 79 - $type: "sh.diffuse.output.playlistItem", 80 - id: "i", 81 - playlist: "Favourites", 82 - criteria: [ 83 - { field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }, 84 - ], 85 - }; 86 - return match(track as never, item as never); 87 - }); 88 - expect(result).toBe(true); 89 - }); 90 - 91 - it("does not match when transformations result in inequality", async () => { 92 - const result = await testWeb(async () => { 93 - const { match } = await import("~/common/playlist.js"); 94 - const track = { 95 - $type: "sh.diffuse.output.track", 96 - id: "t", 97 - uri: "http://x.com/t.mp3", 98 - tags: { artist: "Artist A" }, 99 - }; 100 - const item = { 101 - $type: "sh.diffuse.output.playlistItem", 102 - id: "i", 103 - playlist: "Favourites", 104 - criteria: [ 105 - { field: "tags.artist", value: "Artist B", transformations: ["toLowerCase"] }, 106 - ], 107 - }; 108 - return match(track as never, item as never); 109 - }); 110 - expect(result).toBe(false); 111 - }); 112 - }); 113 - 114 - describe("gather", () => { 115 - it("groups items by playlist name", async () => { 116 - const result = await testWeb(async () => { 117 - const { gather } = await import("~/common/playlist.js"); 118 - const items = [ 119 - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 120 - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, 121 - { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 122 - ] as never[]; 123 - const map = gather(items); 124 - return { rockCount: map.get("Rock")!.items.length, popCount: map.get("Pop")!.items.length }; 125 - }); 126 - expect(result.rockCount).toBe(2); 127 - expect(result.popCount).toBe(1); 128 - }); 129 - 130 - it("marks playlist as unordered when all items lack positionedAfter", async () => { 131 - const result = await testWeb(async () => { 132 - const { gather } = await import("~/common/playlist.js"); 133 - const items = [ 134 - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, 135 - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, 136 - ] as never[]; 137 - const map = gather(items); 138 - return map.get("Mix")!.unordered; 139 - }); 140 - expect(result).toBe(true); 141 - }); 142 - 143 - it("marks playlist as ordered when any item has positionedAfter", async () => { 144 - const result = await testWeb(async () => { 145 - const { gather } = await import("~/common/playlist.js"); 146 - const items = [ 147 - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, 148 - { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, 149 - ] as never[]; 150 - const map = gather(items); 151 - return map.get("Mix")!.unordered; 152 - }); 153 - expect(result).toBe(false); 154 - }); 155 - 156 - it("preserves playlist name", async () => { 157 - const result = await testWeb(async () => { 158 - const { gather } = await import("~/common/playlist.js"); 159 - const items = [ 160 - { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "My Playlist", criteria: [] }, 161 - ] as never[]; 162 - const map = gather(items); 163 - return map.get("My Playlist")!.name; 164 - }); 165 - expect(result).toBe("My Playlist"); 166 - }); 167 - }); 168 - 169 - describe("sort", () => { 170 - it("returns single-item array unchanged", async () => { 171 - const result = await testWeb(async () => { 172 - const { sort } = await import("~/common/playlist.js"); 173 - const items = [ 174 - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 175 - ] as never[]; 176 - return sort(items).map((i: { id: string }) => i.id); 177 - }); 178 - expect(result).toEqual(["a"]); 179 - }); 180 - 181 - it("sorts a simple linked list in order", async () => { 182 - const result = await testWeb(async () => { 183 - const { sort } = await import("~/common/playlist.js"); 184 - const items = [ 185 - { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, 186 - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 187 - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 188 - ] as never[]; 189 - return sort(items).map((i: { id: string }) => i.id); 190 - }); 191 - expect(result).toEqual(["a", "b", "c"]); 192 - }); 193 - 194 - it("places head items (positionedAfter null) first", async () => { 195 - const result = await testWeb(async () => { 196 - const { sort } = await import("~/common/playlist.js"); 197 - const items = [ 198 - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 199 - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 200 - ] as never[]; 201 - return sort(items).map((i: { id: string }) => i.id); 202 - }); 203 - expect(result[0]).toBe("a"); 204 - }); 205 - 206 - it("appends unreachable items at the end", async () => { 207 - const result = await testWeb(async () => { 208 - const { sort } = await import("~/common/playlist.js"); 209 - const items = [ 210 - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 211 - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 212 - { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, 213 - ] as never[]; 214 - const sorted = sort(items).map((i: { id: string }) => i.id); 215 - return sorted[sorted.length - 1]; 216 - }); 217 - expect(result).toBe("orphan"); 218 - }); 219 - 220 - it("sorts multiple heads by updatedAt ascending", async () => { 221 - const result = await testWeb(async () => { 222 - const { sort } = await import("~/common/playlist.js"); 223 - const items = [ 224 - { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" }, 225 - { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" }, 226 - ] as never[]; 227 - return sort(items).map((i: { id: string }) => i.id); 228 - }); 229 - expect(result[0]).toBe("a"); 230 - expect(result[1]).toBe("b"); 231 - }); 232 - }); 233 - 234 - describe("filterByPlaylist", () => { 235 - it("returns tracks matching any playlist item criteria", async () => { 236 - const result = await testWeb(async () => { 237 - const { filterByPlaylist } = await import("~/common/playlist.js"); 238 - const tracks = [ 239 - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, 240 - { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, 241 - ] as never[]; 242 - const items = [ 243 - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, 244 - ] as never[]; 245 - return filterByPlaylist(tracks, items).map((t: { id: string }) => t.id); 246 - }); 247 - expect(result).toEqual(["a"]); 248 - }); 249 - 250 - it("returns empty array when no tracks match", async () => { 251 - const result = await testWeb(async () => { 252 - const { filterByPlaylist } = await import("~/common/playlist.js"); 253 - const tracks = [ 254 - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, 255 - ] as never[]; 256 - const items = [ 257 - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, 258 - ] as never[]; 259 - return filterByPlaylist(tracks, items).length; 260 - }); 261 - expect(result).toBe(0); 262 - }); 263 - 264 - it("applies transformations when matching", async () => { 265 - const result = await testWeb(async () => { 266 - const { filterByPlaylist } = await import("~/common/playlist.js"); 267 - const tracks = [ 268 - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }, 269 - ] as never[]; 270 - const items = [ 271 - { 272 - $type: "sh.diffuse.output.playlistItem", 273 - id: "i1", 274 - playlist: "p", 275 - criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], 276 - }, 277 - ] as never[]; 278 - return filterByPlaylist(tracks, items).length; 279 - }); 280 - expect(result).toBe(1); 281 - }); 282 - 283 - it("returns all tracks when multiple criteria match different tracks", async () => { 284 - const result = await testWeb(async () => { 285 - const { filterByPlaylist } = await import("~/common/playlist.js"); 286 - const tracks = [ 287 - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, 288 - { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B" } }, 289 - ] as never[]; 290 - const items = [ 291 - { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, 292 - { $type: "sh.diffuse.output.playlistItem", id: "i2", playlist: "p", criteria: [{ field: "tags.artist", value: "B" }] }, 293 - ] as never[]; 294 - return filterByPlaylist(tracks, items).length; 295 - }); 296 - expect(result).toBe(2); 297 - }); 298 - 299 - it("returns empty array for empty playlist items", async () => { 300 - const result = await testWeb(async () => { 301 - const { filterByPlaylist } = await import("~/common/playlist.js"); 302 - const tracks = [ 303 - { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, 304 - ] as never[]; 305 - return filterByPlaylist(tracks, []).length; 306 - }); 307 - expect(result).toBe(0); 308 - }); 309 - }); 310 - });
-156
tests/common/signal/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/signal", () => { 7 - describe("signal", () => { 8 - it("get returns the initial value", async () => { 9 - const result = await testWeb(async () => { 10 - const { signal } = await import("~/common/signal.js"); 11 - const s = signal(42); 12 - return s.get(); 13 - }); 14 - expect(result).toBe(42); 15 - }); 16 - 17 - it("value getter returns the initial value", async () => { 18 - const result = await testWeb(async () => { 19 - const { signal } = await import("~/common/signal.js"); 20 - const s = signal("hello"); 21 - return s.value; 22 - }); 23 - expect(result).toBe("hello"); 24 - }); 25 - 26 - it("set updates the value", async () => { 27 - const result = await testWeb(async () => { 28 - const { signal } = await import("~/common/signal.js"); 29 - const s = signal(1); 30 - s.set(99); 31 - return s.get(); 32 - }); 33 - expect(result).toBe(99); 34 - }); 35 - 36 - it("value setter updates the value", async () => { 37 - const result = await testWeb(async () => { 38 - const { signal } = await import("~/common/signal.js"); 39 - const s = signal("a"); 40 - s.value = "b"; 41 - return s.value; 42 - }); 43 - expect(result).toBe("b"); 44 - }); 45 - 46 - it("with compare option skips update when values are equal", async () => { 47 - const result = await testWeb(async () => { 48 - const { signal, effect } = await import("~/common/signal.js"); 49 - let runCount = 0; 50 - const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); 51 - 52 - effect(() => { 53 - s.get(); 54 - runCount++; 55 - }); 56 - 57 - const before = runCount; 58 - s.set({ x: 1 }); // same by compare 59 - return { before, after: runCount }; 60 - }); 61 - expect(result.before).toBe(1); 62 - expect(result.after).toBe(1); // effect not re-run 63 - }); 64 - 65 - it("with compare option triggers update when values differ", async () => { 66 - const result = await testWeb(async () => { 67 - const { signal, effect } = await import("~/common/signal.js"); 68 - let runCount = 0; 69 - const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x }); 70 - 71 - effect(() => { 72 - s.get(); 73 - runCount++; 74 - }); 75 - 76 - const before = runCount; 77 - s.set({ x: 2 }); // different by compare 78 - return { before, after: runCount }; 79 - }); 80 - expect(result.before).toBe(1); 81 - expect(result.after).toBe(2); 82 - }); 83 - }); 84 - 85 - describe("batch", () => { 86 - it("defers effect execution until batch completes", async () => { 87 - const result = await testWeb(async () => { 88 - const { signal, effect, batch } = await import("~/common/signal.js"); 89 - const a = signal(0); 90 - const b = signal(0); 91 - const values: number[] = []; 92 - 93 - effect(() => { 94 - values.push(a.get() + b.get()); 95 - }); 96 - 97 - const before = [...values]; // [0] 98 - batch(() => { 99 - a.set(1); 100 - b.set(2); 101 - }); 102 - 103 - return { before, after: values }; 104 - }); 105 - expect(result.before).toEqual([0]); 106 - expect(result.after).toEqual([0, 3]); // only one update, not two 107 - }); 108 - }); 109 - 110 - describe("untracked", () => { 111 - it("reads signal value without tracking it as a dependency", async () => { 112 - const result = await testWeb(async () => { 113 - const { signal, effect, untracked } = await import( 114 - "~/common/signal.js" 115 - ); 116 - const a = signal(1); 117 - const b = signal(10); 118 - let runCount = 0; 119 - 120 - effect(() => { 121 - a.get(); // tracked 122 - untracked(() => b.get()); // not tracked 123 - runCount++; 124 - }); 125 - 126 - const before = runCount; // 1 127 - b.set(20); // should NOT re-run effect 128 - const afterB = runCount; 129 - a.set(2); // SHOULD re-run effect 130 - return { before, afterB, afterA: runCount }; 131 - }); 132 - expect(result.before).toBe(1); 133 - expect(result.afterB).toBe(1); 134 - expect(result.afterA).toBe(2); 135 - }); 136 - 137 - it("returns the value from the callback", async () => { 138 - const result = await testWeb(async () => { 139 - const { signal, untracked } = await import("~/common/signal.js"); 140 - const s = signal(42); 141 - return untracked(() => s.get()); 142 - }); 143 - expect(result).toBe(42); 144 - }); 145 - }); 146 - 147 - describe("untrackedAsync", () => { 148 - it("returns the resolved value", async () => { 149 - const result = await testWeb(async () => { 150 - const { untrackedAsync } = await import("~/common/signal.js"); 151 - return untrackedAsync(async () => 99); 152 - }); 153 - expect(result).toBe(99); 154 - }); 155 - }); 156 - });
-52
tests/common/temporal/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/temporal", () => { 7 - describe("compareTimestamps", () => { 8 - it("returns 0 for equal timestamps", async () => { 9 - const result = await testWeb(async () => { 10 - const { compareTimestamps } = await import("~/common/temporal.js"); 11 - return compareTimestamps( 12 - "2024-01-01T00:00:00.000Z", 13 - "2024-01-01T00:00:00.000Z", 14 - ); 15 - }); 16 - expect(result).toBe(0); 17 - }); 18 - 19 - it("returns negative when first is earlier", async () => { 20 - const result = await testWeb(async () => { 21 - const { compareTimestamps } = await import("~/common/temporal.js"); 22 - return compareTimestamps( 23 - "2024-01-01T00:00:00.000Z", 24 - "2024-06-01T00:00:00.000Z", 25 - ); 26 - }); 27 - expect(result).toBeLessThan(0); 28 - }); 29 - 30 - it("returns positive when first is later", async () => { 31 - const result = await testWeb(async () => { 32 - const { compareTimestamps } = await import("~/common/temporal.js"); 33 - return compareTimestamps( 34 - "2024-06-01T00:00:00.000Z", 35 - "2024-01-01T00:00:00.000Z", 36 - ); 37 - }); 38 - expect(result).toBeGreaterThan(0); 39 - }); 40 - 41 - it("handles millisecond precision", async () => { 42 - const result = await testWeb(async () => { 43 - const { compareTimestamps } = await import("~/common/temporal.js"); 44 - return compareTimestamps( 45 - "2024-01-01T00:00:00.001Z", 46 - "2024-01-01T00:00:00.000Z", 47 - ); 48 - }); 49 - expect(result).toBeGreaterThan(0); 50 - }); 51 - }); 52 - });
-76
tests/common/track/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/track", () => { 7 - describe("trackURIBase", () => { 8 - it("strips path from URI", async () => { 9 - const result = await testWeb(async () => { 10 - const { trackURIBase } = await import("~/common/track.js"); 11 - return trackURIBase("https://example.com/music/track.mp3"); 12 - }); 13 - expect(result).toBe("https://example.com/"); 14 - }); 15 - 16 - it("strips query string from URI", async () => { 17 - const result = await testWeb(async () => { 18 - const { trackURIBase } = await import("~/common/track.js"); 19 - return trackURIBase("https://example.com/track.mp3?token=abc"); 20 - }); 21 - expect(result).toBe("https://example.com/"); 22 - }); 23 - 24 - it("handles URIs with no path", async () => { 25 - const result = await testWeb(async () => { 26 - const { trackURIBase } = await import("~/common/track.js"); 27 - return trackURIBase("https://example.com"); 28 - }); 29 - expect(result).toBe("https://example.com/"); 30 - }); 31 - 32 - it("preserves scheme and host", async () => { 33 - const result = await testWeb(async () => { 34 - const { trackURIBase } = await import("~/common/track.js"); 35 - return trackURIBase("s3://my-bucket/path/to/track.flac"); 36 - }); 37 - expect(result).toBe("s3://my-bucket"); 38 - }); 39 - }); 40 - 41 - describe("uniqueTrackURIs", () => { 42 - it("returns a set of base URIs", async () => { 43 - const result = await testWeb(async () => { 44 - const { uniqueTrackURIs } = await import("~/common/track.js"); 45 - const tracks = [ 46 - { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, 47 - { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, 48 - ] as never[]; 49 - const set = uniqueTrackURIs(tracks); 50 - return [...set]; 51 - }); 52 - expect(result).toEqual(["https://example.com/"]); 53 - }); 54 - 55 - it("deduplicates tracks from the same source", async () => { 56 - const result = await testWeb(async () => { 57 - const { uniqueTrackURIs } = await import("~/common/track.js"); 58 - const tracks = [ 59 - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 60 - { $type: "sh.diffuse.output.track", id: "2", uri: "https://a.com/2.mp3" }, 61 - { $type: "sh.diffuse.output.track", id: "3", uri: "https://b.com/3.mp3" }, 62 - ] as never[]; 63 - return uniqueTrackURIs(tracks).size; 64 - }); 65 - expect(result).toBe(2); 66 - }); 67 - 68 - it("returns empty set for empty input", async () => { 69 - const result = await testWeb(async () => { 70 - const { uniqueTrackURIs } = await import("~/common/track.js"); 71 - return uniqueTrackURIs([]).size; 72 - }); 73 - expect(result).toBe(0); 74 - }); 75 - }); 76 - });
-313
tests/common/utils/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("common/utils", () => { 7 - describe("arrayShuffle", () => { 8 - it("returns empty array for empty input", async () => { 9 - const result = await testWeb(async () => { 10 - const { arrayShuffle } = await import("~/common/utils.js"); 11 - return arrayShuffle([]); 12 - }); 13 - expect(result).toEqual([]); 14 - }); 15 - 16 - it("returns array with same elements", async () => { 17 - const result = await testWeb(async () => { 18 - const { arrayShuffle } = await import("~/common/utils.js"); 19 - return arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b); 20 - }); 21 - expect(result).toEqual([1, 2, 3, 4, 5]); 22 - }); 23 - 24 - it("does not mutate the original array", async () => { 25 - const result = await testWeb(async () => { 26 - const { arrayShuffle } = await import("~/common/utils.js"); 27 - const original = [1, 2, 3]; 28 - arrayShuffle(original); 29 - return original; 30 - }); 31 - expect(result).toEqual([1, 2, 3]); 32 - }); 33 - 34 - it("returns a single-element array unchanged", async () => { 35 - const result = await testWeb(async () => { 36 - const { arrayShuffle } = await import("~/common/utils.js"); 37 - return arrayShuffle([42]); 38 - }); 39 - expect(result).toEqual([42]); 40 - }); 41 - }); 42 - 43 - describe("boolAttr", () => { 44 - it("returns true for empty string (present attribute)", async () => { 45 - const result = await testWeb(async () => { 46 - const { boolAttr } = await import("~/common/utils.js"); 47 - return boolAttr(""); 48 - }); 49 - expect(result).toBe(true); 50 - }); 51 - 52 - it("returns false for null", async () => { 53 - const result = await testWeb(async () => { 54 - const { boolAttr } = await import("~/common/utils.js"); 55 - return boolAttr(null); 56 - }); 57 - expect(result).toBe(false); 58 - }); 59 - 60 - it("returns false for undefined", async () => { 61 - const result = await testWeb(async () => { 62 - const { boolAttr } = await import("~/common/utils.js"); 63 - return boolAttr(undefined); 64 - }); 65 - expect(result).toBe(false); 66 - }); 67 - 68 - it("returns false for non-empty string", async () => { 69 - const result = await testWeb(async () => { 70 - const { boolAttr } = await import("~/common/utils.js"); 71 - return boolAttr("true"); 72 - }); 73 - expect(result).toBe(false); 74 - }); 75 - }); 76 - 77 - describe("groupTracksPerScheme", () => { 78 - it("groups tracks by URI scheme", async () => { 79 - const result = await testWeb(async () => { 80 - const { groupTracksPerScheme } = await import("~/common/utils.js"); 81 - const tracks = [ 82 - { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }, 83 - { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, 84 - { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, 85 - ] as never[]; 86 - const groups = groupTracksPerScheme(tracks); 87 - return { httpCount: groups["http"].length, s3Count: groups["s3"].length }; 88 - }); 89 - expect(result.httpCount).toBe(2); 90 - expect(result.s3Count).toBe(1); 91 - }); 92 - 93 - it("merges into provided initial object", async () => { 94 - const result = await testWeb(async () => { 95 - const { groupTracksPerScheme } = await import("~/common/utils.js"); 96 - const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] } as never; 97 - const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }] as never[]; 98 - const groups = groupTracksPerScheme(tracks, initial); 99 - return groups["http"].length; 100 - }); 101 - expect(result).toBe(2); 102 - }); 103 - }); 104 - 105 - describe("groupUrisPerScheme", () => { 106 - it("groups URIs by scheme", async () => { 107 - const result = await testWeb(async () => { 108 - const { groupUrisPerScheme } = await import("~/common/utils.js"); 109 - const groups = groupUrisPerScheme([ 110 - "http://a.com/track.mp3", 111 - "s3://bucket/track.flac", 112 - "http://b.com/track.mp3", 113 - ]); 114 - return { httpCount: groups["http"].length, s3Count: groups["s3"].length }; 115 - }); 116 - expect(result.httpCount).toBe(2); 117 - expect(result.s3Count).toBe(1); 118 - }); 119 - 120 - it("returns empty object for empty input", async () => { 121 - const result = await testWeb(async () => { 122 - const { groupUrisPerScheme } = await import("~/common/utils.js"); 123 - return Object.keys(groupUrisPerScheme([])).length; 124 - }); 125 - expect(result).toBe(0); 126 - }); 127 - }); 128 - 129 - describe("isPrimitive", () => { 130 - it("returns true for number", async () => { 131 - const result = await testWeb(async () => { 132 - const { isPrimitive } = await import("~/common/utils.js"); 133 - return isPrimitive(42); 134 - }); 135 - expect(result).toBe(true); 136 - }); 137 - 138 - it("returns true for string", async () => { 139 - const result = await testWeb(async () => { 140 - const { isPrimitive } = await import("~/common/utils.js"); 141 - return isPrimitive("hello"); 142 - }); 143 - expect(result).toBe(true); 144 - }); 145 - 146 - it("returns true for boolean", async () => { 147 - const result = await testWeb(async () => { 148 - const { isPrimitive } = await import("~/common/utils.js"); 149 - return isPrimitive(true); 150 - }); 151 - expect(result).toBe(true); 152 - }); 153 - 154 - it("returns true for null", async () => { 155 - const result = await testWeb(async () => { 156 - const { isPrimitive } = await import("~/common/utils.js"); 157 - return isPrimitive(null); 158 - }); 159 - expect(result).toBe(true); 160 - }); 161 - 162 - it("returns false for object", async () => { 163 - const result = await testWeb(async () => { 164 - const { isPrimitive } = await import("~/common/utils.js"); 165 - return isPrimitive({ a: 1 }); 166 - }); 167 - expect(result).toBe(false); 168 - }); 169 - 170 - it("returns false for array", async () => { 171 - const result = await testWeb(async () => { 172 - const { isPrimitive } = await import("~/common/utils.js"); 173 - return isPrimitive([1, 2]); 174 - }); 175 - expect(result).toBe(false); 176 - }); 177 - }); 178 - 179 - describe("jsonEncode / jsonDecode", () => { 180 - it("round-trips a plain object", async () => { 181 - const result = await testWeb(async () => { 182 - const { jsonEncode, jsonDecode } = await import("~/common/utils.js"); 183 - const original = { a: 1, b: "hello", c: [1, 2, 3] }; 184 - return jsonDecode(jsonEncode(original)); 185 - }); 186 - expect(result).toEqual({ a: 1, b: "hello", c: [1, 2, 3] }); 187 - }); 188 - 189 - it("jsonEncode returns a Uint8Array", async () => { 190 - const result = await testWeb(async () => { 191 - const { jsonEncode } = await import("~/common/utils.js"); 192 - return jsonEncode({ x: 1 }) instanceof Uint8Array; 193 - }); 194 - expect(result).toBe(true); 195 - }); 196 - }); 197 - 198 - describe("hash", () => { 199 - it("returns a string", async () => { 200 - const result = await testWeb(async () => { 201 - const { hash } = await import("~/common/utils.js"); 202 - return typeof hash({ a: 1 }); 203 - }); 204 - expect(result).toBe("string"); 205 - }); 206 - 207 - it("returns the same hash for equal objects", async () => { 208 - const result = await testWeb(async () => { 209 - const { hash } = await import("~/common/utils.js"); 210 - return hash({ a: 1, b: 2 }) === hash({ a: 1, b: 2 }); 211 - }); 212 - expect(result).toBe(true); 213 - }); 214 - 215 - it("returns different hashes for different objects", async () => { 216 - const result = await testWeb(async () => { 217 - const { hash } = await import("~/common/utils.js"); 218 - return hash({ a: 1 }) === hash({ a: 2 }); 219 - }); 220 - expect(result).toBe(false); 221 - }); 222 - }); 223 - 224 - describe("removeUndefinedValuesFromRecord", () => { 225 - it("removes keys with undefined values", async () => { 226 - const result = await testWeb(async () => { 227 - const { removeUndefinedValuesFromRecord } = await import( 228 - "~/common/utils.js" 229 - ); 230 - return removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); 231 - }); 232 - expect(result).toEqual({ a: 1, c: "x" }); 233 - }); 234 - 235 - it("does not mutate the original record", async () => { 236 - const result = await testWeb(async () => { 237 - const { removeUndefinedValuesFromRecord } = await import( 238 - "~/common/utils.js" 239 - ); 240 - const original = { a: 1, b: undefined }; 241 - removeUndefinedValuesFromRecord(original); 242 - return "b" in original; 243 - }); 244 - expect(result).toBe(true); 245 - }); 246 - 247 - it("returns record unchanged when no undefined values", async () => { 248 - const result = await testWeb(async () => { 249 - const { removeUndefinedValuesFromRecord } = await import( 250 - "~/common/utils.js" 251 - ); 252 - return removeUndefinedValuesFromRecord({ a: 1, b: 2 }); 253 - }); 254 - expect(result).toEqual({ a: 1, b: 2 }); 255 - }); 256 - }); 257 - 258 - describe("recursivelyCloneRecords", () => { 259 - it("shallow-clones a flat record", async () => { 260 - const result = await testWeb(async () => { 261 - const { recursivelyCloneRecords } = await import("~/common/utils.js"); 262 - const original = { a: 1, b: "x" }; 263 - const clone = recursivelyCloneRecords(original); 264 - return clone !== original && clone.a === 1 && clone.b === "x"; 265 - }); 266 - expect(result).toBe(true); 267 - }); 268 - 269 - it("deep-clones nested objects", async () => { 270 - const result = await testWeb(async () => { 271 - const { recursivelyCloneRecords } = await import("~/common/utils.js"); 272 - const original = { outer: { inner: 42 } }; 273 - const clone = recursivelyCloneRecords(original); 274 - return clone.outer !== original.outer && clone.outer.inner === 42; 275 - }); 276 - expect(result).toBe(true); 277 - }); 278 - }); 279 - 280 - describe("safeDecodeURIComponent", () => { 281 - it("decodes standard percent-encoded characters", async () => { 282 - const result = await testWeb(async () => { 283 - const { safeDecodeURIComponent } = await import("~/common/utils.js"); 284 - return safeDecodeURIComponent("hello%20world"); 285 - }); 286 - expect(result).toBe("hello world"); 287 - }); 288 - 289 - it("decodes %u unicode escapes", async () => { 290 - const result = await testWeb(async () => { 291 - const { safeDecodeURIComponent } = await import("~/common/utils.js"); 292 - return safeDecodeURIComponent("%u0041"); // 'A' 293 - }); 294 - expect(result).toBe("A"); 295 - }); 296 - 297 - it("leaves plain strings unchanged", async () => { 298 - const result = await testWeb(async () => { 299 - const { safeDecodeURIComponent } = await import("~/common/utils.js"); 300 - return safeDecodeURIComponent("plain-string"); 301 - }); 302 - expect(result).toBe("plain-string"); 303 - }); 304 - 305 - it("decodes mixed encoded and plain text", async () => { 306 - const result = await testWeb(async () => { 307 - const { safeDecodeURIComponent } = await import("~/common/utils.js"); 308 - return safeDecodeURIComponent("hello%2Fworld"); 309 - }); 310 - expect(result).toBe("hello/world"); 311 - }); 312 - }); 313 - });
-131
tests/components/input/common/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - import type { Track } from "~/definitions/types.d.ts"; 6 - 7 - describe("components/input/common", () => { 8 - it("isAudioFile returns truthy for audio extensions", async () => { 9 - const results = await testWeb(async () => { 10 - const mod = await import("~/components/input/common.js"); 11 - return [ 12 - !!mod.isAudioFile("track.mp3"), 13 - !!mod.isAudioFile("track.flac"), 14 - !!mod.isAudioFile("track.ogg"), 15 - !!mod.isAudioFile("track.opus"), 16 - !!mod.isAudioFile("track.wav"), 17 - !!mod.isAudioFile("track.m4a"), 18 - !!mod.isAudioFile("track.webm"), 19 - ]; 20 - }); 21 - 22 - expect(results).toEqual([true, true, true, true, true, true, true]); 23 - }); 24 - 25 - it("isAudioFile returns falsy for non-audio extensions", async () => { 26 - const results = await testWeb(async () => { 27 - const mod = await import("~/components/input/common.js"); 28 - return [ 29 - !!mod.isAudioFile("track.txt"), 30 - !!mod.isAudioFile("track.jpg"), 31 - !!mod.isAudioFile("track.pdf"), 32 - !!mod.isAudioFile("track"), 33 - ]; 34 - }); 35 - 36 - expect(results).toEqual([false, false, false, false]); 37 - }); 38 - 39 - it("groupKey returns scheme://groupId", async () => { 40 - const result = await testWeb(async () => { 41 - const mod = await import("~/components/input/common.js"); 42 - return mod.groupKey("https", "example.com"); 43 - }); 44 - 45 - expect(result).toBe("https://example.com"); 46 - }); 47 - 48 - it("detach with matching scheme removes all tracks", async () => { 49 - const result = await testWeb(async () => { 50 - const mod = await import("~/components/input/common.js"); 51 - 52 - const tracks: Track[] = [ 53 - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 54 - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 55 - ]; 56 - 57 - return mod.detach({ 58 - fileUriOrScheme: "https", 59 - inputScheme: "https", 60 - handleFileUri: () => [], 61 - tracks, 62 - }); 63 - }); 64 - 65 - expect(result).toEqual([]); 66 - }); 67 - 68 - it("detach with non-matching scheme returns all tracks unchanged", async () => { 69 - const result = await testWeb(async () => { 70 - const mod = await import("~/components/input/common.js"); 71 - 72 - const tracks: Track[] = [ 73 - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 74 - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 75 - ]; 76 - 77 - return mod.detach({ 78 - fileUriOrScheme: "ftp", 79 - inputScheme: "https", 80 - handleFileUri: () => [], 81 - tracks, 82 - }); 83 - }); 84 - 85 - expect(result.map((t: Track) => t.id)).toEqual(["1", "2"]); 86 - }); 87 - 88 - it("detach delegates to handleFileUri when a full URI is given", async () => { 89 - const result = await testWeb(async () => { 90 - const mod = await import("~/components/input/common.js"); 91 - 92 - const tracks: Track[] = [ 93 - { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, 94 - { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, 95 - ]; 96 - 97 - return mod.detach({ 98 - fileUriOrScheme: "https://a.com/1.mp3", 99 - inputScheme: "https", 100 - handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), 101 - tracks, 102 - }); 103 - }); 104 - 105 - expect(result.map((t: Track) => t.id)).toEqual(["2"]); 106 - }); 107 - 108 - it("cachedConsult caches and returns result", async () => { 109 - const result = await testWeb(async () => { 110 - const mod = await import("~/components/input/common.js"); 111 - 112 - let callCount = 0; 113 - const cached = mod.cachedConsult( 114 - async (_uri: string) => { 115 - callCount++; 116 - return true; 117 - }, 118 - (uri: string) => uri, 119 - ); 120 - 121 - const r1 = await cached("https://example.com/stream"); 122 - const r2 = await cached("https://example.com/stream"); 123 - 124 - return { r1, r2, callCount }; 125 - }); 126 - 127 - expect(result.r1).toBe(true); 128 - expect(result.r2).toBe(true); 129 - expect(result.callCount).toBe(1); 130 - }); 131 - });