|
| 1 | +const fs = require("fs"); |
| 2 | +const path = require("path"); |
| 3 | +const vm = require("vm"); |
| 4 | + |
| 5 | +describe("Search Widget Listener Fix", () => { |
| 6 | + let activity; |
| 7 | + let Activity; |
| 8 | + let sandbox; |
| 9 | + |
| 10 | + beforeAll(() => { |
| 11 | + const activityPath = path.resolve(__dirname, "../activity.js"); |
| 12 | + let code = fs.readFileSync(activityPath, "utf8"); |
| 13 | + |
| 14 | + // Strip the trailing instantiation |
| 15 | + const splitPoint = code.indexOf("const activity = new Activity();"); |
| 16 | + if (splitPoint !== -1) { |
| 17 | + code = code.substring(0, splitPoint); |
| 18 | + } |
| 19 | + |
| 20 | + const elementCache = {}; |
| 21 | + const jQueryMock = jest.fn(() => ({ |
| 22 | + data: jest.fn(() => false), |
| 23 | + autocomplete: jest.fn(), |
| 24 | + on: jest.fn(), |
| 25 | + off: jest.fn() |
| 26 | + })); |
| 27 | + jQueryMock.fn = jQueryMock; |
| 28 | + |
| 29 | + sandbox = { |
| 30 | + window: global.window, |
| 31 | + jQuery: jQueryMock, |
| 32 | + $: jQueryMock, |
| 33 | + document: { |
| 34 | + getElementById: jest.fn(id => { |
| 35 | + if (elementCache[id]) return elementCache[id]; |
| 36 | + |
| 37 | + let el; |
| 38 | + if (id === "search") { |
| 39 | + el = { |
| 40 | + style: { visibility: "hidden" }, |
| 41 | + contains: jest.fn(target => target === el), |
| 42 | + focus: jest.fn(), |
| 43 | + addEventListener: jest.fn(), |
| 44 | + value: "" |
| 45 | + }; |
| 46 | + } else if (id === "ui-id-1") { |
| 47 | + el = { |
| 48 | + style: { display: "none" }, |
| 49 | + contains: jest.fn(target => target === el), |
| 50 | + addEventListener: jest.fn() |
| 51 | + }; |
| 52 | + } else if (id === "myCanvas") { |
| 53 | + el = { addEventListener: jest.fn() }; |
| 54 | + } else { |
| 55 | + el = { |
| 56 | + style: { visibility: "hidden", display: "none" }, |
| 57 | + addEventListener: jest.fn(), |
| 58 | + appendChild: jest.fn(), |
| 59 | + contains: jest.fn(target => target === el) |
| 60 | + }; |
| 61 | + } |
| 62 | + elementCache[id] = el; |
| 63 | + return el; |
| 64 | + }), |
| 65 | + getElementsByTagName: jest.fn(name => { |
| 66 | + if (name === "tr") { |
| 67 | + const tr = { contains: jest.fn(() => false) }; |
| 68 | + return [{}, {}, tr]; |
| 69 | + } |
| 70 | + return []; |
| 71 | + }), |
| 72 | + addEventListener: jest.fn(), |
| 73 | + removeEventListener: jest.fn(), |
| 74 | + body: { appendChild: jest.fn() }, |
| 75 | + querySelector: jest.fn(() => null) |
| 76 | + }, |
| 77 | + console: { log: jest.fn(), debug: jest.fn(), error: jest.fn() }, |
| 78 | + _: key => key, |
| 79 | + define: jest.fn(), |
| 80 | + require: jest.fn(), |
| 81 | + setTimeout: jest.fn(cb => cb()), |
| 82 | + setInterval: jest.fn(), |
| 83 | + requestAnimationFrame: jest.fn(), |
| 84 | + cancelAnimationFrame: jest.fn(), |
| 85 | + createjs: { |
| 86 | + Stage: class { on() { } removeAllEventListeners() { } addChild() { } getBounds() { return { x: 0, y: 0, width: 0, height: 0 }; } }, |
| 87 | + Container: class { addChild() { } on() { } cache() { } getBounds() { return { x: 0, y: 0, width: 0, height: 0 }; } }, |
| 88 | + Text: class { }, |
| 89 | + Bitmap: class { }, |
| 90 | + Ticker: { framerate: 60 } |
| 91 | + }, |
| 92 | + Image: class { set src(val) { if (this.onload) this.onload(); } }, |
| 93 | + docByClass: jest.fn(() => []), |
| 94 | + docById: jest.fn(id => sandbox.document.getElementById(id)), |
| 95 | + base64Encode: jest.fn(), |
| 96 | + MSGBLOCK: "", |
| 97 | + ERRORARTWORK: [], |
| 98 | + LEADING: 0, |
| 99 | + MYDEFINES: [], |
| 100 | + _THIS_IS_MUSIC_BLOCKS_: true, |
| 101 | + |
| 102 | + Turtles: class { constructor() { this.running = () => false; } }, |
| 103 | + Palettes: class { constructor() { this.getSearchPos = () => [0, 0]; } }, |
| 104 | + Blocks: class { constructor() { this.protoBlockDict = {}; } }, |
| 105 | + Logo: class { }, |
| 106 | + LanguageBox: class { }, |
| 107 | + ThemeBox: class { updateThemeIcon() { } }, |
| 108 | + SaveInterface: class { }, |
| 109 | + StatsWindow: class { }, |
| 110 | + Trashcan: class { }, |
| 111 | + PasteBox: class { }, |
| 112 | + HelpWidget: class { }, |
| 113 | + }; |
| 114 | + |
| 115 | + code = "const localStorage = this.localStorage;\n" + |
| 116 | + "const jQuery = this.jQuery;\n" + |
| 117 | + "const $ = this.$;\n" + code; |
| 118 | + code += "\n this.Activity = Activity;"; |
| 119 | + |
| 120 | + vm.createContext(sandbox); |
| 121 | + vm.runInContext(code, sandbox); |
| 122 | + Activity = sandbox.Activity; |
| 123 | + }); |
| 124 | + |
| 125 | + beforeEach(() => { |
| 126 | + // Suppress constructor noise |
| 127 | + Activity.prototype.prepSearchWidget = jest.fn(); |
| 128 | + Activity.prototype._create2Ddrag = jest.fn(); |
| 129 | + Activity.prototype._createDrag = jest.fn(); |
| 130 | + Activity.prototype._createGrid = jest.fn(); |
| 131 | + Activity.prototype._createMsgContainer = jest.fn(); |
| 132 | + Activity.prototype._createErrorContainers = jest.fn(); |
| 133 | + Activity.prototype._initIdleWatcher = jest.fn(); |
| 134 | + |
| 135 | + activity = new Activity(); |
| 136 | + activity.turtleBlocksScale = 1; |
| 137 | + |
| 138 | + // Fresh mocks for each instance |
| 139 | + activity.addEventListener = jest.fn(function (target, type, listener) { |
| 140 | + if (target && target.addEventListener) { |
| 141 | + target.addEventListener(type, listener); |
| 142 | + } |
| 143 | + if (!this._listeners) this._listeners = []; |
| 144 | + this._listeners.push({ target, type, listener }); |
| 145 | + }); |
| 146 | + activity.removeEventListener = jest.fn(function (target, type, listener) { |
| 147 | + if (target && target.removeEventListener) { |
| 148 | + target.removeEventListener(type, listener); |
| 149 | + } |
| 150 | + }); |
| 151 | + |
| 152 | + // Re-inject mocks that might be overwritten or needed |
| 153 | + activity.searchWidget = sandbox.document.getElementById("search"); |
| 154 | + activity.palettes = new sandbox.Palettes(); |
| 155 | + activity.doSearch = jest.fn(); |
| 156 | + |
| 157 | + sandbox.document.addEventListener.mockClear(); |
| 158 | + sandbox.document.removeEventListener.mockClear(); |
| 159 | + }); |
| 160 | + |
| 161 | + test("showSearchWidget should add a mousedown listener", () => { |
| 162 | + try { |
| 163 | + activity.showSearchWidget(); |
| 164 | + } catch (e) { |
| 165 | + console.error("showSearchWidget crash:", e); |
| 166 | + throw e; |
| 167 | + } |
| 168 | + |
| 169 | + const addCalls = activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown"); |
| 170 | + expect(addCalls.length).toBe(1); |
| 171 | + }); |
| 172 | + |
| 173 | + test("showSearchWidget should NOT accumulate listeners when toggled", () => { |
| 174 | + activity.showSearchWidget(); // Toggles ON |
| 175 | + expect(activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1); |
| 176 | + |
| 177 | + activity.showSearchWidget(); // Toggles OFF |
| 178 | + |
| 179 | + expect(activity.removeEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1); |
| 180 | + // addEventListener should NOT have been called again during toggle OFF |
| 181 | + expect(activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1); |
| 182 | + }); |
| 183 | + |
| 184 | + test("hideSearchWidget should remove the listener via wrapper", () => { |
| 185 | + activity.showSearchWidget(); |
| 186 | + |
| 187 | + // Capture the listener added |
| 188 | + const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2]; |
| 189 | + |
| 190 | + activity.hideSearchWidget(); |
| 191 | + |
| 192 | + expect(activity.removeEventListener).toHaveBeenCalledWith(sandbox.document, "mousedown", listener); |
| 193 | + }); |
| 194 | + |
| 195 | + test("clicking inside search should NOT remove listener", () => { |
| 196 | + activity.showSearchWidget(); |
| 197 | + const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2]; |
| 198 | + |
| 199 | + const searchElem = activity.searchWidget; |
| 200 | + searchElem.style.visibility = "visible"; |
| 201 | + searchElem.contains.mockReturnValue(true); |
| 202 | + |
| 203 | + listener({ target: searchElem }); |
| 204 | + |
| 205 | + // Verify removeEventListener was NOT called for this listener |
| 206 | + const removeCalls = activity.removeEventListener.mock.calls.filter(c => c[2] === listener); |
| 207 | + expect(removeCalls.length).toBe(0); |
| 208 | + }); |
| 209 | + |
| 210 | + test("clicking outside search SHOULD remove listener and hide widget", () => { |
| 211 | + activity.showSearchWidget(); |
| 212 | + const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2]; |
| 213 | + const hideSpy = jest.spyOn(activity, "hideSearchWidget"); |
| 214 | + |
| 215 | + // Clicked outside |
| 216 | + listener({ target: {} }); |
| 217 | + |
| 218 | + expect(hideSpy).toHaveBeenCalled(); |
| 219 | + expect(activity.removeEventListener).toHaveBeenCalledWith(sandbox.document, "mousedown", listener); |
| 220 | + hideSpy.mockRestore(); |
| 221 | + }); |
| 222 | +}); |
0 commit comments