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: make sure all unit tests pass

+137 -81
+4
deno.jsonc
··· 199 199 "description": "Run documentation tests", 200 200 "command": "deno test src -A --doc --ignore=README.md", 201 201 }, 202 + "test:unit": { 203 + "description": "Run unit tests (documentation tests)", 204 + "command": "deno task test:doc", 205 + }, 202 206 "test:integration": { 203 207 "description": "Run integration tests", 204 208 "command": "deno test tests -A --ignore=README.md",
+2 -2
src/common/output.js
··· 14 14 * import { data } from "~/common/output.js"; 15 15 * import { signal } from "~/common/signal.js"; 16 16 * 17 - * const col = signal({ state: "loaded", data: ["a", "b"] }); 17 + * const col = signal(JSON.parse('{"state":"loaded","data":["a","b"]}')); 18 18 * const result = await data({ collection: col.get }); 19 19 * if (result.join(",") !== "a,b") throw new Error("expected ['a', 'b']"); 20 20 * ``` ··· 24 24 * import { data } from "~/common/output.js"; 25 25 * import { signal } from "~/common/signal.js"; 26 26 * 27 - * const col = signal({ state: "loading" }); 27 + * const col = signal(JSON.parse('{"state":"loading"}')); 28 28 * const promise = data({ collection: col.get }); 29 29 * 30 30 * await Promise.resolve();
+34 -17
src/common/playlist.js
··· 21 21 * const items = [ 22 22 * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, 23 23 * ]; 24 + * // @ts-ignore 24 25 * const result = filterByPlaylist(tracks, items); 25 26 * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); 26 27 * ``` ··· 32 33 * const tracks = [ 33 34 * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, 34 35 * ]; 35 - * const noMatch = filterByPlaylist(tracks, [ 36 + * const noMatchItems = [ 36 37 * { $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 - * 38 + * ]; 39 + * // @ts-ignore 40 + * if (filterByPlaylist(tracks, noMatchItems).length !== 0) throw new Error("expected no matches"); 41 + * // @ts-ignore 40 42 * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); 41 43 * ``` 42 44 * ··· 51 53 * playlist: "p", 52 54 * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], 53 55 * }]; 56 + * // @ts-ignore 54 57 * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); 55 58 * ``` 56 59 */ ··· 119 122 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, 120 123 * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 121 124 * ]; 125 + * // @ts-ignore 122 126 * 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 - * } 127 + * const rock = map.get("Rock"); 128 + * const pop = map.get("Pop"); 129 + * if (!rock || !pop) throw new Error("expected Rock and Pop playlists"); 130 + * if (rock.items.length !== 2) throw new Error("expected 2 rock items"); 131 + * if (pop.items.length !== 1) throw new Error("expected 1 pop item"); 128 132 * 133 + * // @ts-ignore 129 134 * const unordered = gather([ 130 135 * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, 131 136 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, 132 137 * ]); 133 - * if (!unordered.get("Mix").unordered) throw new Error("playlist without positionedAfter should be unordered"); 138 + * const unorderedMix = unordered.get("Mix"); 139 + * if (!unorderedMix) throw new Error("expected Mix playlist"); 140 + * if (!unorderedMix.unordered) throw new Error("playlist without positionedAfter should be unordered"); 134 141 * 142 + * // @ts-ignore 135 143 * const ordered = gather([ 136 - * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null }, 144 + * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: undefined }, 137 145 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, 138 146 * ]); 139 - * if (ordered.get("Mix").unordered) throw new Error("playlist with positionedAfter should be ordered"); 147 + * const orderedMix = ordered.get("Mix"); 148 + * if (!orderedMix) throw new Error("expected Mix playlist"); 149 + * if (orderedMix.unordered) throw new Error("playlist with positionedAfter should be ordered"); 140 150 * ``` 141 151 */ 142 152 export function gather(items) { ··· 180 190 * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 181 191 * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], 182 192 * }; 193 + * // @ts-ignore 183 194 * if (!match(track, item)) throw new Error("should match when all criteria pass"); 184 195 * 185 196 * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; 197 + * // @ts-ignore 186 198 * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); 187 199 * ``` 188 200 * ··· 195 207 * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 196 208 * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], 197 209 * }; 210 + * // @ts-ignore 198 211 * if (!match(track, item)) throw new Error("transformation should match lowercase"); 199 212 * ``` 200 213 */ ··· 234 247 * ```js 235 248 * import { sort } from "~/common/playlist.js"; 236 249 * 237 - * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }]; 250 + * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }]; 251 + * // @ts-ignore 238 252 * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); 239 253 * 240 254 * const linked = [ 241 255 * { $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 }, 256 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, 243 257 * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 244 258 * ]; 259 + * // @ts-ignore 245 260 * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); 246 261 * 247 262 * const withOrphan = [ 248 - * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }, 263 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, 249 264 * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 250 265 * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, 251 266 * ]; 267 + * // @ts-ignore 252 268 * const sorted = sort(withOrphan); 253 269 * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); 254 270 * ``` ··· 258 274 * import { sort } from "~/common/playlist.js"; 259 275 * 260 276 * 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" }, 277 + * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-06-01T00:00:00.000Z" }, 278 + * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-01-01T00:00:00.000Z" }, 263 279 * ]; 280 + * // @ts-ignore 264 281 * const result = sort(items); 265 282 * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); 266 283 * ```
+9 -8
src/common/signal.js
··· 20 20 * 21 21 * const a = signal(0); 22 22 * const b = signal(0); 23 - * const values = []; 23 + * const values = [0]; values.length = 0; // typed as number[] 24 24 * 25 25 * effect(() => { values.push(a.get() + b.get()); }); 26 26 * ··· 50 50 * ```js 51 51 * import { signal } from "~/common/signal.js"; 52 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"); 53 + * const num = signal(42); 54 + * if (num.get() !== 42) throw new Error("get should return initial value"); 55 + * if (num.value !== 42) throw new Error("value getter should return initial value"); 56 56 * 57 - * s.set(99); 58 - * if (s.get() !== 99) throw new Error("get should return updated value"); 57 + * num.set(99); 58 + * if (num.get() !== 99) throw new Error("get should return updated value"); 59 59 * 60 - * s.value = "b"; 61 - * if (s.value !== "b") throw new Error("value setter should update value"); 60 + * const str = signal("a"); 61 + * str.value = "b"; 62 + * if (str.value !== "b") throw new Error("value setter should update value"); 62 63 * ``` 63 64 * 64 65 * @example compare option skips update when values are equal by custom comparator
-36
src/common/track.js
··· 7 7 /** 8 8 * @param {string} uri 9 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 - * ``` 26 10 */ 27 11 export function trackURIBase(uri) { 28 12 const p = URI.parse(uri); ··· 34 18 /** 35 19 * @param {Track[]} tracks 36 20 * 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 - * ``` 57 21 */ 58 22 export function uniqueTrackURIs(tracks) { 59 23 const set = new Set();
+6 -4
src/common/utils.js
··· 90 90 * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, 91 91 * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, 92 92 * ]; 93 + * // @ts-ignore 93 94 * const groups = groupTracksPerScheme(tracks); 94 95 * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks"); 95 96 * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track"); ··· 101 102 * 102 103 * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] }; 103 104 * 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"); 105 + * // @ts-ignore 106 + * const merged = groupTracksPerScheme(tracks, initial); 107 + * if (merged["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); 106 108 * ``` 107 109 */ 108 110 export function groupTracksPerScheme( ··· 207 209 * 208 210 * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); 209 211 * 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"); 212 + * if (result["a"] !== 1 || result["c"] !== "x") throw new Error("defined keys should be preserved"); 211 213 * 212 214 * const original = { a: 1, b: undefined }; 213 215 * removeUndefinedValuesFromRecord(original); 214 216 * if (!("b" in original)) throw new Error("original should not be mutated"); 215 217 * 216 218 * 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"); 219 + * if (noUndef["a"] !== 1 || noUndef["b"] !== 2) throw new Error("record without undefined values should be unchanged"); 218 220 * ``` 219 221 */ 220 222 export function removeUndefinedValuesFromRecord(rec) {
+6 -14
src/components/input/common.js
··· 52 52 * ```js 53 53 * import { detach } from "~/components/input/common.js"; 54 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 - * ]; 55 + * 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"}]'); 59 56 * 57 + * // @ts-ignore 60 58 * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks }); 61 59 * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks"); 62 60 * 61 + * // @ts-ignore 63 62 * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks }); 64 63 * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks"); 65 64 * ``` ··· 68 67 * ```js 69 68 * import { detach } from "~/components/input/common.js"; 70 69 * 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 - * ]; 70 + * 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"}]'); 75 71 * 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 - * }); 72 + * // @ts-ignore 73 + * const result = detach({ fileUriOrScheme: "https://a.com/1.mp3", inputScheme: "https", handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), tracks }); 82 74 * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI"); 83 75 * ``` 84 76 */
+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 + });