| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import { ChatScrollController } from "../lib/chatScrollController"; |
| 4 | |
| 5 | const dom = new JSDOM('<div id="scroll"><div id="content"></div></div>', { url: "http://localhost" }); |
| 6 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement }); |
| 7 | let nextFrame = 0; |
| 8 | const frames = new Map<number, FrameRequestCallback>(); |
| 9 | globalThis.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; }; |
| 10 | globalThis.cancelAnimationFrame = id => { frames.delete(id); }; |
| 11 | const callbacks: ResizeObserverCallback[] = []; |
| 12 | globalThis.ResizeObserver = class { |
| 13 | constructor(callback: ResizeObserverCallback) { callbacks.push(callback); } |
| 14 | observe() {} unobserve() {} disconnect() {} |
| 15 | } as unknown as typeof ResizeObserver; |
| 16 | const el = document.getElementById("scroll")!; |
| 17 | const content = document.getElementById("content")!; |
| 18 | let offset = 0, writes = 0; |
| 19 | Object.defineProperties(el, { |
| 20 | clientHeight: { get: () => 200 }, scrollHeight: { get: () => content.children.length * 100 }, |
| 21 | scrollTop: { get: () => offset, set: value => { writes++; offset = Math.max(0, Math.min(value, content.children.length * 100 - 200)); } }, |
| 22 | }); |
| 23 | el.getBoundingClientRect = () => ({ top: 0, bottom: 200 }) as DOMRect; |
| 24 | function row(key: string) { |
| 25 | const node = document.createElement("div"); node.dataset.chatAnchorKey = key; node.dataset.chatTurn = key; node.textContent = key; |
| 26 | node.getBoundingClientRect = () => { const top = Array.from(content.children).indexOf(node) * 100 - offset; return { top, bottom: top + 100 } as DOMRect; }; |
| 27 | return node; |
| 28 | } |
| 29 | for (let index = 0; index < 10; index++) content.append(row(`row${index}`)); |
| 30 | const controller = new ChatScrollController("scroll-test"); |
| 31 | controller.attach(el, content); controller.ready(); |
| 32 | assert.equal(offset, 800); |
| 33 | const unchanged = writes; controller.layout(); assert.equal(writes, unchanged, "no redundant physical write"); |
| 34 | el.dispatchEvent(new dom.window.WheelEvent("wheel", { deltaY: -5 })); |
| 35 | offset = 795; el.dispatchEvent(new dom.window.Event("scroll")); |
| 36 | controller.layout(); |
| 37 | assert.equal(offset, 795, "small upward movement inside 24px never snaps back"); |
| 38 | assert.equal(controller.getSnapshot().following, false); |
| 39 | offset = 450; el.dispatchEvent(new dom.window.Event("scroll")); controller.beforeChange(); |
| 40 | const held = content.children[4].getBoundingClientRect().top; |
| 41 | content.prepend(row("older")); controller.layout(); |
| 42 | assert.equal(content.children[5].getBoundingClientRect().top, held, "prepend preserves row-relative position"); |
| 43 | const reading = offset; content.append(row("tail")); controller.layout(); |
| 44 | assert.equal(offset, reading, "tail growth does not move a reader"); |
| 45 | controller.jump("row2"); assert.equal(document.querySelector('[data-chat-anchor-key="row2"]')!.getBoundingClientRect().top, 0); |
| 46 | controller.toBottom(); assert.equal(offset, 1000); |
| 47 | const callback = callbacks[0]; |
| 48 | controller.dispose(); |
| 49 | const afterDispose = writes; |
| 50 | callback([], {} as ResizeObserver); |
| 51 | frames.forEach(callback => callback(16)); |
| 52 | assert.equal(writes, afterDispose, "late observers and frames cannot write after disposal"); |
| 53 | dom.window.close(); |
| 54 | console.log("chat scroll: reader intent, prepend, growth, jump, no-op and stale observer tests passed"); |
| 55 |