Files
bitvid/tests/playlist-service.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 e5064eeb87 playlists(#37): foundation — kind-30082 schema, builder, pure service + tests
First slice of creator playlists. Adds a bitvid-native addressable playlist
event and the pure, hermetic core that builds/parses/mutates it. No UI or network
yet — those land in follow-up commits.

- nostrEventSchemas.js: PLAYLIST_KIND=30082 (+ NOTE_TYPES.PLAYLIST, BASE_SCHEMAS
  entry) and buildPlaylistEvent(). Data lives in tags: d (id), title/description/
  image, and ordered `a` refs (30078:pubkey:d) to videos; order = tag order;
  repeated refs deduped. Deliberately NOT NIP-51's 30005 "video curation set" —
  bitvid already reads 30005 as legacy interest-sets, so reuse would collide.
- js/playlists/playlistService.js: parsePlaylistEvent, buildPlaylist, and
  immutable add/remove/reorder item helpers + videoCoordinate() (reuses the
  canonical buildVideoAddressPointer, same 30078 contract as the zap fix) +
  generatePlaylistId(). Pure — fully unit-testable with no relays/signer.
- tests/playlist-service.test.mjs: 12 scenarios (build tags + order + dedup,
  d-tag required, parse structure/rejects/defaults, build→parse round-trip,
  add/remove/reorder immutability + clamping, coordinate derivation incl. the
  kind-0 internal-object case).
- docs/nostr-event-schemas.md: catalogue row for the new kind.

Schema validation, lint, and build all pass; tests 12/12.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 12:48:49 -04:00

224 lines
7.0 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import {
PLAYLIST_KIND,
parsePlaylistEvent,
addVideoToPlaylist,
removeVideoFromPlaylist,
reorderPlaylistItems,
videoCoordinate,
buildPlaylist,
generatePlaylistId,
playlistItemKey,
} from "../js/playlists/playlistService.js";
import { buildPlaylistEvent } from "../js/nostrEventSchemas.js";
const PUBKEY = "a".repeat(64);
const AUTHOR = "b".repeat(64);
const coord = (dtag, author = AUTHOR) => `30078:${author}:${dtag}`;
test("buildPlaylistEvent emits kind, identifier, metadata, and ordered a-refs", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 1000,
dTagValue: "my-list",
title: "My List",
description: "cool clips",
image: "https://img.example/x.jpg",
videoCoordinates: [coord("v1"), coord("v2"), coord("v3")],
});
assert.equal(event.kind, PLAYLIST_KIND);
assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "my-list");
assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "playlist");
assert.equal(event.tags.find((t) => t[0] === "title")?.[1], "My List");
assert.equal(event.tags.find((t) => t[0] === "description")?.[1], "cool clips");
assert.equal(
event.tags.find((t) => t[0] === "image")?.[1],
"https://img.example/x.jpg",
);
// Ordered a-refs, in the given order.
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
});
test("buildPlaylistEvent dedupes repeated video coordinates, preserving first order", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 1000,
dTagValue: "d1",
videoCoordinates: [coord("v1"), coord("v2"), coord("v1"), coord("v3")],
});
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
});
test("buildPlaylistEvent requires a d-tag identifier", () => {
assert.throws(
() => buildPlaylistEvent({ pubkey: PUBKEY, created_at: 1, dTagValue: " " }),
/requires a d-tag/i,
);
});
test("parsePlaylistEvent structures a playlist and preserves item order", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 4242,
dTagValue: "list-7",
title: "Seven",
videoCoordinates: [coord("a"), coord("b")],
eventRefs: ["e".repeat(64)],
});
event.id = "event-id-123";
const parsed = parsePlaylistEvent(event);
assert.equal(parsed.id, "list-7");
assert.equal(parsed.pubkey, PUBKEY);
assert.equal(parsed.title, "Seven");
assert.equal(parsed.updatedAt, 4242);
assert.equal(parsed.address, `${PLAYLIST_KIND}:${PUBKEY}:list-7`);
assert.equal(parsed.eventId, "event-id-123");
assert.deepEqual(parsed.items, [
{ type: "a", value: coord("a") },
{ type: "a", value: coord("b") },
{ type: "e", value: "e".repeat(64) },
]);
});
test("parsePlaylistEvent rejects the wrong kind and a missing d-tag", () => {
assert.equal(
parsePlaylistEvent({ kind: 30078, tags: [["d", "x"]] }),
null,
"wrong kind → null",
);
assert.equal(
parsePlaylistEvent({ kind: PLAYLIST_KIND, tags: [["title", "no id"]] }),
null,
"missing d → null",
);
assert.equal(parsePlaylistEvent(null), null);
});
test("parsePlaylistEvent defaults an empty title", () => {
const parsed = parsePlaylistEvent({
kind: PLAYLIST_KIND,
pubkey: PUBKEY,
created_at: 1,
tags: [["d", "no-title"]],
});
assert.equal(parsed.title, "Untitled playlist");
});
test("build → parse round-trips a full playlist (order intact)", () => {
const items = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "a", value: coord("v3") },
];
const event = buildPlaylist({
pubkey: PUBKEY,
id: "rt",
title: "Round Trip",
description: "desc",
items,
created_at: 55,
});
const parsed = parsePlaylistEvent({ ...event, id: "signed-id" });
assert.equal(parsed.title, "Round Trip");
assert.equal(parsed.description, "desc");
assert.deepEqual(parsed.items, items);
});
test("addVideoToPlaylist appends, dedupes, inserts by position, and is immutable", () => {
const base = [{ type: "a", value: coord("v1") }];
const appended = addVideoToPlaylist(base, coord("v2"));
assert.deepEqual(appended.map((i) => i.value), [coord("v1"), coord("v2")]);
assert.deepEqual(base.map((i) => i.value), [coord("v1")], "input not mutated");
const deduped = addVideoToPlaylist(appended, coord("v1"));
assert.equal(deduped.length, 2, "existing video is a no-op");
const inserted = addVideoToPlaylist(appended, coord("v3"), { position: 1 });
assert.deepEqual(inserted.map((i) => i.value), [
coord("v1"),
coord("v3"),
coord("v2"),
]);
assert.deepEqual(addVideoToPlaylist(base, " "), base.slice(), "blank ignored");
});
test("removeVideoFromPlaylist removes by coordinate and by item, immutably", () => {
const base = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "e", value: "e".repeat(64) },
];
const byCoord = removeVideoFromPlaylist(base, coord("v1"));
assert.deepEqual(byCoord.map(playlistItemKey), [
`a:${coord("v2")}`,
`e:${"e".repeat(64)}`,
]);
assert.equal(base.length, 3, "input not mutated");
const byItem = removeVideoFromPlaylist(base, { type: "e", value: "e".repeat(64) });
assert.ok(!byItem.some((i) => i.type === "e"));
});
test("reorderPlaylistItems moves items, clamps, no-ops safely, immutably", () => {
const base = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "a", value: coord("v3") },
];
// Move first to last.
const moved = reorderPlaylistItems(base, 0, 2);
assert.deepEqual(moved.map((i) => i.value), [
coord("v2"),
coord("v3"),
coord("v1"),
]);
assert.deepEqual(base.map((i) => i.value), [
coord("v1"),
coord("v2"),
coord("v3"),
], "input not mutated");
// toIndex beyond the end clamps to last.
const clamped = reorderPlaylistItems(base, 0, 99);
assert.equal(clamped[clamped.length - 1].value, coord("v1"));
// Out-of-range fromIndex → unchanged copy.
assert.deepEqual(reorderPlaylistItems(base, 5, 0), base);
// Same index → unchanged.
assert.deepEqual(reorderPlaylistItems(base, 1, 1), base);
});
test("videoCoordinate derives the 30078 coordinate from a video object", () => {
const video = {
kind: 30078,
pubkey: AUTHOR,
tags: [["d", "1700000000000-abc"]],
};
assert.equal(videoCoordinate(video), `30078:${AUTHOR}:1700000000000-abc`);
// A video object carrying kind 0 (bitvid's internal objects often do) still
// yields the canonical 30078 coordinate — matches the zap-pointer contract.
assert.equal(
videoCoordinate({ kind: 0, pubkey: AUTHOR, tags: [["d", "x"]] }),
`30078:${AUTHOR}:x`,
);
});
test("generatePlaylistId produces distinct, tag-safe ids", () => {
const a = generatePlaylistId();
const b = generatePlaylistId();
assert.notEqual(a, b);
assert.match(a, /^pl-[0-9a-z]+-[0-9a-z]+$/);
});