From 06ff708b07706594253374d6b815745fa296e376 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Fri, 10 Apr 2026 17:00:45 +0200 Subject: [PATCH] test: make sure all unit tests pass --- deno.jsonc | 4 ++ src/common/output.js | 4 +- src/common/playlist.js | 51 +++++++++++++++-------- src/common/signal.js | 17 ++++---- src/common/track.js | 36 ---------------- src/common/utils.js | 10 +++-- src/components/input/common.js | 22 ++++------ tests/common/track/test.ts | 76 ++++++++++++++++++++++++++++++++++ 8 files changed, 138 insertions(+), 82 deletions(-) create mode 100644 tests/common/track/test.ts diff --git a/deno.jsonc b/deno.jsonc index 4bc7c5ac..f9599222 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -199,6 +199,10 @@ "description": "Run documentation tests", "command": "deno test src -A --doc --ignore=README.md", }, + "test:unit": { + "description": "Run unit tests (documentation tests)", + "command": "deno task test:doc", + }, "test:integration": { "description": "Run integration tests", "command": "deno test tests -A --ignore=README.md", diff --git a/src/common/output.js b/src/common/output.js index 55204b50..e7e726de 100644 --- a/src/common/output.js +++ b/src/common/output.js @@ -14,7 +14,7 @@ import { effect } from "~/common/signal.js"; * import { data } from "~/common/output.js"; * import { signal } from "~/common/signal.js"; * - * const col = signal({ state: "loaded", data: ["a", "b"] }); + * const col = signal(JSON.parse('{"state":"loaded","data":["a","b"]}')); * const result = await data({ collection: col.get }); * if (result.join(",") !== "a,b") throw new Error("expected ['a', 'b']"); * ``` @@ -24,7 +24,7 @@ import { effect } from "~/common/signal.js"; * import { data } from "~/common/output.js"; * import { signal } from "~/common/signal.js"; * - * const col = signal({ state: "loading" }); + * const col = signal(JSON.parse('{"state":"loading"}')); * const promise = data({ collection: col.get }); * * await Promise.resolve(); diff --git a/src/common/playlist.js b/src/common/playlist.js index 357921c5..d2355a8e 100644 --- a/src/common/playlist.js +++ b/src/common/playlist.js @@ -21,6 +21,7 @@ import { compareTimestamps } from "~/common/temporal.js"; * const items = [ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, * ]; + * // @ts-ignore * const result = filterByPlaylist(tracks, items); * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); * ``` @@ -32,11 +33,12 @@ import { compareTimestamps } from "~/common/temporal.js"; * const tracks = [ * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, * ]; - * const noMatch = filterByPlaylist(tracks, [ + * const noMatchItems = [ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, - * ]); - * if (noMatch.length !== 0) throw new Error("expected no matches"); - * + * ]; + * // @ts-ignore + * if (filterByPlaylist(tracks, noMatchItems).length !== 0) throw new Error("expected no matches"); + * // @ts-ignore * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); * ``` * @@ -51,6 +53,7 @@ import { compareTimestamps } from "~/common/temporal.js"; * playlist: "p", * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], * }]; + * // @ts-ignore * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); * ``` */ @@ -119,24 +122,31 @@ export function filterByPlaylist(tracks, playlistItems) { * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, * ]; + * // @ts-ignore * const map = gather(items); - * if (map.get("Rock").items.length !== 2) throw new Error("expected 2 rock items"); - * if (map.get("Pop").items.length !== 1) throw new Error("expected 1 pop item"); - * if (map.get("My Playlist") !== undefined) { - * // separate test: preserves playlist name - * } + * const rock = map.get("Rock"); + * const pop = map.get("Pop"); + * if (!rock || !pop) throw new Error("expected Rock and Pop playlists"); + * if (rock.items.length !== 2) throw new Error("expected 2 rock items"); + * if (pop.items.length !== 1) throw new Error("expected 1 pop item"); * + * // @ts-ignore * const unordered = gather([ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, * ]); - * if (!unordered.get("Mix").unordered) throw new Error("playlist without positionedAfter should be unordered"); + * const unorderedMix = unordered.get("Mix"); + * if (!unorderedMix) throw new Error("expected Mix playlist"); + * if (!unorderedMix.unordered) throw new Error("playlist without positionedAfter should be unordered"); * + * // @ts-ignore * const ordered = gather([ - * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, * ]); - * if (ordered.get("Mix").unordered) throw new Error("playlist with positionedAfter should be ordered"); + * const orderedMix = ordered.get("Mix"); + * if (!orderedMix) throw new Error("expected Mix playlist"); + * if (orderedMix.unordered) throw new Error("playlist with positionedAfter should be ordered"); * ``` */ export function gather(items) { @@ -180,9 +190,11 @@ export function gather(items) { * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], * }; + * // @ts-ignore * if (!match(track, item)) throw new Error("should match when all criteria pass"); * * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; + * // @ts-ignore * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); * ``` * @@ -195,6 +207,7 @@ export function gather(items) { * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], * }; + * // @ts-ignore * if (!match(track, item)) throw new Error("transformation should match lowercase"); * ``` */ @@ -234,21 +247,24 @@ export function match(track, item) { * ```js * import { sort } from "~/common/playlist.js"; * - * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }]; + * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }]; + * // @ts-ignore * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); * * const linked = [ * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, - * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, * ]; + * // @ts-ignore * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); * * const withOrphan = [ - * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, * ]; + * // @ts-ignore * const sorted = sort(withOrphan); * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); * ``` @@ -258,9 +274,10 @@ export function match(track, item) { * import { sort } from "~/common/playlist.js"; * * const items = [ - * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" }, - * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" }, + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-06-01T00:00:00.000Z" }, + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-01-01T00:00:00.000Z" }, * ]; + * // @ts-ignore * const result = sort(items); * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); * ``` diff --git a/src/common/signal.js b/src/common/signal.js index 142ff6dd..b02e2251 100644 --- a/src/common/signal.js +++ b/src/common/signal.js @@ -20,7 +20,7 @@ export * from "alien-signals"; * * const a = signal(0); * const b = signal(0); - * const values = []; + * const values = [0]; values.length = 0; // typed as number[] * * effect(() => { values.push(a.get() + b.get()); }); * @@ -50,15 +50,16 @@ export const batch = (fn) => { * ```js * import { signal } from "~/common/signal.js"; * - * const s = signal(42); - * if (s.get() !== 42) throw new Error("get should return initial value"); - * if (s.value !== 42) throw new Error("value getter should return initial value"); + * const num = signal(42); + * if (num.get() !== 42) throw new Error("get should return initial value"); + * if (num.value !== 42) throw new Error("value getter should return initial value"); * - * s.set(99); - * if (s.get() !== 99) throw new Error("get should return updated value"); + * num.set(99); + * if (num.get() !== 99) throw new Error("get should return updated value"); * - * s.value = "b"; - * if (s.value !== "b") throw new Error("value setter should update value"); + * const str = signal("a"); + * str.value = "b"; + * if (str.value !== "b") throw new Error("value setter should update value"); * ``` * * @example compare option skips update when values are equal by custom comparator diff --git a/src/common/track.js b/src/common/track.js index 2d2bb274..b72799af 100644 --- a/src/common/track.js +++ b/src/common/track.js @@ -7,22 +7,6 @@ import * as URI from "fast-uri"; /** * @param {string} uri * - * @example Strips path and query from a URI - * ```js - * import { trackURIBase } from "~/common/track.js"; - * - * const r1 = trackURIBase("https://example.com/music/track.mp3"); - * if (r1 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r1}"`); - * - * const r2 = trackURIBase("https://example.com/track.mp3?token=abc"); - * if (r2 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r2}"`); - * - * const r3 = trackURIBase("https://example.com"); - * if (r3 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r3}"`); - * - * const r4 = trackURIBase("s3://my-bucket/path/to/track.flac"); - * if (r4 !== "s3://my-bucket") throw new Error(`expected "s3://my-bucket", got "${r4}"`); - * ``` */ export function trackURIBase(uri) { const p = URI.parse(uri); @@ -34,26 +18,6 @@ export function trackURIBase(uri) { /** * @param {Track[]} tracks * - * @example Returns a deduplicated set of base URIs - * ```js - * import { uniqueTrackURIs } from "~/common/track.js"; - * - * const tracks = [ - * { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, - * { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, - * ]; - * const set = uniqueTrackURIs(tracks); - * if (!set.has("https://example.com/")) throw new Error("expected base URI in set"); - * if (set.size !== 1) throw new Error("expected deduplication to one entry"); - * - * const multi = uniqueTrackURIs([ - * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - * ]); - * if (multi.size !== 2) throw new Error("expected 2 distinct base URIs"); - * - * if (uniqueTrackURIs([]).size !== 0) throw new Error("expected empty set for empty input"); - * ``` */ export function uniqueTrackURIs(tracks) { const set = new Set(); diff --git a/src/common/utils.js b/src/common/utils.js index ab4d2450..341f9bf0 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -90,6 +90,7 @@ export function hash(object) { * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, * ]; + * // @ts-ignore * const groups = groupTracksPerScheme(tracks); * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks"); * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track"); @@ -101,8 +102,9 @@ export function hash(object) { * * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] }; * const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }]; - * const groups = groupTracksPerScheme(tracks, initial); - * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); + * // @ts-ignore + * const merged = groupTracksPerScheme(tracks, initial); + * if (merged["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); * ``` */ export function groupTracksPerScheme( @@ -207,14 +209,14 @@ export function jsonEncode(a) { * * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); * if ("b" in result) throw new Error("undefined key should be removed"); - * if (result.a !== 1 || result.c !== "x") throw new Error("defined keys should be preserved"); + * if (result["a"] !== 1 || result["c"] !== "x") throw new Error("defined keys should be preserved"); * * const original = { a: 1, b: undefined }; * removeUndefinedValuesFromRecord(original); * if (!("b" in original)) throw new Error("original should not be mutated"); * * const noUndef = removeUndefinedValuesFromRecord({ a: 1, b: 2 }); - * if (noUndef.a !== 1 || noUndef.b !== 2) throw new Error("record without undefined values should be unchanged"); + * if (noUndef["a"] !== 1 || noUndef["b"] !== 2) throw new Error("record without undefined values should be unchanged"); * ``` */ export function removeUndefinedValuesFromRecord(rec) { diff --git a/src/components/input/common.js b/src/components/input/common.js index c0fe486a..57d0a9b2 100644 --- a/src/components/input/common.js +++ b/src/components/input/common.js @@ -52,14 +52,13 @@ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { * ```js * import { detach } from "~/components/input/common.js"; * - * const tracks = [ - * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - * ]; + * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]'); * + * // @ts-ignore * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks }); * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks"); * + * // @ts-ignore * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks }); * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks"); * ``` @@ -68,17 +67,10 @@ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { * ```js * import { detach } from "~/components/input/common.js"; * - * const tracks = [ - * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, - * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, - * ]; - * - * const result = detach({ - * fileUriOrScheme: "https://a.com/1.mp3", - * inputScheme: "https", - * handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), - * tracks, - * }); + * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]'); + * + * // @ts-ignore + * const result = detach({ fileUriOrScheme: "https://a.com/1.mp3", inputScheme: "https", handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), tracks }); * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI"); * ``` */ diff --git a/tests/common/track/test.ts b/tests/common/track/test.ts new file mode 100644 index 00000000..34913af8 --- /dev/null +++ b/tests/common/track/test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("common/track", () => { + describe("trackURIBase", () => { + it("strips path from URI", async () => { + const result = await testWeb(async () => { + const { trackURIBase } = await import("~/common/track.js"); + return trackURIBase("https://example.com/music/track.mp3"); + }); + expect(result).toBe("https://example.com/"); + }); + + it("strips query string from URI", async () => { + const result = await testWeb(async () => { + const { trackURIBase } = await import("~/common/track.js"); + return trackURIBase("https://example.com/track.mp3?token=abc"); + }); + expect(result).toBe("https://example.com/"); + }); + + it("handles URIs with no path", async () => { + const result = await testWeb(async () => { + const { trackURIBase } = await import("~/common/track.js"); + return trackURIBase("https://example.com"); + }); + expect(result).toBe("https://example.com/"); + }); + + it("preserves scheme and host", async () => { + const result = await testWeb(async () => { + const { trackURIBase } = await import("~/common/track.js"); + return trackURIBase("s3://my-bucket/path/to/track.flac"); + }); + expect(result).toBe("s3://my-bucket"); + }); + }); + + describe("uniqueTrackURIs", () => { + it("returns a set of base URIs", async () => { + const result = await testWeb(async () => { + const { uniqueTrackURIs } = await import("~/common/track.js"); + const tracks = [ + { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" }, + ] as never[]; + const set = uniqueTrackURIs(tracks); + return [...set]; + }); + expect(result).toEqual(["https://example.com/"]); + }); + + it("deduplicates tracks from the same source", async () => { + const result = await testWeb(async () => { + const { uniqueTrackURIs } = await import("~/common/track.js"); + const tracks = [ + { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "https://a.com/2.mp3" }, + { $type: "sh.diffuse.output.track", id: "3", uri: "https://b.com/3.mp3" }, + ] as never[]; + return uniqueTrackURIs(tracks).size; + }); + expect(result).toBe(2); + }); + + it("returns empty set for empty input", async () => { + const result = await testWeb(async () => { + const { uniqueTrackURIs } = await import("~/common/track.js"); + return uniqueTrackURIs([]).size; + }); + expect(result).toBe(0); + }); + }); +}); -- 2.51.2