From aff951dde8ffd5d29eedab6f8fc955c5c0845b90 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Sat, 28 Mar 2026 22:34:43 +0100 Subject: [PATCH] chore: transformer components tests --- .../transformer/output/bytes/json/test.ts | 131 ++++++++++++++ .../output/raw/atproto-sync/test.ts | 55 ++++++ .../output/refiner/default/test.ts | 167 ++++++++++++++++++ .../output/refiner/initial-contents/test.ts | 161 +++++++++++++++++ .../output/refiner/track-uri-passkey/test.ts | 137 ++++++++++++++ 5 files changed, 651 insertions(+) create mode 100644 tests/components/transformer/output/bytes/json/test.ts create mode 100644 tests/components/transformer/output/raw/atproto-sync/test.ts create mode 100644 tests/components/transformer/output/refiner/default/test.ts create mode 100644 tests/components/transformer/output/refiner/initial-contents/test.ts create mode 100644 tests/components/transformer/output/refiner/track-uri-passkey/test.ts diff --git a/tests/components/transformer/output/bytes/json/test.ts b/tests/components/transformer/output/bytes/json/test.ts new file mode 100644 index 00000000..88ffc6e1 --- /dev/null +++ b/tests/components/transformer/output/bytes/json/test.ts @@ -0,0 +1,131 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/transformer/output/bytes/json", () => { + it("ready returns false when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/bytes/json/element.js" + ); + const t = new mod.CLASS(); + return t.ready(); + }); + + expect(result).toBe(false); + }); + + it("tracks.collection is loading when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/bytes/json/element.js" + ); + const t = new mod.CLASS(); + return t.tracks.collection().state; + }); + + expect(result).toBe("loading"); + }); + + it("tracks save and collection round-trip", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/bytes/json/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb"); + document.body.append(t); + + await t.tracks.save([ + { + $type: "sh.diffuse.output.track", + id: "track-1", + uri: "https://example.com/track1.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "track-2", + uri: "https://example.com/track2.mp3", + }, + ]); + + const col = t.tracks.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ id: string }>).map((tr) => tr.id); + }); + + expect(result).toEqual(["track-1", "track-2"]); + }); + + it("facets save and collection round-trip", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/bytes/json/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-facets"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-facets"); + document.body.append(t); + + await t.facets.save([ + { + $type: "sh.diffuse.output.facet", + id: "f1", + name: "Favourites", + }, + ]); + + const col = t.facets.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ id: string; name: string }>).map((f) => ({ + id: f.id, + name: f.name, + })); + }); + + expect(result).toEqual([{ id: "f1", name: "Favourites" }]); + }); + + it("saving empty array results in empty loaded collection", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/bytes/json/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-empty"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-empty"); + document.body.append(t); + + await t.tracks.save([]); + + const col = t.tracks.collection(); + if (col.state !== "loaded") return null; + return (col.data as unknown[]).length; + }); + + expect(result).toBe(0); + }); +}); diff --git a/tests/components/transformer/output/raw/atproto-sync/test.ts b/tests/components/transformer/output/raw/atproto-sync/test.ts new file mode 100644 index 00000000..0e0720dd --- /dev/null +++ b/tests/components/transformer/output/raw/atproto-sync/test.ts @@ -0,0 +1,55 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/transformer/output/raw/atproto-sync", () => { + it("ready returns true", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/raw/atproto-sync/element.js" + ); + const t = new mod.CLASS(); + return t.ready(); + }); + + expect(result).toBe(true); + }); + + it("tracks.collection is loading when no local output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/raw/atproto-sync/element.js" + ); + const t = new mod.CLASS() as any; + // Not connected to DOM — #localOutput stays undefined + return t.tracks.collection().state; + }); + + expect(result).toBe("loading"); + }); + + it("facets.collection is loading when no local output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/raw/atproto-sync/element.js" + ); + const t = new mod.CLASS() as any; + return t.facets.collection().state; + }); + + expect(result).toBe("loading"); + }); + + it("playlistItems.collection is loading when no local output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/raw/atproto-sync/element.js" + ); + const t = new mod.CLASS() as any; + return t.playlistItems.collection().state; + }); + + expect(result).toBe("loading"); + }); +}); diff --git a/tests/components/transformer/output/refiner/default/test.ts b/tests/components/transformer/output/refiner/default/test.ts new file mode 100644 index 00000000..5f3ae706 --- /dev/null +++ b/tests/components/transformer/output/refiner/default/test.ts @@ -0,0 +1,167 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/transformer/output/refiner/default", () => { + it("ready returns false when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/default/element.js" + ); + const t = new mod.CLASS(); + return t.ready(); + }); + + expect(result).toBe(false); + }); + + it("tracks.collection is loading when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/default/element.js" + ); + const t = new mod.CLASS(); + return t.tracks.collection().state; + }); + + expect(result).toBe("loading"); + }); + + it("non-ephemeral tracks are saved to backing output and appear in collection", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/default/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb"); + document.body.append(t); + + await t.tracks.save([ + { + $type: "sh.diffuse.output.track", + id: "track-1", + uri: "https://example.com/track1.mp3", + }, + ]); + + const col = t.tracks.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ id: string }>).map((tr) => tr.id); + }); + + expect(result).toEqual(["track-1"]); + }); + + it("ephemeral tracks appear in transformer collection but not in backing output", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/default/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-eph"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-eph"); + document.body.append(t); + + await t.tracks.save([ + { + $type: "sh.diffuse.output.track", + id: "regular", + uri: "https://example.com/regular.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "eph", + uri: "https://example.com/eph.mp3", + ephemeral: true, + }, + ]); + + const transformerCol = t.tracks.collection(); + const backingCol = output.tracks.collection(); + + if (transformerCol.state !== "loaded") return null; + if (backingCol.state !== "loaded") return null; + + return { + transformerIds: (transformerCol.data as Array<{ id: string }>) + .map((tr) => tr.id) + .sort(), + backingIds: (backingCol.data as Array<{ id: string }>) + .map((tr) => tr.id) + .sort(), + }; + }); + + expect(result?.transformerIds).toEqual(["eph", "regular"]); + expect(result?.backingIds).toEqual(["regular"]); + }); + + it("ephemeral playlist items appear in transformer collection but not in backing output", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/default/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-pl"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-pl"); + document.body.append(t); + + await t.playlistItems.save([ + { + $type: "sh.diffuse.output.playlistItem", + id: "pl-1", + playlist: "My Playlist", + criteria: [], + }, + { + $type: "sh.diffuse.output.playlistItem", + id: "pl-eph", + playlist: "Ephemeral Playlist", + criteria: [], + ephemeral: true, + }, + ]); + + const transformerCol = t.playlistItems.collection(); + const backingCol = output.playlistItems.collection(); + + if (transformerCol.state !== "loaded") return null; + if (backingCol.state !== "loaded") return null; + + return { + transformerIds: (transformerCol.data as Array<{ id: string }>) + .map((pl) => pl.id) + .sort(), + backingIds: (backingCol.data as Array<{ id: string }>) + .map((pl) => pl.id) + .sort(), + }; + }); + + expect(result?.transformerIds).toEqual(["pl-1", "pl-eph"]); + expect(result?.backingIds).toEqual(["pl-1"]); + }); +}); diff --git a/tests/components/transformer/output/refiner/initial-contents/test.ts b/tests/components/transformer/output/refiner/initial-contents/test.ts new file mode 100644 index 00000000..eaab0423 --- /dev/null +++ b/tests/components/transformer/output/refiner/initial-contents/test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/transformer/output/refiner/initial-contents", () => { + it("ready returns false when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + const t = new mod.CLASS(); + return t.ready(); + }); + + expect(result).toBe(false); + }); + + it("facets.collection is loading when no output is connected", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + const t = new mod.CLASS(); + return t.facets.collection().state; + }); + + expect(result).toBe("loading"); + }); + + it("facets.collection returns default facets when backing output is empty and not yet initialized", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb"); + document.body.append(t); + + // Save empty to backing so it transitions to "loaded" state + await output.facets.save([]); + + // Wait for the IDB flag check in the constructor to resolve + await new Promise((r) => setTimeout(r, 50)); + + const col = t.facets.collection(); + if (col.state !== "loaded") return null; + return (col.data as unknown[]).length > 0; + }); + + // Should return default facets (non-empty) since not yet initialized + expect(result).toBe(true); + }); + + it("facets.collection returns empty array after explicit save([]) marks as initialized", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-init"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-init"); + document.body.append(t); + + // Wait for the IDB flag check to resolve + await new Promise((r) => setTimeout(r, 50)); + + // Explicit save marks as initialized (even with empty array) + await t.facets.save([]); + + const col = t.facets.collection(); + if (col.state !== "loaded") return null; + return (col.data as unknown[]).length; + }); + + expect(result).toBe(0); + }); + + it("facets.collection returns provided facets after save with data", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-data"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-data"); + document.body.append(t); + + await new Promise((r) => setTimeout(r, 50)); + + await t.facets.save([ + { + $type: "sh.diffuse.output.facet", + id: "my-facet", + name: "My Playlist", + }, + ]); + + const col = t.facets.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ id: string }>).map((f) => f.id); + }); + + expect(result).toEqual(["my-facet"]); + }); + + it("tracks and playlistItems delegate to backing output unchanged", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/transformer/output/refiner/initial-contents/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-tracks"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("output-selector", "#test-idb-tracks"); + document.body.append(t); + + await t.tracks.save([ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "https://example.com/track.mp3", + }, + ]); + + const col = t.tracks.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ id: string }>).map((tr) => tr.id); + }); + + expect(result).toEqual(["t1"]); + }); +}); diff --git a/tests/components/transformer/output/refiner/track-uri-passkey/test.ts b/tests/components/transformer/output/refiner/track-uri-passkey/test.ts new file mode 100644 index 00000000..dab380b4 --- /dev/null +++ b/tests/components/transformer/output/refiner/track-uri-passkey/test.ts @@ -0,0 +1,137 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/transformer/output/refiner/track-uri-passkey", () => { + // Crypto utilities from passkey.js + + it("isEncryptedUri returns true for encrypted:// URIs", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/passkey.js" + ); + return mod.isEncryptedUri("encrypted://abc123def456"); + }); + + expect(result).toBe(true); + }); + + it("isEncryptedUri returns false for plain URIs", async () => { + const results = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/passkey.js" + ); + return [ + mod.isEncryptedUri("https://example.com/track.mp3"), + mod.isEncryptedUri("icecast://radio.example.com/stream"), + mod.isEncryptedUri("local://tid/path.mp3"), + mod.isEncryptedUri(""), + ]; + }); + + expect(results).toEqual([false, false, false, false]); + }); + + it("encryptUri and decryptUri round-trip preserves the original URI", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/passkey.js" + ); + + const key = crypto.getRandomValues(new Uint8Array(32)); + const original = "https://example.com/my-track.mp3"; + + const encrypted = mod.encryptUri(key, original); + const decrypted = mod.decryptUri(key, encrypted); + + return { encrypted: encrypted.startsWith("encrypted://"), decrypted }; + }); + + expect(result.encrypted).toBe(true); + expect(result.decrypted).toBe("https://example.com/my-track.mp3"); + }); + + it("encryptUri produces different ciphertext each time (managed nonce)", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/passkey.js" + ); + + const key = crypto.getRandomValues(new Uint8Array(32)); + const uri = "https://example.com/track.mp3"; + + const enc1 = mod.encryptUri(key, uri); + const enc2 = mod.encryptUri(key, uri); + return enc1 !== enc2; + }); + + expect(result).toBe(true); + }); + + it("decryptUri with wrong key throws", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/passkey.js" + ); + + const key1 = crypto.getRandomValues(new Uint8Array(32)); + const key2 = crypto.getRandomValues(new Uint8Array(32)); + const encrypted = mod.encryptUri(key1, "https://example.com/track.mp3"); + + try { + mod.decryptUri(key2, encrypted); + return false; + } catch { + return true; + } + }); + + expect(result).toBe(true); + }); + + // Element + + it("passkeyActive returns false when no key is loaded", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/element.js" + ); + const t = new mod.CLASS(); + document.body.append(t); + return t.passkeyActive(); + }); + + expect(result).toBe(false); + }); + + it("lockedTracks returns empty array initially", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/element.js" + ); + const t = new mod.CLASS(); + document.body.append(t); + return t.lockedTracks(); + }); + + expect(result).toEqual([]); + }); + + it("ready becomes true after connectedCallback resolves the IDB key check", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/transformer/output/refiner/track-uri-passkey/element.js" + ); + const t = new mod.CLASS(); + document.body.append(t); + + // ready() starts false; connectedCallback loads the key from IDB + // (returning undefined = no stored key) and then sets #keyReady = true + await new Promise((r) => setTimeout(r, 50)); + return t.ready(); + }); + + expect(result).toBe(true); + }); +}); -- 2.51.2