// ── RoomViewsSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for room-views.js — the room game-views carousel
// ([[project-room-game-views-carousel]]). The carousel navigation + composer
// are exercised end-to-end by the game_room FT (test_game_room_views.py); these
// specs pin the pure pieces of the ATLAS aggregate, which merges two on-page
// feeds (SCROLL provenance + POST thread) into one time-ordered list.
//
// Public API under test (window.RoomViews):
//   mergeAtlasRows(rows)  — stable, ISO-timestamp-ascending merge
//   renderAtlasRow(row)   — one row's HTML, carrying its data-source
//
// ─────────────────────────────────────────────────────────────────────────────

describe("RoomViews atlas merge", () => {
    it("orders rows ascending by ISO timestamp regardless of source", () => {
        const rows = [
            { source: "post",       ts: "2026-06-02T10:05:00", bodyHtml: "door creaks" },
            { source: "provenance", ts: "2026-06-02T10:01:00", bodyHtml: "deposits a Carte" },
            { source: "post",       ts: "2026-06-02T10:09:00", bodyHtml: "lets gooo" },
        ];
        const merged = window.RoomViews.mergeAtlasRows(rows);
        expect(merged.map(r => r.bodyHtml)).toEqual(
            ["deposits a Carte", "door creaks", "lets gooo"]);
    });

    it("is stable for equal timestamps (preserves input order)", () => {
        const rows = [
            { source: "provenance", ts: "2026-06-02T10:00:00", bodyHtml: "first" },
            { source: "post",       ts: "2026-06-02T10:00:00", bodyHtml: "second" },
        ];
        const merged = window.RoomViews.mergeAtlasRows(rows);
        expect(merged.map(r => r.bodyHtml)).toEqual(["first", "second"]);
    });

    it("does not mutate the input array", () => {
        const rows = [
            { source: "post",       ts: "2026-06-02T10:05:00", bodyHtml: "b" },
            { source: "provenance", ts: "2026-06-02T10:01:00", bodyHtml: "a" },
        ];
        window.RoomViews.mergeAtlasRows(rows);
        expect(rows[0].bodyHtml).toBe("b");   // still in original order
    });

    it("sinks rows with a missing timestamp to the top", () => {
        const rows = [
            { source: "provenance", ts: "2026-06-02T10:01:00", bodyHtml: "later" },
            { source: "post",       ts: "",                    bodyHtml: "undated" },
        ];
        const merged = window.RoomViews.mergeAtlasRows(rows);
        expect(merged[0].bodyHtml).toBe("undated");
    });
});

describe("RoomViews atlas row rendering", () => {
    it("tags each row with its data-source", () => {
        const prov = window.RoomViews.renderAtlasRow(
            { source: "provenance", bodyHtml: "deposits a Carte Blanche", whoText: "", timeHtml: "" });
        const post = window.RoomViews.renderAtlasRow(
            { source: "post", bodyHtml: "door creaks open", whoText: "@disco", timeHtml: "" });
        expect(prov).toContain('data-source="provenance"');
        expect(prov).toContain("deposits a Carte Blanche");
        expect(post).toContain('data-source="post"');
        expect(post).toContain("door creaks open");
    });

    it("escapes the author handle but trusts the pre-rendered body html", () => {
        const row = window.RoomViews.renderAtlasRow(
            { source: "post", bodyHtml: "<em>safe</em>", whoText: "<script>", timeHtml: "" });
        expect(row).toContain("<em>safe</em>");        // body trusted (server-escaped)
        expect(row).toContain("&lt;script&gt;");        // who escaped
    });

    it("marks struck (redacted) provenance rows so they read line-through like SCROLL", () => {
        const struck = window.RoomViews.renderAtlasRow(
            { source: "provenance", bodyHtml: "withdraws yos Carte Blanche", whoText: "", timeHtml: "", struck: true });
        expect(struck).toContain("struck");
        const plain = window.RoomViews.renderAtlasRow(
            { source: "provenance", bodyHtml: "deposits a Carte Blanche", whoText: "", timeHtml: "" });
        expect(plain).not.toContain("struck");
    });
});

// The Text sub-btn swipe machine drives the reelhouse to GAME POST. Its from-
// hex path runs the OVER-to-POST nav on a delayed (post-descent + hold)
// programmatic scroll, which headless Selenium drops — so the nav DECISION is
// pinned here against a fixture + mocked clock, where it doesn't depend on real
// scrolling. (The descent beat itself + plain goToView are covered by the FT.)
describe("RoomViews swipe machine", () => {
    let aperture, viewsEl, strip, textBtn;
    const VIEWS = ["atlas", "scroll", "yarn", "post", "pulse"];

    function tag(name, attrs) {
        const n = document.createElement(name);
        Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
        return n;
    }

    beforeEach(() => {
        jasmine.clock().install();

        aperture = tag("div", { id: "id_room_aperture" });
        const pane = tag("div", { class: "room-scroll-pane" });
        viewsEl = tag("div", { id: "id_room_views", class: "room-views reelhouse" });
        VIEWS.forEach(v => {
            const view = tag("div", { class: "room-view room-view--" + v });
            view.dataset.view = v;
            viewsEl.appendChild(view);
        });
        pane.appendChild(viewsEl);
        aperture.appendChild(pane);
        document.body.appendChild(aperture);

        strip = tag("div", { id: "id_room_views_strip" });
        VIEWS.forEach(v => {
            const ic = tag("button", { class: "room-view-icon" });
            ic.dataset.view = v;
            strip.appendChild(ic);
        });
        document.body.appendChild(strip);

        textBtn = tag("button", { id: "id_text_btn", class: "active" });
        document.body.appendChild(textBtn);

        window.RoomViews.init();   // bind the carousel + swipe machine to the fixture
    });

    afterEach(() => {
        jasmine.clock().uninstall();
        [aperture, strip, textBtn].forEach(n => n && n.remove());
    });

    function activeView() {
        const a = strip.querySelector(".room-view-icon.is-active");
        return a ? a.dataset.view : null;
    }

    it("from the hex, an active click navigates to POST after the descent + hold", () => {
        aperture.scrollTop = 0;             // up in the room (the hex)
        textBtn.click();
        // It must NOT be on POST mid-descent / mid-hold (the OVER beat waits).
        expect(activeView()).not.toBe("post");
        jasmine.clock().tick(700);          // descent settles (fallback ceiling)
        jasmine.clock().tick(500);          // the 0.5s hold
        expect(activeView()).toBe("post");  // then OVER to POST
    });

    it("an inactive Text btn does not navigate (falls through to the burger flash)", () => {
        window.RoomViews.goToView("scroll");
        textBtn.classList.remove("active");
        textBtn.click();
        jasmine.clock().tick(2000);
        expect(activeView()).toBe("scroll");
    });
});

