mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The share popover's menu items (Copy URL, Copy Magnet, Copy CDN Link, Share on Nostr) only set `data-action` — like the ⋯ menu — but unlike the ⋯ menu (which moreMenuController delegates) nothing wired their clicks, so they did nothing. Only the bottom "forced-source" test links self-wire. Same dead-button class as the old embed/creator-link bugs. Add a delegated click handler on the share panel (bindShareMenuActions) that routes each action to the event videoModalController already handles: copy-magnet → video:copy-magnet, copy-cdn → video:copy-cdn, share-nostr → video:share-nostr, and share (Copy URL) → a new video:copy-url (buildShareUrl makes the plain `?v=<nevent>` deep link; controller copies it with a toast). Forced-source links are skipped (they self-wire). Tests: tests/video-modal-controllers.test.mjs (+5 routing scenarios, 24/24). Lint + build clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
384 lines
13 KiB
JavaScript
384 lines
13 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupModal } from "./video-modal-accessibility.test.mjs";
|
|
|
|
test("CommentsController lifecycle manages comment references", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
assert.ok(modal.commentsRoot, "comments should be initialized after hydrate");
|
|
|
|
modal.commentsController.destroy();
|
|
assert.equal(modal.commentsRoot, null, "destroy should clear comment root");
|
|
|
|
modal.commentsController.initialize({ playerModal: modal.getRoot() });
|
|
assert.ok(modal.commentsRoot, "initialize should restore comment root");
|
|
});
|
|
|
|
test("ReactionsController delegates reaction updates", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
modal.reactionsController.update({ type: "set-user-reaction", reaction: "+" });
|
|
assert.equal(
|
|
modal.reactionState.userReaction,
|
|
"+",
|
|
"user reaction should be updated via controller",
|
|
);
|
|
|
|
modal.reactionsController.destroy();
|
|
assert.equal(
|
|
modal.reactionButtons["+"],
|
|
null,
|
|
"destroy should clear reaction button references",
|
|
);
|
|
});
|
|
|
|
test("SimilarContentController toggles section visibility", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
const container = modal.similarContentContainer;
|
|
assert.ok(container, "similar content container should exist");
|
|
|
|
modal.similarContentController.update({ type: "set-visibility", visible: false });
|
|
assert.equal(
|
|
container.hasAttribute("hidden"),
|
|
true,
|
|
"controller should hide similar content when requested",
|
|
);
|
|
|
|
modal.similarContentController.update({ type: "set-visibility", visible: true });
|
|
assert.equal(
|
|
container.hasAttribute("hidden"),
|
|
false,
|
|
"controller should reveal similar content",
|
|
);
|
|
});
|
|
|
|
test("ModerationController reset clears moderation overlay references", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
assert.ok(modal.moderationOverlay, "moderation overlay should be present");
|
|
|
|
modal.moderationController.destroy();
|
|
assert.equal(
|
|
modal.moderationOverlay,
|
|
null,
|
|
"destroy should clear moderation overlay references",
|
|
);
|
|
|
|
modal.moderationController.initialize({ playerModal: modal.getRoot() });
|
|
assert.ok(
|
|
modal.moderationOverlay,
|
|
"initialize should restore moderation overlay references",
|
|
);
|
|
});
|
|
|
|
test("VideoModal trims tag strip to fit modal width", async (t) => {
|
|
const { window, modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
const root = modal.videoTagsRoot;
|
|
assert.ok(root, "tag container should be available after hydrate");
|
|
|
|
modal.setTagPreferenceStateResolver((tag) => {
|
|
if (tag === "#alpha") {
|
|
return "interest";
|
|
}
|
|
if (tag === "#beta") {
|
|
return "disinterest";
|
|
}
|
|
return "neutral";
|
|
});
|
|
|
|
let containerWidth = 180;
|
|
Object.defineProperty(root, "clientWidth", {
|
|
configurable: true,
|
|
get() {
|
|
return containerWidth;
|
|
},
|
|
});
|
|
root.getBoundingClientRect = () => ({ width: containerWidth });
|
|
|
|
const elementPrototype = window.HTMLElement.prototype;
|
|
const originalScrollWidthDescriptor =
|
|
Object.getOwnPropertyDescriptor(elementPrototype, "scrollWidth") || null;
|
|
|
|
Object.defineProperty(elementPrototype, "scrollWidth", {
|
|
configurable: true,
|
|
get() {
|
|
if (this.classList?.contains("video-tag-strip")) {
|
|
const buttonCount = this.querySelectorAll("button[data-tag]").length;
|
|
return buttonCount * 90;
|
|
}
|
|
if (originalScrollWidthDescriptor?.get) {
|
|
return originalScrollWidthDescriptor.get.call(this);
|
|
}
|
|
return 0;
|
|
},
|
|
});
|
|
|
|
t.after(() => {
|
|
if (originalScrollWidthDescriptor) {
|
|
Object.defineProperty(
|
|
elementPrototype,
|
|
"scrollWidth",
|
|
originalScrollWidthDescriptor,
|
|
);
|
|
} else {
|
|
delete elementPrototype.scrollWidth;
|
|
}
|
|
delete root.clientWidth;
|
|
delete root.getBoundingClientRect;
|
|
});
|
|
|
|
modal.renderVideoTags(["gamma", "beta", "delta", "alpha"]);
|
|
|
|
const trimmedButtons = [
|
|
...root.querySelectorAll("button[data-tag]"),
|
|
];
|
|
assert.equal(trimmedButtons.length, 2, "tag list should be trimmed to fit");
|
|
assert.deepEqual(
|
|
trimmedButtons.map((button) => button.dataset.tag),
|
|
["#alpha", "#beta"],
|
|
"leading tags should remain after trimming",
|
|
);
|
|
assert.equal(
|
|
trimmedButtons[0].dataset.preferenceState,
|
|
"interest",
|
|
"interest styling should persist on trimmed buttons",
|
|
);
|
|
assert.equal(
|
|
trimmedButtons[0].dataset.variant,
|
|
"success",
|
|
"interest variant token should remain on trimmed buttons",
|
|
);
|
|
assert.equal(
|
|
trimmedButtons[1].dataset.preferenceState,
|
|
"disinterest",
|
|
"disinterest styling should persist on trimmed buttons",
|
|
);
|
|
assert.equal(
|
|
trimmedButtons[1].dataset.variant,
|
|
"critical",
|
|
"disinterest variant token should remain on trimmed buttons",
|
|
);
|
|
assert.equal(
|
|
root.hasAttribute("hidden"),
|
|
false,
|
|
"tag container should stay visible when pills remain",
|
|
);
|
|
|
|
containerWidth = 300;
|
|
modal.reflowVideoTags();
|
|
|
|
const expandedButtons = [
|
|
...root.querySelectorAll("button[data-tag]"),
|
|
];
|
|
assert.equal(
|
|
expandedButtons.length,
|
|
3,
|
|
"reflow should allow more tags when width increases",
|
|
);
|
|
assert.deepEqual(
|
|
expandedButtons.map((button) => button.dataset.tag),
|
|
["#alpha", "#beta", "#delta"],
|
|
"reflow should restore full list order before trimming",
|
|
);
|
|
});
|
|
|
|
|
|
// Channel-profile link in the player modal (TODO #53): clicking the creator
|
|
// avatar/name must navigate to the creator's channel. It dispatched
|
|
// "navigate:profile", which had NO listener (ModalManager listens for
|
|
// "creator:navigate"), so the click did nothing — same class as the old
|
|
// action:embed bug. Now it dispatches "creator:navigate" with the pubkey.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-video-modal-creator-nav
|
|
// given: "a video modal with an active video"
|
|
// when: "handleCreatorNavigation runs (creator avatar/name click)"
|
|
// then: "it dispatches creator:navigate with the pubkey, not the dead navigate:profile"
|
|
// observable_outcomes:
|
|
// - "creator:navigate fires with { pubkey }"
|
|
// - "the old dead navigate:profile event never fires"
|
|
// determinism_controls:
|
|
// - "JSDOM modal via setupModal; synchronous dispatch"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "asserting the broken event name"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test("creator link dispatches creator:navigate with the pubkey (not the dead navigate:profile)", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
modal.activeVideo = { pubkey: "a".repeat(64) };
|
|
|
|
let creatorNavigate = null;
|
|
let deadEventFired = false;
|
|
modal.addEventListener("creator:navigate", (event) => {
|
|
creatorNavigate = event.detail;
|
|
});
|
|
modal.addEventListener("navigate:profile", () => {
|
|
deadEventFired = true;
|
|
});
|
|
|
|
modal.handleCreatorNavigation();
|
|
|
|
assert.ok(creatorNavigate, "creator:navigate should fire (ModalManager listens for it)");
|
|
assert.equal(creatorNavigate.pubkey, "a".repeat(64));
|
|
assert.equal(deadEventFired, false, "the old listener-less navigate:profile must not be used");
|
|
});
|
|
|
|
test("creator navigation is a no-op without an active video pubkey", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
modal.activeVideo = { pubkey: "" };
|
|
let fired = false;
|
|
modal.addEventListener("creator:navigate", () => {
|
|
fired = true;
|
|
});
|
|
modal.handleCreatorNavigation();
|
|
assert.equal(fired, false, "no pubkey → no navigation event");
|
|
});
|
|
|
|
// Like/dislike in the player modal (TODO #54): clicking a reaction button must
|
|
// dispatch "video:reaction" (→ app.handleVideoReaction → reactionController). The
|
|
// old handleReactionClick called this.reactionsController.handleReaction (a method
|
|
// that doesn't exist on the video-modal reactions controller) with the raw DOM
|
|
// event, so nothing was published.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-video-modal-reaction-dispatch
|
|
// given: "a video modal with an active video"
|
|
// when: "handleReactionClick runs for a like/dislike button or a bare +/-"
|
|
// then: "it dispatches video:reaction with the derived reaction; unrelated targets do nothing"
|
|
// observable_outcomes:
|
|
// - "bare '+'/'-' → video:reaction with that reaction"
|
|
// - "a click whose currentTarget is the like button → reaction '+'"
|
|
// - "a null/unrelated target → no dispatch"
|
|
// determinism_controls:
|
|
// - "JSDOM modal via setupModal; synchronous dispatch"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "asserting the broken call path"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test("like/dislike dispatches video:reaction with the derived reaction", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
modal.activeVideo = { pubkey: "a".repeat(64) };
|
|
const events = [];
|
|
modal.addEventListener("video:reaction", (event) => events.push(event.detail));
|
|
|
|
modal.handleReactionClick("+");
|
|
modal.handleReactionClick("-");
|
|
|
|
assert.equal(events.length, 2, "both reactions dispatched");
|
|
assert.equal(events[0].reaction, "+");
|
|
assert.equal(events[1].reaction, "-");
|
|
assert.equal(events[0].video, modal.activeVideo, "carries the active video");
|
|
|
|
// And via a real button click (currentTarget = the bound like button).
|
|
if (modal.reactionButtons?.["+"]) {
|
|
modal.handleReactionClick({ currentTarget: modal.reactionButtons["+"] });
|
|
assert.equal(events[2]?.reaction, "+", "derived '+' from the like button element");
|
|
}
|
|
});
|
|
|
|
test("reaction click with no matching button does not dispatch", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
|
|
let fired = false;
|
|
modal.addEventListener("video:reaction", () => {
|
|
fired = true;
|
|
});
|
|
modal.handleReactionClick({ currentTarget: null });
|
|
modal.handleReactionClick({});
|
|
assert.equal(fired, false, "no reaction event for an unrelated/empty target");
|
|
});
|
|
|
|
// Share popover (reported: "Copy URL / Copy Magnet / Copy CDN don't work"). The
|
|
// share menu buttons only set data-action (like the ⋯ menu), but unlike the ⋯
|
|
// menu nothing wired their clicks, so they were dead. handleShareMenuAction now
|
|
// routes each to the event videoModalController already handles.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-video-modal-share-menu-routing
|
|
// given: "a video modal with an active video"
|
|
// when: "handleShareMenuAction runs for a share-menu action"
|
|
// then: "it dispatches the matching video:* event; forced-source actions do not re-route"
|
|
// determinism_controls:
|
|
// - "JSDOM modal via setupModal; synchronous dispatch"
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test("share menu Copy Magnet routes to video:copy-magnet", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
modal.activeVideo = { id: "e".repeat(64), url: "https://x/v.mp4", magnet: "" };
|
|
const events = [];
|
|
modal.addEventListener("video:copy-magnet", (e) => events.push(e.detail));
|
|
modal.handleShareMenuAction("copy-magnet");
|
|
assert.equal(events.length, 1, "Copy Magnet dispatches video:copy-magnet");
|
|
assert.equal(events[0].video.id, "e".repeat(64));
|
|
});
|
|
|
|
test("share menu Copy CDN routes to video:copy-cdn", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
modal.activeVideo = { id: "e".repeat(64), url: "https://x/v.mp4" };
|
|
const events = [];
|
|
modal.addEventListener("video:copy-cdn", (e) => events.push(e.detail));
|
|
modal.handleShareMenuAction("copy-cdn");
|
|
assert.equal(events.length, 1, "Copy CDN dispatches video:copy-cdn");
|
|
assert.equal(events[0].video.url, "https://x/v.mp4");
|
|
});
|
|
|
|
test("share menu Copy URL routes to video:copy-url", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
modal.activeVideo = { id: "e".repeat(64) };
|
|
const events = [];
|
|
modal.addEventListener("video:copy-url", (e) => events.push(e.detail));
|
|
modal.handleShareMenuAction("share");
|
|
assert.equal(events.length, 1, "Copy URL dispatches video:copy-url");
|
|
assert.equal(typeof events[0].url, "string");
|
|
});
|
|
|
|
test("share menu Share on Nostr routes to video:share-nostr", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
modal.activeVideo = { id: "e".repeat(64) };
|
|
const events = [];
|
|
modal.addEventListener("video:share-nostr", (e) => events.push(e.detail));
|
|
modal.handleShareMenuAction("share-nostr");
|
|
assert.equal(events.length, 1, "Share on Nostr dispatches video:share-nostr");
|
|
});
|
|
|
|
test("share menu forced-source actions do not re-route through handleShareMenuAction", async (t) => {
|
|
const { modal, cleanup } = await setupModal();
|
|
t.after(cleanup);
|
|
modal.activeVideo = { id: "e".repeat(64) };
|
|
let fired = false;
|
|
["video:copy-magnet", "video:copy-cdn", "video:copy-url", "video:share-nostr"].forEach(
|
|
(name) => modal.addEventListener(name, () => { fired = true; })
|
|
);
|
|
modal.handleShareMenuAction("copy-cdn-url");
|
|
modal.handleShareMenuAction("copy-webtorrent-url");
|
|
assert.equal(fired, false, "forced-source links self-wire; not routed here");
|
|
});
|