diff --git a/js/app.js b/js/app.js index 772baeba..25587992 100644 --- a/js/app.js +++ b/js/app.js @@ -150,6 +150,7 @@ import { urlHealthConstants, } from "./state/cache.js"; import ApplicationBootstrap from "./ui/applicationBootstrap.js"; +import SimilarContentController from "./ui/similarContentController.js"; import VideoModalCommentController from "./ui/videoModalCommentController.js"; import TorrentStatusController from "./ui/torrentStatusController.js"; import ModerationActionController from "./services/moderationActionController.js"; @@ -285,6 +286,7 @@ class Application { }); this.initializeModerationActionController(); + this.initializeSimilarContentController(); } get modalVideo() { @@ -1136,7 +1138,40 @@ class Application { }; } + initializeSimilarContentController() { + if (this.similarContentController) { + return; + } + + this.similarContentController = new SimilarContentController({ + services: { + nostrClient, + }, + callbacks: { + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + decorateVideoModeration: (video) => this.decorateVideoModeration(video), + decorateVideoCreatorIdentity: (video) => this.decorateVideoCreatorIdentity(video), + }, + ui: { + videoModal: this.videoModal, + }, + state: { + getVideoListView: () => this.videoListView, + getVideosMap: () => this.videosMap, + }, + helpers: { + getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), + buildShareUrlFromEventId: (id) => this.buildShareUrlFromEventId(id), + formatTimeAgo: (ts) => this.formatTimeAgo(ts), + } + }); + } + extractDTagValue(tags) { + if (this.similarContentController) { + return this.similarContentController.extractDTagValue(tags); + } + // Fallback if controller not initialized (though it should be) if (!Array.isArray(tags)) { return ""; } @@ -1152,6 +1187,10 @@ class Application { } deriveVideoPointerInfo(video) { + if (this.similarContentController) { + return this.similarContentController.deriveVideoPointerInfo(video); + } + // Fallback if controller not initialized if (!video || typeof video !== "object") { return null; } @@ -1168,271 +1207,20 @@ class Application { }); } - computeSimilarContentCandidates({ activeVideo = this.currentVideo, maxItems = 5 } = {}) { - const decorateCandidate = (video) => { - if (!video || typeof video !== "object") { - return video; - } - let decoratedVideo = video; - if (typeof this.decorateVideoModeration === "function") { - try { - const decorated = this.decorateVideoModeration(video); - if (decorated && typeof decorated === "object") { - decoratedVideo = decorated; - } - } catch (error) { - devLogger.warn( - "[Application] Failed to decorate similar content candidate", - error, - ); - } - } - if (typeof this.decorateVideoCreatorIdentity === "function") { - try { - const identityDecorated = this.decorateVideoCreatorIdentity( - decoratedVideo, - ); - if (identityDecorated && typeof identityDecorated === "object") { - decoratedVideo = identityDecorated; - } - } catch (error) { - devLogger.warn( - "[Application] Failed to decorate similar content identity", - error, - ); - } - } - return decoratedVideo; - }; - - const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null; - if (!target) { - return []; - } - - const activeTagsSource = Array.isArray(target.displayTags) && target.displayTags.length - ? target.displayTags - : collectVideoTags(target); - - const activeTagSet = new Set(); - for (const tag of activeTagsSource) { - if (typeof tag !== "string") { - continue; - } - const normalized = tag.trim().toLowerCase(); - if (normalized) { - activeTagSet.add(normalized); - } - } - - if (activeTagSet.size === 0) { - return []; - } - - const limit = Number.isFinite(maxItems) && maxItems > 0 - ? Math.max(1, Math.floor(maxItems)) - : 5; - - let candidateSource = []; - if (Array.isArray(this.videoListView?.currentVideos) && this.videoListView.currentVideos.length) { - candidateSource = this.videoListView.currentVideos; - } else if (this.videosMap instanceof Map && this.videosMap.size) { - candidateSource = Array.from(this.videosMap.values()); - } else if (nostrClient && typeof nostrClient.getActiveVideos === "function") { - try { - const activeVideos = nostrClient.getActiveVideos(); - if (Array.isArray(activeVideos)) { - candidateSource = activeVideos; - } - } catch (error) { - devLogger.warn("[Application] Failed to read active videos for similar content:", error); - } - } - - if (!Array.isArray(candidateSource) || candidateSource.length === 0) { - return []; - } - - const activeId = typeof target.id === "string" ? target.id : ""; - const activePointerKey = typeof target.pointerKey === "string" ? target.pointerKey : ""; - const seenKeys = new Set(); - if (activeId) { - const normalizedId = activeId.trim().toLowerCase(); - if (normalizedId) { - seenKeys.add(normalizedId); - } - } - if (activePointerKey) { - const normalizedPointer = activePointerKey.trim().toLowerCase(); - if (normalizedPointer) { - seenKeys.add(normalizedPointer); - } - } - - const results = []; - - for (const candidate of candidateSource) { - const decoratedCandidate = decorateCandidate(candidate); - if (!decoratedCandidate || typeof decoratedCandidate !== "object") { - continue; - } - if (decoratedCandidate === target) { - continue; - } - - const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : ""; - if (candidateId && candidateId === activeId) { - continue; - } - if (decoratedCandidate.deleted === true) { - continue; - } - if (decoratedCandidate.isPrivate === true) { - continue; - } - if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) { - continue; - } - - const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : ""; - if (candidatePubkey && this.isAuthorBlocked(candidatePubkey)) { - continue; - } - - const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length - ? decoratedCandidate.displayTags - : collectVideoTags(decoratedCandidate); - if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) { - continue; - } - - const candidateTagSet = new Set(); - for (const tag of candidateTagsSource) { - if (typeof tag !== "string") { - continue; - } - const normalized = tag.trim().toLowerCase(); - if (normalized) { - candidateTagSet.add(normalized); - } - } - - if (candidateTagSet.size === 0) { - continue; - } - - let sharedCount = 0; - for (const tag of candidateTagSet) { - if (activeTagSet.has(tag)) { - sharedCount += 1; - } - } - - if (sharedCount === 0) { - continue; - } - - const pointerInfo = this.deriveVideoPointerInfo(candidate); - const pointerKey = typeof candidate.pointerKey === "string" && candidate.pointerKey.trim() - ? candidate.pointerKey.trim() - : typeof pointerInfo?.key === "string" && pointerInfo.key - ? pointerInfo.key - : ""; - - const dedupeKeyRaw = (candidateId || pointerKey || "").trim(); - if (dedupeKeyRaw) { - const dedupeKey = dedupeKeyRaw.toLowerCase(); - if (seenKeys.has(dedupeKey)) { - continue; - } - seenKeys.add(dedupeKey); - } - - if (!Array.isArray(candidate.displayTags) || candidate.displayTags.length === 0) { - candidate.displayTags = Array.isArray(candidateTagsSource) - ? candidateTagsSource.slice() - : []; - } - - let postedAt = this.getKnownVideoPostedAt(decoratedCandidate); - if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) { - postedAt = Math.floor(decoratedCandidate.rootCreatedAt); - } - if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) { - postedAt = Math.floor(decoratedCandidate.created_at); - } - if (!Number.isFinite(postedAt)) { - postedAt = null; - } - - let shareUrl = ""; - if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) { - shareUrl = decoratedCandidate.shareUrl.trim(); - } else if (candidateId) { - shareUrl = this.buildShareUrlFromEventId(candidateId) || ""; - } - - results.push({ - video: decoratedCandidate, - pointerInfo: pointerInfo || null, - shareUrl, - postedAt, - sharedTagCount: sharedCount, + computeSimilarContentCandidates(options = {}) { + if (this.similarContentController) { + return this.similarContentController.computeCandidates({ + activeVideo: options.activeVideo || this.currentVideo, + maxItems: options.maxItems, }); } - - if (results.length === 0) { - return []; - } - - results.sort((a, b) => { - if (b.sharedTagCount !== a.sharedTagCount) { - return b.sharedTagCount - a.sharedTagCount; - } - const tsA = Number.isFinite(a.postedAt) ? a.postedAt : 0; - const tsB = Number.isFinite(b.postedAt) ? b.postedAt : 0; - return tsB - tsA; - }); - - return results.slice(0, limit).map((entry) => { - const normalizedPostedAt = Number.isFinite(entry.postedAt) - ? Math.floor(entry.postedAt) - : null; - const timeAgo = normalizedPostedAt !== null ? this.formatTimeAgo(normalizedPostedAt) : ""; - return { - video: entry.video, - pointerInfo: entry.pointerInfo, - shareUrl: entry.shareUrl, - postedAt: normalizedPostedAt, - timeAgo, - sharedTagCount: entry.sharedTagCount, - }; - }); + return []; } updateModalSimilarContent({ activeVideo = this.currentVideo, maxItems } = {}) { - if (!this.videoModal) { - return; - } - - const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null; - if (!target) { - if (typeof this.videoModal.clearSimilarContent === "function") { - this.videoModal.clearSimilarContent(); - } - return; - } - - const matches = this.computeSimilarContentCandidates({ activeVideo: target, maxItems }); - if (matches.length > 0) { - if (typeof this.videoModal.setSimilarContent === "function") { - this.videoModal.setSimilarContent(matches); - } - return; - } - - if (typeof this.videoModal.clearSimilarContent === "function") { - this.videoModal.clearSimilarContent(); + if (this.similarContentController) { + this.similarContentController.ui.videoModal = this.videoModal; + this.similarContentController.updateModal({ activeVideo, maxItems }); } } diff --git a/js/ui/similarContentController.js b/js/ui/similarContentController.js new file mode 100644 index 00000000..cfc60e74 --- /dev/null +++ b/js/ui/similarContentController.js @@ -0,0 +1,332 @@ +import { devLogger } from "../utils/logger.js"; +import { collectVideoTags } from "../utils/videoTags.js"; +import { ALLOW_NSFW_CONTENT } from "../config.js"; +import resolveVideoPointer from "../utils/videoPointer.js"; + +/** + * Controller for managing "Similar Content" logic and updates for the video modal. + */ +export default class SimilarContentController { + constructor({ + services = {}, + callbacks = {}, + ui = {}, + state = {}, + helpers = {} + }) { + this.services = services; + this.callbacks = callbacks; + this.ui = ui; + this.state = state; + this.helpers = helpers; + } + + extractDTagValue(tags) { + if (!Array.isArray(tags)) { + return ""; + } + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] === "d" && typeof tag[1] === "string") { + return tag[1]; + } + } + return ""; + } + + deriveVideoPointerInfo(video) { + if (!video || typeof video !== "object") { + return null; + } + + const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); + + return resolveVideoPointer({ + kind: video.kind, + pubkey: video.pubkey, + videoRootId: video.videoRootId, + dTag: dTagValue, + fallbackEventId: video.id, + relay: video.relay, + }); + } + + computeCandidates({ activeVideo, maxItems = 5 } = {}) { + const decorateCandidate = (video) => { + if (!video || typeof video !== "object") { + return video; + } + let decoratedVideo = video; + if (typeof this.callbacks.decorateVideoModeration === "function") { + try { + const decorated = this.callbacks.decorateVideoModeration(video); + if (decorated && typeof decorated === "object") { + decoratedVideo = decorated; + } + } catch (error) { + devLogger.warn( + "[SimilarContentController] Failed to decorate similar content candidate", + error, + ); + } + } + if (typeof this.callbacks.decorateVideoCreatorIdentity === "function") { + try { + const identityDecorated = this.callbacks.decorateVideoCreatorIdentity( + decoratedVideo, + ); + if (identityDecorated && typeof identityDecorated === "object") { + decoratedVideo = identityDecorated; + } + } catch (error) { + devLogger.warn( + "[SimilarContentController] Failed to decorate similar content identity", + error, + ); + } + } + return decoratedVideo; + }; + + const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null; + if (!target) { + return []; + } + + const activeTagsSource = Array.isArray(target.displayTags) && target.displayTags.length + ? target.displayTags + : collectVideoTags(target); + + const activeTagSet = new Set(); + for (const tag of activeTagsSource) { + if (typeof tag !== "string") { + continue; + } + const normalized = tag.trim().toLowerCase(); + if (normalized) { + activeTagSet.add(normalized); + } + } + + if (activeTagSet.size === 0) { + return []; + } + + const limit = Number.isFinite(maxItems) && maxItems > 0 + ? Math.max(1, Math.floor(maxItems)) + : 5; + + let candidateSource = []; + const videoListView = this.state.getVideoListView ? this.state.getVideoListView() : null; + const videosMap = this.state.getVideosMap ? this.state.getVideosMap() : null; + const nostrClient = this.services.nostrClient; + + if (videoListView && Array.isArray(videoListView.currentVideos) && videoListView.currentVideos.length) { + candidateSource = videoListView.currentVideos; + } else if (videosMap instanceof Map && videosMap.size) { + candidateSource = Array.from(videosMap.values()); + } else if (nostrClient && typeof nostrClient.getActiveVideos === "function") { + try { + const activeVideos = nostrClient.getActiveVideos(); + if (Array.isArray(activeVideos)) { + candidateSource = activeVideos; + } + } catch (error) { + devLogger.warn("[SimilarContentController] Failed to read active videos for similar content:", error); + } + } + + if (!Array.isArray(candidateSource) || candidateSource.length === 0) { + return []; + } + + const activeId = typeof target.id === "string" ? target.id : ""; + const activePointerKey = typeof target.pointerKey === "string" ? target.pointerKey : ""; + const seenKeys = new Set(); + if (activeId) { + const normalizedId = activeId.trim().toLowerCase(); + if (normalizedId) { + seenKeys.add(normalizedId); + } + } + if (activePointerKey) { + const normalizedPointer = activePointerKey.trim().toLowerCase(); + if (normalizedPointer) { + seenKeys.add(normalizedPointer); + } + } + + const results = []; + + for (const candidate of candidateSource) { + const decoratedCandidate = decorateCandidate(candidate); + if (!decoratedCandidate || typeof decoratedCandidate !== "object") { + continue; + } + if (decoratedCandidate === target) { + continue; + } + + const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : ""; + if (candidateId && candidateId === activeId) { + continue; + } + if (decoratedCandidate.deleted === true) { + continue; + } + if (decoratedCandidate.isPrivate === true) { + continue; + } + if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) { + continue; + } + + const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : ""; + if (candidatePubkey && typeof this.callbacks.isAuthorBlocked === "function" && this.callbacks.isAuthorBlocked(candidatePubkey)) { + continue; + } + + const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length + ? decoratedCandidate.displayTags + : collectVideoTags(decoratedCandidate); + if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) { + continue; + } + + const candidateTagSet = new Set(); + for (const tag of candidateTagsSource) { + if (typeof tag !== "string") { + continue; + } + const normalized = tag.trim().toLowerCase(); + if (normalized) { + candidateTagSet.add(normalized); + } + } + + if (candidateTagSet.size === 0) { + continue; + } + + let sharedCount = 0; + for (const tag of candidateTagSet) { + if (activeTagSet.has(tag)) { + sharedCount += 1; + } + } + + if (sharedCount === 0) { + continue; + } + + const pointerInfo = this.deriveVideoPointerInfo(candidate); + const pointerKey = typeof candidate.pointerKey === "string" && candidate.pointerKey.trim() + ? candidate.pointerKey.trim() + : typeof pointerInfo?.key === "string" && pointerInfo.key + ? pointerInfo.key + : ""; + + const dedupeKeyRaw = (candidateId || pointerKey || "").trim(); + if (dedupeKeyRaw) { + const dedupeKey = dedupeKeyRaw.toLowerCase(); + if (seenKeys.has(dedupeKey)) { + continue; + } + seenKeys.add(dedupeKey); + } + + if (!Array.isArray(candidate.displayTags) || candidate.displayTags.length === 0) { + candidate.displayTags = Array.isArray(candidateTagsSource) + ? candidateTagsSource.slice() + : []; + } + + let postedAt = null; + if (typeof this.helpers.getKnownVideoPostedAt === "function") { + postedAt = this.helpers.getKnownVideoPostedAt(decoratedCandidate); + } + if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) { + postedAt = Math.floor(decoratedCandidate.rootCreatedAt); + } + if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) { + postedAt = Math.floor(decoratedCandidate.created_at); + } + if (!Number.isFinite(postedAt)) { + postedAt = null; + } + + let shareUrl = ""; + if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) { + shareUrl = decoratedCandidate.shareUrl.trim(); + } else if (candidateId && typeof this.helpers.buildShareUrlFromEventId === "function") { + shareUrl = this.helpers.buildShareUrlFromEventId(candidateId) || ""; + } + + results.push({ + video: decoratedCandidate, + pointerInfo: pointerInfo || null, + shareUrl, + postedAt, + sharedTagCount: sharedCount, + }); + } + + if (results.length === 0) { + return []; + } + + results.sort((a, b) => { + if (b.sharedTagCount !== a.sharedTagCount) { + return b.sharedTagCount - a.sharedTagCount; + } + const tsA = Number.isFinite(a.postedAt) ? a.postedAt : 0; + const tsB = Number.isFinite(b.postedAt) ? b.postedAt : 0; + return tsB - tsA; + }); + + return results.slice(0, limit).map((entry) => { + const normalizedPostedAt = Number.isFinite(entry.postedAt) + ? Math.floor(entry.postedAt) + : null; + const timeAgo = normalizedPostedAt !== null && typeof this.helpers.formatTimeAgo === "function" + ? this.helpers.formatTimeAgo(normalizedPostedAt) + : ""; + return { + video: entry.video, + pointerInfo: entry.pointerInfo, + shareUrl: entry.shareUrl, + postedAt: normalizedPostedAt, + timeAgo, + sharedTagCount: entry.sharedTagCount, + }; + }); + } + + updateModal({ activeVideo, maxItems } = {}) { + if (!this.ui.videoModal) { + return; + } + + const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null; + if (!target) { + if (typeof this.ui.videoModal.clearSimilarContent === "function") { + this.ui.videoModal.clearSimilarContent(); + } + return; + } + + const matches = this.computeCandidates({ activeVideo: target, maxItems }); + if (matches.length > 0) { + if (typeof this.ui.videoModal.setSimilarContent === "function") { + this.ui.videoModal.setSimilarContent(matches); + } + return; + } + + if (typeof this.ui.videoModal.clearSimilarContent === "function") { + this.ui.videoModal.clearSimilarContent(); + } + } +} diff --git a/tests/ui/similarContentController.test.mjs b/tests/ui/similarContentController.test.mjs new file mode 100644 index 00000000..3c339642 --- /dev/null +++ b/tests/ui/similarContentController.test.mjs @@ -0,0 +1,164 @@ + +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; +import SimilarContentController from "../../js/ui/similarContentController.js"; + +describe("SimilarContentController", () => { + let controller; + let services; + let callbacks; + let ui; + let state; + let helpers; + + beforeEach(() => { + services = { + nostrClient: { + getActiveVideos: mock.fn(() => []), + }, + }; + callbacks = { + isAuthorBlocked: mock.fn(() => false), + decorateVideoModeration: mock.fn((v) => v), + decorateVideoCreatorIdentity: mock.fn((v) => v), + }; + ui = { + videoModal: { + setSimilarContent: mock.fn(), + clearSimilarContent: mock.fn(), + }, + }; + state = { + getVideoListView: mock.fn(() => ({ currentVideos: [] })), + getVideosMap: mock.fn(() => new Map()), + }; + helpers = { + getKnownVideoPostedAt: mock.fn(() => Date.now()), + buildShareUrlFromEventId: mock.fn((id) => `https://example.com/v/${id}`), + formatTimeAgo: mock.fn(() => "just now"), + }; + + controller = new SimilarContentController({ + services, + callbacks, + ui, + state, + helpers, + }); + }); + + describe("extractDTagValue", () => { + it("should return the 'd' tag value", () => { + const tags = [["e", "123"], ["d", "my-d-tag"]]; + const result = controller.extractDTagValue(tags); + assert.strictEqual(result, "my-d-tag"); + }); + + it("should return empty string if no 'd' tag", () => { + const tags = [["e", "123"]]; + const result = controller.extractDTagValue(tags); + assert.strictEqual(result, ""); + }); + }); + + describe("computeCandidates", () => { + it("should return empty array if no active video", () => { + const result = controller.computeCandidates(); + assert.deepStrictEqual(result, []); + }); + + it("should compute similar candidates based on tags", () => { + const activeVideo = { + id: "active", + displayTags: ["fun", "music"], + pubkey: "abc" + }; + const candidate1 = { + id: "c1", + displayTags: ["fun", "other"], + pubkey: "def" + }; + const candidate2 = { + id: "c2", + displayTags: ["boring"], + pubkey: "ghi" + }; + + state.getVideoListView.mock.mockImplementationOnce(() => ({ + currentVideos: [candidate1, candidate2] + })); + + const result = controller.computeCandidates({ activeVideo }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].video.id, "c1"); + assert.strictEqual(result[0].sharedTagCount, 1); + }); + + it("should exclude blocked authors", () => { + const activeVideo = { id: "active", displayTags: ["fun"] }; + const candidate1 = { id: "c1", displayTags: ["fun"], pubkey: "bad-guy" }; + + state.getVideoListView.mock.mockImplementationOnce(() => ({ + currentVideos: [candidate1] + })); + + callbacks.isAuthorBlocked.mock.mockImplementationOnce((pubkey) => pubkey === "bad-guy"); + + const result = controller.computeCandidates({ activeVideo }); + assert.strictEqual(result.length, 0); + }); + + it("should prioritize higher shared tag count", () => { + const activeVideo = { id: "active", displayTags: ["a", "b", "c"] }; + const candidate1 = { id: "c1", displayTags: ["a"] }; + const candidate2 = { id: "c2", displayTags: ["a", "b"] }; + + state.getVideoListView.mock.mockImplementationOnce(() => ({ + currentVideos: [candidate1, candidate2] + })); + + const result = controller.computeCandidates({ activeVideo }); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].video.id, "c2"); // 2 shared tags + assert.strictEqual(result[1].video.id, "c1"); // 1 shared tag + }); + }); + + describe("updateModal", () => { + it("should clear similar content if no active video", () => { + controller.updateModal(); + assert.strictEqual(ui.videoModal.clearSimilarContent.mock.callCount(), 1); + }); + + it("should set similar content if matches found", () => { + const activeVideo = { id: "active", displayTags: ["fun"] }; + const candidate1 = { id: "c1", displayTags: ["fun"] }; + + state.getVideoListView.mock.mockImplementationOnce(() => ({ + currentVideos: [candidate1] + })); + + controller.updateModal({ activeVideo }); + + assert.strictEqual(ui.videoModal.setSimilarContent.mock.callCount(), 1); + const args = ui.videoModal.setSimilarContent.mock.calls[0].arguments; + assert.strictEqual(args[0].length, 1); + assert.strictEqual(args[0][0].video.id, "c1"); + }); + + it("should clear similar content if no matches found", () => { + const activeVideo = { id: "active", displayTags: ["fun"] }; + const candidate1 = { id: "c1", displayTags: ["boring"] }; + + state.getVideoListView.mock.mockImplementationOnce(() => ({ + currentVideos: [candidate1] + })); + + controller.updateModal({ activeVideo }); + + assert.strictEqual(ui.videoModal.clearSimilarContent.mock.callCount(), 1); + }); + }); + +});