// The ATLAS feed is built client-side by merging the live SCROLL + POST DOM, so
// it must be (re)built on EVERY path that makes ATLAS the active view. The
// desktop paths — icon-click / horizontal wheel (goToView) — always did; the
// path a native finger-SWIPE takes did NOT. A swipe reaches the carousel only
// through the IntersectionObserver → setActiveView, which never built the feed,
// so on touch devices ATLAS arrived blank ("The atlas gathers . . .") until a
// gear-OK re-ran the merge. We reproduce the swipe by driving a stubbed
// IntersectionObserver's callback (headless never fires a real intersection
// without real scrolling — see the swipe-machine note above).
describe("RoomViews atlas builds on a native swipe (IntersectionObserver path)", () => {
    let aperture, viewsEl, strip, savedIO, observers;

    function FakeIO(cb) { this.cb = cb; this.targets = []; observers.push(this); }
    FakeIO.prototype.observe = function (el) { this.targets.push(el); };
    FakeIO.prototype.unobserve = function () {};
    FakeIO.prototype.disconnect = function () {};

    function tag(name, attrs, html) {
        const n = document.createElement(name);
        Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
        if (html != null) n.innerHTML = html;
        return n;
    }

    beforeEach(() => {
        observers = [];
        savedIO = window.IntersectionObserver;
        window.IntersectionObserver = FakeIO;

        aperture = tag("div", { id: "id_room_aperture" });
        const pane = tag("div", { class: "room-scroll-pane" });
        viewsEl = tag("div", { id: "id_room_views", class: "room-views reelhouse" });

        // ATLAS — the feed starts EMPTY (no server placeholder); JS fills it.
        const atlas = tag("div", { class: "room-view room-view--atlas" });
        atlas.dataset.view = "atlas";
        atlas.appendChild(tag("div", { id: "id_room_atlas", class: "room-atlas-feed" }));
        viewsEl.appendChild(atlas);

        // SCROLL — one provenance row to merge.
        const scroll = tag("div", { class: "room-view room-view--scroll" });
        scroll.dataset.view = "scroll";
        scroll.innerHTML =
            '<div class="drama-event">' +
            '<time class="drama-event-time" datetime="2026-06-02T10:00:00">10:00</time>' +
            '<span class="drama-event-body">deposits a Carte Blanche</span></div>';
        viewsEl.appendChild(scroll);

        // POST — one thread line to merge.
        const post = tag("div", { class: "room-view room-view--post" });
        post.dataset.view = "post";
        post.innerHTML =
            '<ul id="id_post_table"><li class="post-line">' +
            '<span class="post-line-author">@disco</span>' +
            '<span class="post-line-text">door creaks open</span>' +
            '<time class="post-line-time" datetime="2026-06-02T10:05:00">10:05</time>' +
            '</li></ul>';
        viewsEl.appendChild(post);

        pane.appendChild(viewsEl);
        aperture.appendChild(pane);
        document.body.appendChild(aperture);

        strip = tag("div", { id: "id_room_views_strip" });
        ["atlas", "scroll", "post"].forEach(v => {
            const ic = tag("button", { class: "room-view-icon" });
            ic.dataset.view = v;
            strip.appendChild(ic);
        });
        document.body.appendChild(strip);

        window.RoomViews.init();   // binds the carousel + its IOs (stubbed) to the fixture
    });

    afterEach(() => {
        window.IntersectionObserver = savedIO;
        [aperture, strip].forEach(n => n && n.remove());
    });

    // Drive the active-view IO (the one watching the .room-view panes) the way a
    // native swipe would — no goToView, no gear interaction.
    function swipeTo(view) {
        const io = observers.find(o => o.targets.some(t => t.dataset && t.dataset.view));
        const target = viewsEl.querySelector(".room-view--" + view);
        io.cb([{ target: target, intersectionRatio: 1 }]);
    }

    it("populates #id_room_atlas on swipe-in — without any goToView or gear-OK", () => {
        const atlas = document.getElementById("id_room_atlas");
        expect(atlas.innerHTML).toBe("");      // SCROLL is the default land → nothing built yet

        swipeTo("atlas");                      // a finger-swipe: IO → setActiveView('atlas')

        expect(atlas.innerHTML).not.toBe("");
        expect(atlas.textContent).toContain("deposits a Carte Blanche");
        expect(atlas.textContent).toContain("door creaks open");
        expect(atlas.querySelectorAll("[data-source='provenance']").length).toBe(1);
        expect(atlas.querySelectorAll("[data-source='post']").length).toBe(1);
    });
});
