diff --git a/js/widgets/__tests__/reflection.test.js b/js/widgets/__tests__/reflection.test.js new file mode 100644 index 0000000000..2e375b781f --- /dev/null +++ b/js/widgets/__tests__/reflection.test.js @@ -0,0 +1,602 @@ +/** + * MusicBlocks + * + * @author kh-ub-ayb + * + * @copyright 2026 kh-ub-ayb + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const fs = require("fs"); +const path = require("path"); + +// Load the ReflectionMatrix class by reading the source and evaluating it +const source = fs.readFileSync(path.resolve(__dirname, "../reflection.js"), "utf-8"); +// We put ReflectionMatrix in global scope +new Function( + source + "\nif (typeof global !== 'undefined') { global.ReflectionMatrix = ReflectionMatrix; }" +)(); + +// Mock globals +global._ = str => str; + +// Mock window.widgetWindows and widget +function createMockWidgetWindow() { + const widgetBody = document.createElement("div"); + return { + clear: jest.fn(), + show: jest.fn(), + onclose: null, + addButton: jest.fn(() => { + const btn = document.createElement("button"); + return btn; + }), + getWidgetBody: jest.fn(() => widgetBody), + destroy: jest.fn() + }; +} + +let mockWidgetWindow; +let mockActivity; + +beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockWidgetWindow = createMockWidgetWindow(); + window.widgetWindows = { + windowFor: jest.fn(() => mockWidgetWindow) + }; + + mockActivity = { + isInputON: false, + textMsg: jest.fn(), + errorMsg: jest.fn(), + prepareExport: jest.fn().mockResolvedValue("mocked_code") + }; + + // Keep original fetch and localStorage just in case, but usually we mock them + global.fetch = jest.fn(); + + // Mock URL.createObjectURL/revokeObjectURL + global.URL.createObjectURL = jest.fn(() => "blob:mock-url"); + global.URL.revokeObjectURL = jest.fn(); +}); + +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe("ReflectionMatrix", () => { + describe("Constructor and Constants", () => { + test("Initializes constants correctly", () => { + expect(ReflectionMatrix.BUTTONDIVWIDTH).toBe(535); + expect(ReflectionMatrix.OUTERWINDOWWIDTH).toBe("858px"); + expect(ReflectionMatrix.OUTERWINDOWHEIGHT).toBe("550px"); + expect(ReflectionMatrix.INNERWINDOWWIDTH).toBe(730); + expect(ReflectionMatrix.BUTTONSIZE).toBe(53); + expect(ReflectionMatrix.ICONSIZE).toBe(32); + }); + + test("Initializes constructor properties correctly", () => { + const reflection = new ReflectionMatrix(); + expect(reflection.chatHistory).toEqual([]); + expect(reflection.AImentor).toBe("meta"); + expect(reflection.mentorsMap).toEqual({ + user: "YOU", + meta: "ROHAN", + music: "BEETHOVEN", + code: "ALAN" + }); + expect(reflection.triggerFirst).toBe(false); + expect(reflection.projectAlgorithm).toBe(""); + expect(reflection.code).toBe(""); + }); + }); + + describe("init()", () => { + test("Sets up widget window and basic properties", () => { + const reflection = new ReflectionMatrix(); + + // Spy on class methods called during init + jest.spyOn(reflection, "changeMentor"); + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(reflection.activity).toBe(mockActivity); + expect(reflection.isOpen).toBe(true); + expect(reflection.isMaximized).toBe(false); + expect(mockActivity.isInputON).toBe(true); + expect(reflection.PORT).toBe("http://3.105.177.138:8000"); + + expect(window.widgetWindows.windowFor).toHaveBeenCalledWith( + reflection, + "reflection", + "reflection" + ); + expect(mockWidgetWindow.clear).toHaveBeenCalled(); + expect(mockWidgetWindow.show).toHaveBeenCalled(); + + const body = mockWidgetWindow.getWidgetBody(); + expect(body.style.height).toBe(ReflectionMatrix.OUTERWINDOWHEIGHT); + expect(body.style.width).toBe(ReflectionMatrix.OUTERWINDOWWIDTH); + }); + + test("Sets up onclose handler", () => { + const reflection = new ReflectionMatrix(); + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(mockWidgetWindow.onclose).not.toBeNull(); + + // Trigger close + reflection.dotsInterval = setInterval(() => {}, 1000); + mockWidgetWindow.onclose(); + + expect(reflection.isOpen).toBe(false); + expect(mockActivity.isInputON).toBe(false); + expect(mockWidgetWindow.destroy).toHaveBeenCalled(); + }); + + test("Creates buttons with appropriate actions", () => { + const reflection = new ReflectionMatrix(); + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + jest.spyOn(reflection, "getAnalysis").mockImplementation(() => {}); + jest.spyOn(reflection, "downloadAsTxt").mockImplementation(() => {}); + jest.spyOn(reflection, "changeMentor"); + jest.spyOn(reflection, "updateProjectCode").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(mockWidgetWindow.addButton).toHaveBeenCalledTimes(6); + + // Trigger summary button + reflection.summaryButton.onclick(); + expect(reflection.getAnalysis).toHaveBeenCalled(); + + // Trigger meta button + reflection.metaButton.onclick(); + expect(reflection.changeMentor).toHaveBeenCalledWith("meta"); + + // Trigger code button + reflection.codeButton.onclick(); + expect(reflection.changeMentor).toHaveBeenCalledWith("code"); + + // Trigger music button + reflection.musicButton.onclick(); + expect(reflection.changeMentor).toHaveBeenCalledWith("music"); + + // Trigger reload button + reflection.reloadButton.onclick(); + expect(reflection.updateProjectCode).toHaveBeenCalled(); + }); + + test("Sets up input field and chat interface", () => { + const reflection = new ReflectionMatrix(); + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + jest.spyOn(reflection, "sendMessage").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(reflection.chatInterface).toBeDefined(); + expect(reflection.chatLog).toBeDefined(); + expect(reflection.inputContainer).toBeDefined(); + expect(reflection.input).toBeDefined(); + + // Test enter key + reflection.input.onkeydown({ key: "Enter" }); + expect(reflection.sendMessage).toHaveBeenCalled(); + }); + + test("Calls startChatSession if history is empty", () => { + const reflection = new ReflectionMatrix(); + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + jest.spyOn(reflection, "renderChatHistory").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(reflection.startChatSession).toHaveBeenCalled(); + expect(reflection.renderChatHistory).not.toHaveBeenCalled(); + expect(reflection.inputContainer.style.display).toBe("none"); + }); + + test("Calls renderChatHistory if history exists", () => { + const reflection = new ReflectionMatrix(); + reflection.chatHistory = [{ role: "user", content: "Hi" }]; + jest.spyOn(reflection, "startChatSession").mockImplementation(() => {}); + jest.spyOn(reflection, "renderChatHistory").mockImplementation(() => {}); + + reflection.init(mockActivity); + + expect(reflection.startChatSession).not.toHaveBeenCalled(); + expect(reflection.renderChatHistory).toHaveBeenCalled(); + expect(reflection.inputContainer.style.display).toBe("flex"); + }); + }); + + describe("UI Methods", () => { + test("showTypingIndicator creates indicator and animates dots", () => { + const reflection = new ReflectionMatrix(); + reflection.chatLog = document.createElement("div"); + + reflection.showTypingIndicator("Thinking"); + + expect(reflection.typingDiv).toBeDefined(); + expect(reflection.typingDiv.textContent).toContain("Thinking"); + expect(reflection.dotsContainer).toBeDefined(); + + // Fast forward timers for animations + jest.advanceTimersByTime(500); + expect(reflection.dotsContainer.textContent).toBe("."); + jest.advanceTimersByTime(500); + expect(reflection.dotsContainer.textContent).toBe(".."); + jest.advanceTimersByTime(500); + expect(reflection.dotsContainer.textContent).toBe("..."); + jest.advanceTimersByTime(500); + expect(reflection.dotsContainer.textContent).toBe(""); + }); + + test("hideTypingIndicator removes indicator and clears interval", () => { + const reflection = new ReflectionMatrix(); + reflection.chatLog = document.createElement("div"); + + reflection.showTypingIndicator("Thinking"); + const typingDiv = reflection.typingDiv; + const removeSpy = jest.spyOn(typingDiv, "remove"); + + reflection.hideTypingIndicator(); + + expect(removeSpy).toHaveBeenCalled(); + expect(reflection.typingDiv).toBeNull(); + }); + + test("changeMentor updates active mentor and button colors", () => { + const reflection = new ReflectionMatrix(); + reflection.metaButton = { style: { removeProperty: jest.fn() } }; + reflection.codeButton = { style: { removeProperty: jest.fn() } }; + reflection.musicButton = { style: { removeProperty: jest.fn() } }; + + reflection.changeMentor("code"); + expect(reflection.AImentor).toBe("code"); + expect(reflection.codeButton.style.background).toBe("orange"); + expect(reflection.metaButton.style.removeProperty).toHaveBeenCalledWith("background"); + + reflection.changeMentor("music"); + expect(reflection.AImentor).toBe("music"); + expect(reflection.musicButton.style.background).toBe("orange"); + expect(reflection.codeButton.style.removeProperty).toHaveBeenCalledWith("background"); + }); + }); + + describe("Chat Logic and API Calls", () => { + // We'll mock botReplyDiv and show/hide indicators extensively + let reflection; + + beforeEach(() => { + reflection = new ReflectionMatrix(); + reflection.activity = mockActivity; + reflection.inputContainer = document.createElement("div"); + reflection.chatLog = document.createElement("div"); + reflection.input = document.createElement("input"); + reflection.summaryButton = document.createElement("button"); + + jest.spyOn(reflection, "showTypingIndicator").mockImplementation(() => {}); + jest.spyOn(reflection, "hideTypingIndicator").mockImplementation(() => {}); + jest.spyOn(reflection, "botReplyDiv").mockImplementation(() => {}); + }); + + test("startChatSession handles successful API response", async () => { + jest.spyOn(reflection, "generateAlgorithm").mockResolvedValue({ + algorithm: "mocked_algorithm", + response: "Hello" + }); + + await reflection.startChatSession(); + + expect(reflection.triggerFirst).toBe(true); + expect(mockActivity.prepareExport).toHaveBeenCalled(); + expect(reflection.generateAlgorithm).toHaveBeenCalledWith("mocked_code"); + expect(reflection.inputContainer.style.display).toBe("flex"); + expect(reflection.botReplyDiv).toHaveBeenCalledWith( + { + algorithm: "mocked_algorithm", + response: "Hello" + }, + false, + false + ); + expect(reflection.projectAlgorithm).toBe("mocked_algorithm"); + expect(reflection.code).toBe("mocked_code"); + }); + + test("startChatSession handles API error", async () => { + jest.spyOn(reflection, "generateAlgorithm").mockResolvedValue({ + error: "Network error" + }); + + await reflection.startChatSession(); + + expect(reflection.inputContainer.style.display).not.toBe("flex"); // Should not show + expect(mockActivity.errorMsg).toHaveBeenCalledWith("Network error", 3000); + expect(reflection.botReplyDiv).not.toHaveBeenCalled(); + }); + + test("updateProjectCode skips if code unchanged", async () => { + reflection.code = "mocked_code"; // Same as prepareExport mock + jest.spyOn(reflection, "generateNewAlgorithm"); + + await reflection.updateProjectCode(); + + expect(reflection.generateNewAlgorithm).not.toHaveBeenCalled(); + }); + + test("updateProjectCode updates algorithm and calls botReplyDiv on success", async () => { + reflection.code = "old_code"; + jest.spyOn(reflection, "generateNewAlgorithm").mockResolvedValue({ + algorithm: "new_algorithm", + response: "Updated" + }); + + await reflection.updateProjectCode(); + + expect(reflection.generateNewAlgorithm).toHaveBeenCalledWith("mocked_code"); + expect(reflection.projectAlgorithm).toBe("new_algorithm"); + expect(reflection.code).toBe("mocked_code"); + expect(reflection.botReplyDiv).toHaveBeenCalledWith( + { + algorithm: "new_algorithm", + response: "Updated" + }, + false, + false + ); + }); + + test("generateAlgorithm makes correct API call", async () => { + global.fetch.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ algorithm: "alg" }) + }); + + const data = await reflection.generateAlgorithm("some_code"); + + expect(global.fetch).toHaveBeenCalledWith(`${reflection.PORT}/projectcode`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "some_code" }) + }); + expect(data).toEqual({ algorithm: "alg" }); + }); + + test("generateAlgorithm catches errors", async () => { + global.fetch.mockRejectedValue(new Error("Net Error")); + + const data = await reflection.generateAlgorithm("some_code"); + + expect(data).toEqual({ error: "Failed to send message" }); + }); + + test("generateNewAlgorithm makes correct API call", async () => { + reflection.code = "old_code"; + global.fetch.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ algorithm: "new_alg" }) + }); + + const data = await reflection.generateNewAlgorithm("new_code"); + + expect(global.fetch).toHaveBeenCalledWith(`${reflection.PORT}/updatecode`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oldcode: "old_code", newcode: "new_code" }) + }); + expect(data).toEqual({ algorithm: "new_alg" }); + }); + + test("generateBotReply makes correct API call", async () => { + global.fetch.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ response: "AI reply" }) + }); + + const data = await reflection.generateBotReply("msg", [], "meta", "alg"); + + expect(reflection.showTypingIndicator).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith(`${reflection.PORT}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "msg", + messages: [], + mentor: "meta", + algorithm: "alg" + }) + }); + expect(reflection.hideTypingIndicator).toHaveBeenCalled(); + expect(data).toEqual({ response: "AI reply" }); + }); + + test("getAnalysis calls generateAnalysis and saveReport if length >= 10", async () => { + reflection.chatHistory = new Array(10).fill({}); + jest.spyOn(reflection, "generateAnalysis").mockResolvedValue({ + response: "Analysis body" + }); + jest.spyOn(reflection, "saveReport").mockImplementation(() => {}); + + await reflection.getAnalysis(); + + expect(reflection.generateAnalysis).toHaveBeenCalled(); + expect(reflection.botReplyDiv).toHaveBeenCalledWith( + { response: "Analysis body" }, + false, + true + ); + expect(reflection.saveReport).toHaveBeenCalledWith({ response: "Analysis body" }); + }); + + test("sendMessage skips if input is empty", () => { + reflection.input.value = " "; + reflection.sendMessage(); + + expect(reflection.chatHistory).toEqual([]); + }); + + test("sendMessage adds user msg to history, updates UI, and triggers botReplyDiv", () => { + reflection.input.value = "Hello AI"; + reflection.sendMessage(); + + // Check history + expect(reflection.chatHistory.length).toBe(1); + expect(reflection.chatHistory[0]).toEqual({ role: "user", content: "Hello AI" }); + + // Check UI + expect(reflection.chatLog.childNodes.length).toBe(1); + expect(reflection.chatLog.childNodes[0].classList.contains("user")).toBe(true); + + // Input cleared + expect(reflection.input.value).toBe(""); + + // Triggered bot + expect(reflection.botReplyDiv).toHaveBeenCalledWith("Hello AI"); + }); + + test("renderChatHistory clears log and appends history", () => { + reflection.chatHistory = [ + { role: "user", content: "msg1" }, + { role: "meta", content: "msg2" } + ]; + + reflection.renderChatHistory(); + + expect(reflection.chatLog.childNodes.length).toBe(2); + expect(reflection.chatLog.childNodes[0].classList.contains("user")).toBe(true); + expect(reflection.chatLog.childNodes[1].classList.contains("user")).toBe(false); + }); + + test("saveReport stores data in localStorage", () => { + const setItemSpy = jest.spyOn(Storage.prototype, "setItem"); + + reflection.saveReport({ response: "data" }); + + expect(setItemSpy).toHaveBeenCalledWith("musicblocks_analysis", "data"); + }); + + test("readReport retrieves data from localStorage", () => { + jest.spyOn(Storage.prototype, "getItem").mockReturnValue("local_data"); + + const result = reflection.readReport(); + + expect(result).toBe("local_data"); + }); + + test("downloadAsTxt does not download if empty history", () => { + const anchorSpy = jest.spyOn(document, "createElement"); + + reflection.downloadAsTxt([]); + + expect(reflection.activity.errorMsg).toHaveBeenCalled(); + expect(anchorSpy).not.toHaveBeenCalledWith("a"); + }); + + test("downloadAsTxt creates anchor and clicks it", () => { + const mockClick = jest.fn(); + // Since we use document.createElement inside, we intercept "a" creation + const originalCreate = document.createElement.bind(document); + const spy = jest.spyOn(document, "createElement").mockImplementation(tag => { + if (tag === "a") { + const el = originalCreate(tag); + el.click = mockClick; + return el; + } + return originalCreate(tag); + }); + + reflection.downloadAsTxt([{ role: "user", content: "Hello" }]); + + expect(mockClick).toHaveBeenCalled(); + expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(global.URL.revokeObjectURL).toHaveBeenCalled(); + + spy.mockRestore(); + }); + }); + + describe("HTML Security and Markdown", () => { + let reflection; + + beforeEach(() => { + reflection = new ReflectionMatrix(); + }); + + test("escapeHTML replaces special characters", () => { + const input = "
&\"
"; + const output = reflection.escapeHTML(input); + expect(output).toBe("<div id='test'>&"</div>"); + }); + + test("isUnsafeUrl flags javascript/data/vbscript correctly", () => { + expect(reflection.isUnsafeUrl("javascript:alert(1)")).toBe(true); + expect(reflection.isUnsafeUrl("jAvAsCrIpT:alert(1)")).toBe(true); + expect(reflection.isUnsafeUrl("data:text/html, + `.trim(); + + const html = reflection.mdToHTML(markdown); + + expect(html).toContain("

Head

"); + expect(html).toContain("Bold"); + expect(html).toContain("Italic"); + expect(html).toContain( + 'Link' + ); + + // XSS script tags should be escaped, so they appear as text, not tags + expect(html).toContain("<script>alert(1)</script>"); + expect(html).not.toContain("