Files
bitvid/tests/video-modal-controllers.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 6e2894e3d8 [video-modal] Fix dead like/dislike buttons — dispatch video:reaction (#54)
The real #54 bug (the signer gate was correct but unreachable): the reactions
controller forwards the raw click event to VideoModal.handleReactionClick, which
called this.reactionsController.handleReaction(event) — a method that does NOT
exist on the video-modal reactions controller (it only binds buttons + does
optimistic UI), passing the DOM event as the "reaction". So clicking like/dislike
threw / silently did nothing and never reached the publish path.

Fix: handleReactionClick now derives +/- from the clicked button
(#modalLikeBtn / #modalDislikeBtn, or a bare "+"/"-" for programmatic callers) and
dispatches "video:reaction" — the event ModalManager already routes to
app.handleVideoReaction -> reactionController.handleReaction (signs + publishes the
kind-7, and runs the ensureEncryptionCapableSigner gate from the earlier #54 pass).

Note: #53 (channel avatar/name link) is already fixed in a20388c5
(handleCreatorNavigation now dispatches "creator:navigate"); if it still looks dead
it's a stale build — rebuild dist / redeploy the branch under test.

Tests: tests/video-modal-controllers.test.mjs (+2). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:40:46 -04:00

311 lines
10 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");
});