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

Configure Feed

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

chore: processor components tests

+473 -18
+1
_config.ts
··· 163 163 site.add("/favicons", "/"); 164 164 site.add("/fonts"); 165 165 site.add("/images"); 166 + site.add("/testing"); 166 167 site.add([".woff2"]); 167 168 168 169 site.remoteFile(
src/testing/sample/audio.mp3

This is a binary file and will not be displayed.

+44 -17
tests/components/engine/audio/test.ts
··· 53 53 it("supply with URL items updates the items signal", async () => { 54 54 const result = await testWeb(async () => { 55 55 const mod = await import("~/components/engine/audio/element.js"); 56 - const { trackA, trackB } = await import("~/testing/sample/tracks.js"); 56 + const { trackA } = await import("~/testing/sample/tracks.js"); 57 57 const engine = new mod.CLASS(); 58 58 document.body.append(engine); 59 59 60 60 engine.supply({ 61 61 audio: [ 62 - { id: "audio-a", url: trackA.uri, isPreload: false, track: trackA }, 63 - { id: "audio-b", url: trackB.uri, isPreload: false, track: trackB }, 62 + { 63 + id: "audio-a", 64 + url: "/testing/sample/audio.mp3", 65 + isPreload: false, 66 + track: trackA, 67 + }, 68 + { 69 + id: "audio-b", 70 + url: "/testing/sample/audio.mp3", 71 + isPreload: false, 72 + track: trackA, 73 + }, 64 74 ], 65 75 }); 66 76 ··· 77 87 const engine = new mod.CLASS(); 78 88 document.body.append(engine); 79 89 80 - const item = { id: "audio-a", url: trackA.uri, isPreload: false, track: trackA }; 90 + const item = { 91 + id: "audio-a", 92 + url: "/testing/sample/audio.mp3", 93 + isPreload: false, 94 + track: trackA, 95 + }; 81 96 82 97 engine.supply({ audio: [item] }); 83 98 const itemsAfterFirst = engine.items(); ··· 95 110 it("supply replaces items when IDs change", async () => { 96 111 const result = await testWeb(async () => { 97 112 const mod = await import("~/components/engine/audio/element.js"); 98 - const { trackA, trackB } = await import("~/testing/sample/tracks.js"); 113 + const { trackA } = await import("~/testing/sample/tracks.js"); 99 114 const engine = new mod.CLASS(); 100 115 document.body.append(engine); 101 116 102 117 engine.supply({ 103 - audio: [ 104 - { id: "audio-a", url: trackA.uri, isPreload: false, track: trackA }, 105 - ], 118 + audio: [{ 119 + id: "audio-a", 120 + url: "/testing/sample/audio.mp3", 121 + isPreload: false, 122 + track: trackA, 123 + }], 106 124 }); 107 125 108 126 engine.supply({ 109 - audio: [ 110 - { id: "audio-b", url: trackB.uri, isPreload: false, track: trackB }, 111 - ], 127 + audio: [{ 128 + id: "audio-b", 129 + url: "/testing/sample/audio.mp3", 130 + isPreload: false, 131 + track: trackA, 132 + }], 112 133 }); 113 134 114 135 return engine.items().map((i) => i.id); ··· 125 146 document.body.append(engine); 126 147 127 148 engine.supply({ 128 - audio: [ 129 - { id: "audio-a", url: trackA.uri, isPreload: true, track: trackA }, 130 - ], 149 + audio: [{ 150 + id: "audio-a", 151 + url: "/testing/sample/audio.mp3", 152 + isPreload: true, 153 + track: trackA, 154 + }], 131 155 }); 132 156 133 157 engine.supply({ 134 - audio: [ 135 - { id: "audio-a", url: trackA.uri, isPreload: false, track: trackA }, 136 - ], 158 + audio: [{ 159 + id: "audio-a", 160 + url: "/testing/sample/audio.mp3", 161 + isPreload: false, 162 + track: trackA, 163 + }], 137 164 }); 138 165 139 166 return engine.items()[0]?.isPreload;
+147
tests/components/processor/artwork/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("components/processor/artwork", () => { 7 + it("returns empty array when tags have no artist", async () => { 8 + // processRequest short-circuits when artist or album is missing, 9 + // so no network calls are made 10 + const result = await testWeb(async () => { 11 + const mod = await import("~/components/processor/artwork/element.js"); 12 + const processor = new mod.CLASS(); 13 + 14 + document.body.append(processor); 15 + 16 + return processor.artwork({ 17 + cacheId: "test-no-artist", 18 + tags: { album: "Some Album" }, 19 + }); 20 + }); 21 + 22 + expect(result).toEqual([]); 23 + }); 24 + 25 + it("returns empty array when tags have no album", async () => { 26 + const result = await testWeb(async () => { 27 + const mod = await import("~/components/processor/artwork/element.js"); 28 + const processor = new mod.CLASS(); 29 + 30 + document.body.append(processor); 31 + 32 + return processor.artwork({ 33 + cacheId: "test-no-album", 34 + tags: { artist: "Some Artist" }, 35 + }); 36 + }); 37 + 38 + expect(result).toEqual([]); 39 + }); 40 + 41 + it("returns empty array when tags are not provided and URL is unreachable", async () => { 42 + const result = await testWeb(async () => { 43 + const mod = await import("~/components/processor/artwork/element.js"); 44 + const processor = new mod.CLASS(); 45 + 46 + document.body.append(processor); 47 + 48 + // musicMetadataTags will fail (bad URL), meta.tags stays undefined 49 + // → still short-circuits on missing artist/album 50 + return processor.artwork({ 51 + cacheId: "test-no-tags", 52 + urls: { 53 + get: "http://localhost/nonexistent.mp3", 54 + head: "http://localhost/nonexistent.mp3", 55 + }, 56 + }); 57 + }); 58 + 59 + expect(result).toEqual([]); 60 + }); 61 + 62 + it("artwork with artist 'VA' and no album returns empty array", async () => { 63 + // 'VA' sets variousArtists=true but still short-circuits on missing album 64 + const result = await testWeb(async () => { 65 + const mod = await import("~/components/processor/artwork/element.js"); 66 + const processor = new mod.CLASS(); 67 + 68 + document.body.append(processor); 69 + 70 + return processor.artwork({ 71 + cacheId: "test-va", 72 + tags: { artist: "VA", title: "Various Track" }, 73 + }); 74 + }); 75 + 76 + expect(result).toEqual([]); 77 + }); 78 + 79 + it("supply queues requests and returns without throwing", async () => { 80 + const result = await testWeb(async () => { 81 + const mod = await import("~/components/processor/artwork/element.js"); 82 + const processor = new mod.CLASS(); 83 + 84 + document.body.append(processor); 85 + 86 + const r = await processor.supply([ 87 + { cacheId: "queued-1", tags: { title: "Track One" } }, 88 + { cacheId: "queued-2", tags: { title: "Track Two" } }, 89 + ]); 90 + 91 + return r ?? null; 92 + }); 93 + 94 + // supply() returns void — proxy resolves to undefined 95 + expect(result).toBe(null); 96 + }); 97 + 98 + it("extracts tags from sample audio file and returns array", async () => { 99 + // Passes the real file URL; musicMetadataTags runs successfully. 100 + // The file has no embedded artwork so processRequest short-circuits 101 + // on the missing album tag passed via explicit tags override. 102 + const result = await testWeb(async () => { 103 + const mod = await import("~/components/processor/artwork/element.js"); 104 + const processor = new mod.CLASS(); 105 + 106 + document.body.append(processor); 107 + 108 + const blob = await fetch("/testing/sample/audio.mp3").then((r) => 109 + r.blob() 110 + ); 111 + const blobUrl = URL.createObjectURL(blob); 112 + 113 + // Provide only a title — processRequest short-circuits before hitting 114 + // the network because artist+album are not both present 115 + const art = await processor.artwork({ 116 + cacheId: "sample-audio-title-only", 117 + tags: { title: "Mr. Sandman" }, 118 + urls: { get: blobUrl, head: blobUrl }, 119 + }); 120 + 121 + URL.revokeObjectURL(blobUrl); 122 + return art; 123 + }); 124 + 125 + expect(Array.isArray(result)).toBe(true); 126 + }); 127 + 128 + it("returns cached artwork on subsequent calls with the same cacheId", async () => { 129 + const [first, second] = await testWeb(async () => { 130 + const mod = await import("~/components/processor/artwork/element.js"); 131 + const processor = new mod.CLASS(); 132 + 133 + document.body.append(processor); 134 + 135 + const req = { cacheId: "cached-id", tags: { title: "Track" } }; 136 + 137 + // Both calls go through processRequest; second should hit IDB cache path 138 + const first = await processor.artwork(req); 139 + const second = await processor.artwork(req); 140 + 141 + return [first, second]; 142 + }); 143 + 144 + // Both should return the same empty result ([] in this case) 145 + expect(first).toEqual(second); 146 + }); 147 + });
+89
tests/components/processor/metadata/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("components/processor/metadata", () => { 7 + it("returns empty extraction when no urls or stream are provided", async () => { 8 + const result = await testWeb(async () => { 9 + const mod = await import("~/components/processor/metadata/element.js"); 10 + const processor = new mod.CLASS(); 11 + 12 + document.body.append(processor); 13 + 14 + // No urls/stream — musicMetadataTags throws, worker catches and returns {} 15 + return processor.supply({}); 16 + }); 17 + 18 + expect(result).toEqual({}); 19 + }); 20 + 21 + it("returns empty extraction when the URL is unreachable", async () => { 22 + const result = await testWeb(async () => { 23 + const mod = await import("~/components/processor/metadata/element.js"); 24 + const processor = new mod.CLASS(); 25 + 26 + document.body.append(processor); 27 + 28 + return processor.supply({ 29 + urls: { 30 + get: "http://localhost/nonexistent-audio-file.mp3", 31 + head: "http://localhost/nonexistent-audio-file.mp3", 32 + }, 33 + }); 34 + }); 35 + 36 + expect(result).toEqual({}); 37 + }); 38 + 39 + it("extracts tags from sample audio file", async () => { 40 + const tags = await testWeb(async () => { 41 + const mod = await import("~/components/processor/metadata/element.js"); 42 + const processor = new mod.CLASS(); 43 + document.body.append(processor); 44 + 45 + const blob = await fetch("/testing/sample/audio.mp3").then((r) => 46 + r.blob() 47 + ); 48 + const blobUrl = URL.createObjectURL(blob); 49 + const extraction = await processor.supply({ 50 + urls: { get: blobUrl, head: blobUrl }, 51 + }); 52 + URL.revokeObjectURL(blobUrl); 53 + 54 + return extraction.tags ?? null; 55 + }); 56 + 57 + expect(tags).not.toBe(null); 58 + expect(tags?.title).toBe("Mr. Sandman"); 59 + expect(tags?.album).toBe("Mr. Sandman"); 60 + expect(tags?.year).toBe(1954); 61 + expect(tags?.track?.no).toBe(1); 62 + expect(tags?.artist).toContain("The Chordettes"); 63 + }); 64 + 65 + it("extracts stats from sample audio file", async () => { 66 + const stats = await testWeb(async () => { 67 + const mod = await import("~/components/processor/metadata/element.js"); 68 + const processor = new mod.CLASS(); 69 + document.body.append(processor); 70 + 71 + const blob = await fetch("/testing/sample/audio.mp3").then((r) => 72 + r.blob() 73 + ); 74 + const blobUrl = URL.createObjectURL(blob); 75 + const extraction = await processor.supply({ 76 + urls: { get: blobUrl, head: blobUrl }, 77 + }); 78 + URL.revokeObjectURL(blobUrl); 79 + 80 + return extraction.stats ?? null; 81 + }); 82 + 83 + expect(stats).not.toBe(null); 84 + expect(stats?.bitrate).toBe(143320); 85 + // Duration is stored in milliseconds; 151.21s ± 500ms 86 + expect(stats?.duration).toBeGreaterThan(150000); 87 + expect(stats?.duration).toBeLessThan(152000); 88 + }); 89 + });
+192 -1
tests/components/processor/search/test.ts
··· 2 2 import { expect } from "@std/expect"; 3 3 4 4 import { testWeb } from "@tests/common/index.ts"; 5 - import { trackA, trackB } from "~/testing/sample/tracks.js"; 5 + import { trackA, trackB, tracks } from "~/testing/sample/tracks.js"; 6 6 7 7 describe("components/processor/search", () => { 8 8 it("finds tracks by album", async () => { ··· 63 63 }); 64 64 65 65 expect(results[0]?.id).toBe(trackB.id); 66 + }); 67 + 68 + it("returns empty array when no tracks match the search term", async () => { 69 + const results = await testWeb(async () => { 70 + const SearchProcessor = await import( 71 + "~/components/processor/search/element.js" 72 + ); 73 + const processor = new SearchProcessor.CLASS(); 74 + 75 + document.body.append(processor); 76 + 77 + const { tracks } = await import("~/testing/sample/tracks.js"); 78 + await processor.supply({ tracks }); 79 + 80 + return processor.search({ term: "zzz-no-match-zzz" }); 81 + }); 82 + 83 + expect(results).toEqual([]); 84 + }); 85 + 86 + it("supplyFingerprint is undefined before first supply", async () => { 87 + const fp = await testWeb(async () => { 88 + const SearchProcessor = await import( 89 + "~/components/processor/search/element.js" 90 + ); 91 + const processor = new SearchProcessor.CLASS(); 92 + 93 + document.body.append(processor); 94 + 95 + return (await processor.supplyFingerprint()) ?? null; 96 + }); 97 + 98 + expect(fp).toBe(null); 99 + }); 100 + 101 + it("supplyFingerprint is set after supply", async () => { 102 + const fp = await testWeb(async () => { 103 + const SearchProcessor = await import( 104 + "~/components/processor/search/element.js" 105 + ); 106 + const processor = new SearchProcessor.CLASS(); 107 + 108 + document.body.append(processor); 109 + 110 + const { tracks } = await import("~/testing/sample/tracks.js"); 111 + await processor.supply({ tracks }); 112 + 113 + return processor.supplyFingerprint() ?? null; 114 + }); 115 + 116 + expect(fp).not.toBe(null); 117 + expect(typeof fp).toBe("string"); 118 + }); 119 + 120 + it("supply with same tracks does not change the fingerprint", async () => { 121 + const [fp1, fp2] = await testWeb(async () => { 122 + const SearchProcessor = await import( 123 + "~/components/processor/search/element.js" 124 + ); 125 + const processor = new SearchProcessor.CLASS(); 126 + 127 + document.body.append(processor); 128 + 129 + const { tracks } = await import("~/testing/sample/tracks.js"); 130 + await processor.supply({ tracks }); 131 + const fp1 = processor.supplyFingerprint(); 132 + 133 + await processor.supply({ tracks }); 134 + const fp2 = processor.supplyFingerprint(); 135 + 136 + return [fp1, fp2]; 137 + }); 138 + 139 + expect(fp1).toBe(fp2); 140 + }); 141 + 142 + it("supply removes tracks no longer in the list", async () => { 143 + const results = await testWeb(async () => { 144 + const SearchProcessor = await import( 145 + "~/components/processor/search/element.js" 146 + ); 147 + const processor = new SearchProcessor.CLASS(); 148 + 149 + document.body.append(processor); 150 + 151 + const { trackA, trackB } = await import("~/testing/sample/tracks.js"); 152 + 153 + // Supply both tracks then narrow to only trackB 154 + await processor.supply({ tracks: [trackA, trackB] }); 155 + await processor.supply({ tracks: [trackB] }); 156 + 157 + // trackA's artist "Artist" is absent from trackB — must return empty 158 + return processor.search({ term: "Artist" }); 159 + }); 160 + 161 + expect(results).toEqual([]); 162 + }); 163 + 164 + it("supply with empty list clears all tracks from the index", async () => { 165 + const results = await testWeb(async () => { 166 + const SearchProcessor = await import( 167 + "~/components/processor/search/element.js" 168 + ); 169 + const processor = new SearchProcessor.CLASS(); 170 + 171 + document.body.append(processor); 172 + 173 + const { tracks } = await import("~/testing/sample/tracks.js"); 174 + await processor.supply({ tracks }); 175 + await processor.supply({ tracks: [] }); 176 + 177 + return processor.search({ term: "Sample" }); 178 + }); 179 + 180 + expect(results).toEqual([]); 181 + }); 182 + 183 + it("sorts results by artist alphabetically", async () => { 184 + const ids = await testWeb(async () => { 185 + const SearchProcessor = await import( 186 + "~/components/processor/search/element.js" 187 + ); 188 + const processor = new SearchProcessor.CLASS(); 189 + 190 + document.body.append(processor); 191 + 192 + const testTracks = [ 193 + { 194 + $type: "sh.diffuse.output.track" as const, 195 + id: "sort-zebra", 196 + uri: "diffuse://sort-zebra", 197 + tags: { artist: "Zebra", title: "Sort Test" }, 198 + }, 199 + { 200 + $type: "sh.diffuse.output.track" as const, 201 + id: "sort-apple", 202 + uri: "diffuse://sort-apple", 203 + tags: { artist: "Apple", title: "Sort Test" }, 204 + }, 205 + { 206 + $type: "sh.diffuse.output.track" as const, 207 + id: "sort-mango", 208 + uri: "diffuse://sort-mango", 209 + tags: { artist: "Mango", title: "Sort Test" }, 210 + }, 211 + ]; 212 + 213 + await processor.supply({ tracks: testTracks }); 214 + const results = await processor.search({ term: "Sort Test" }); 215 + return results.map((t) => t.id); 216 + }); 217 + 218 + expect(ids).toEqual(["sort-apple", "sort-mango", "sort-zebra"]); 219 + }); 220 + 221 + it("sorts results by track number within the same album", async () => { 222 + const ids = await testWeb(async () => { 223 + const SearchProcessor = await import( 224 + "~/components/processor/search/element.js" 225 + ); 226 + const processor = new SearchProcessor.CLASS(); 227 + 228 + document.body.append(processor); 229 + 230 + const testTracks = [ 231 + { 232 + $type: "sh.diffuse.output.track" as const, 233 + id: "track-03", 234 + uri: "diffuse://track-03", 235 + tags: { artist: "Band", album: "Album", track: { no: 3 }, title: "C" }, 236 + }, 237 + { 238 + $type: "sh.diffuse.output.track" as const, 239 + id: "track-01", 240 + uri: "diffuse://track-01", 241 + tags: { artist: "Band", album: "Album", track: { no: 1 }, title: "A" }, 242 + }, 243 + { 244 + $type: "sh.diffuse.output.track" as const, 245 + id: "track-02", 246 + uri: "diffuse://track-02", 247 + tags: { artist: "Band", album: "Album", track: { no: 2 }, title: "B" }, 248 + }, 249 + ]; 250 + 251 + await processor.supply({ tracks: testTracks }); 252 + const results = await processor.search({ term: "Band" }); 253 + return results.map((t) => t.id); 254 + }); 255 + 256 + expect(ids).toEqual(["track-01", "track-02", "track-03"]); 66 257 }); 67 258 });