Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409import { describe, it, beforeEach, afterEach, mock } from "node:test";import assert from "node:assert/strict";import { MainLayout, mainLayoutTemplate } from "/js/mainLayout.js";import { render, html } from "/js/lib/lit-html.js";import { Signal, SignalSet } from "/js/signals.js";import { makeTestPluginService } from "../testHelpers.js";
const mockUser = { did: "did:plc:testuser", handle: "testuser.bsky.social", displayName: "Test User", avatar: "https://example.com/avatar.jpg", followersCount: 100, followsCount: 50,};
function flushRender() { return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), );}
describe("MainLayout", () => { let harness;
function createHarness() { const $currentRoute = new Signal.State(null); const $currentUser = new Signal.State(mockUser); const $numNotifications = new Signal.State(0); const $numChatNotifications = new Signal.State(0); const sidebarItems = new SignalSet(); const composePost = mock.fn(); const $trends = new Signal.State(null); const loadTrends = mock.fn(async () => {}); const $hydratedPinnedItems = new Signal.State(null); const $selectedFeedUri = new Signal.State(null); const ensurePinnedItems = mock.fn(async () => []); const context = { isAuthenticated: true, dataLayer: { derived: { $currentUser, $trends, $hydratedPinnedItems, $selectedFeedUri, }, requests: { loadTrends }, declarative: { ensurePinnedItems }, }, notificationService: { $numNotifications }, chatNotificationService: { $numNotifications: $numChatNotifications }, postComposerService: { composePost }, accountSwitcherService: null, pluginService: makeTestPluginService({ getSidebarItems: () => [...sidebarItems], }), groupChatLinkService: { handleAction: mock.fn() }, interactionHandlers: { postInteractionHandler: {} }, }; const layout = new MainLayout(context, { $currentRoute }); const appRoot = document.createElement("div"); const layoutContainer = document.createElement("div"); appRoot.appendChild(layoutContainer); layout.mount(layoutContainer); return { layout, appRoot, layoutContainer, $currentRoute, $currentUser, $numNotifications, sidebarItems, composePost, $trends, loadTrends, }; }
function setRoute(options, params = {}) { harness.$currentRoute.set({ path: "/", route: "/", params, options }); }
beforeEach(() => { harness = createHarness(); });
afterEach(() => { harness.layout.dispose(); });
it("renders the chrome with the pages slot inside the center column", () => { const { layout, appRoot } = harness; const layoutRoot = appRoot.querySelector("#main-layout"); assert(layoutRoot !== null); const centerColumn = appRoot.querySelector( "[data-testid='view-column-center']", ); assert(centerColumn.contains(layout.slot)); assert(appRoot.querySelector("animated-sidebar") !== null); assert(appRoot.querySelector("[data-testid='footer-nav']") !== null); });
it("re-renders chrome on badge changes without touching cached pages", async () => { const { layout, appRoot, $numNotifications } = harness; const cachedPage = document.createElement("div"); cachedPage.className = "page"; layout.slot.appendChild(cachedPage); assert.deepEqual( appRoot.querySelectorAll("[data-testid='status-badge']").length, 0, );
$numNotifications.set(5); await flushRender();
assert(appRoot.querySelectorAll("[data-testid='status-badge']").length > 0); assert(layout.slot.children[0] === cachedPage); });
it("mounts the trending pane in the right column on every route", async () => { setRoute({ layoutOptions: { activeNavItem: "home" } }); await flushRender();
const rightColumn = harness.appRoot.querySelector(".view-column-right"); assert(rightColumn.querySelector("trending-pane") !== null);
setRoute({ layoutOptions: { activeNavItem: "chat" } }); await flushRender();
assert( harness.appRoot .querySelector(".view-column-right") .querySelector("trending-pane") !== null, ); });
it("mounts the sidebar search above trending, except on the search page", async () => { setRoute({ layoutOptions: { activeNavItem: "home" } }); await flushRender();
const rightColumn = harness.appRoot.querySelector(".view-column-right"); const elements = [...rightColumn.children].map((child) => child.tagName.toLowerCase(), ); assert.deepEqual(elements, [ "sidebar-search", "pinned-feeds-pane", "trending-pane", ]); assert( rightColumn .querySelector("pinned-feeds-pane") .hasAttribute("show-selected"), );
setRoute({ layoutOptions: { activeNavItem: "search" } }); await flushRender();
assert.deepEqual( harness.appRoot .querySelector(".view-column-right") .querySelector("sidebar-search"), null, ); });
it("derives the active nav item from the current route options", async () => { setRoute({ layoutOptions: { activeNavItem: "home" } }); await flushRender();
const homeItem = harness.appRoot.querySelector( "[data-testid='footer-nav-home']", ); assert(homeItem.classList.contains("active")); });
it("supports function-valued activeNavItem receiving route params", async () => { setRoute( { layoutOptions: { activeNavItem: (params) => params.handleOrDid === mockUser.did ? "profile" : null, }, }, { handleOrDid: mockUser.did }, ); await flushRender(); const profileItem = harness.appRoot.querySelector( "[data-testid='footer-nav-profile']", ); assert(profileItem.classList.contains("active"));
setRoute( { layoutOptions: { activeNavItem: (params) => params.handleOrDid === mockUser.did ? "profile" : null, }, }, { handleOrDid: "did:plc:someoneelse" }, ); await flushRender(); assert( !harness.appRoot .querySelector("[data-testid='footer-nav-profile']") .classList.contains("active"), ); });
it("preserves router-set container classes across chrome re-renders", async () => { const { layoutContainer, $numNotifications } = harness; layoutContainer.classList.add("layout-hidden");
$numNotifications.set(5); await flushRender();
assert(layoutContainer.classList.contains("layout-hidden")); });
it("throws when mounted a second time", () => { assert.throws( () => harness.layout.mount(document.createElement("div")), /already mounted/, ); });
it("clears its container on dispose and allows remounting", () => { harness.layout.dispose(); assert.deepEqual(harness.layoutContainer.children.length, 0);
const newContainer = document.createElement("div"); harness.layout.mount(newContainer); assert(newContainer.querySelector("animated-sidebar") !== null); });
it("opens the composer from the sidebar compose button", () => { const { appRoot, composePost } = harness; appRoot.querySelector("[data-testid='sidebar-compose-button']").click(); assert.deepEqual(composePost.mock.callCount(), 1); assert.deepEqual( composePost.mock.calls[0].arguments[0].currentUser, mockUser, ); });
it("opens the composer from the sidebar compose button on chat routes", async () => { const { appRoot, composePost } = harness; setRoute({ layoutOptions: { activeNavItem: "chat" } }); await flushRender();
appRoot.querySelector("[data-testid='sidebar-compose-button']").click(); assert.deepEqual(composePost.mock.callCount(), 1); });
it("lets a layout listener claim active nav clicks via preventDefault", async (t) => { const scrollTo = t.mock.method(window, "scrollTo", () => {}); const { layout, appRoot } = harness; setRoute({ layoutOptions: { activeNavItem: "home", isNavItemPage: true }, }); await flushRender(); const handler = mock.fn((event) => event.preventDefault()); layout.addEventListener("active-nav-click", handler);
appRoot.querySelector("[data-testid='footer-nav-home']").click();
assert.deepEqual(handler.mock.callCount(), 1); assert.deepEqual(scrollTo.mock.callCount(), 0); layout.removeEventListener("active-nav-click", handler); });
it("scrolls to top on active nav clicks nobody claims", async (t) => { const scrollTo = t.mock.method(window, "scrollTo", () => {}); setRoute({ layoutOptions: { activeNavItem: "home", isNavItemPage: true }, }); await flushRender();
harness.appRoot.querySelector("[data-testid='footer-nav-home']").click();
assert.deepEqual(scrollTo.mock.callCount(), 1); assert.deepEqual(scrollTo.mock.calls[0].arguments, [ { top: -1, behavior: "smooth" }, ]); });
it("reflects plugin sidebar item registration reactively", async () => { const { appRoot, sidebarItems } = harness; assert.deepEqual(appRoot.querySelector(".sidebar-plugin-nav-item"), null);
sidebarItems.add({ title: "My Plugin", icon: "star", invoke: () => {} }); await flushRender();
const pluginItem = appRoot.querySelector(".sidebar-plugin-nav-item"); assert(pluginItem !== null); assert(pluginItem.textContent.includes("My Plugin")); });});
const mockPluginService = makeTestPluginService();
describe("mainLayoutTemplate", () => { it("should render children in center column", () => { const result = mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: true, currentUser: mockUser, children: html`<div class="test-content">Test Content</div>`, }); const container = document.createElement("div"); render(result, container); const centerColumn = container.querySelector( "[data-testid='view-column-center']", ); assert(centerColumn.querySelector(".test-content") !== null); });});
describe("mainLayoutTemplate - pinned feeds pane", () => { it("renders the pinned feeds pane only when authenticated", () => { const container = document.createElement("div"); render( mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: true, currentUser: mockUser, children: html`<div>Content</div>`, }), container, ); assert(container.querySelector("pinned-feeds-pane") !== null);
const loggedOutContainer = document.createElement("div"); render( mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: false, currentUser: null, children: html`<div>Content</div>`, }), loggedOutContainer, ); assert.deepEqual( loggedOutContainer.querySelector("pinned-feeds-pane"), null, ); });});
describe("mainLayoutTemplate - footer", () => { it("should render footer", () => { const result = mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: true, currentUser: mockUser, children: html`<div>Content</div>`, }); const container = document.createElement("div"); render(result, container); assert(container.querySelector("[data-testid='footer-nav']") !== null); });
it("should render logged out footer when not authenticated", () => { const result = mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: false, currentUser: null, children: html`<div>Content</div>`, }); const container = document.createElement("div"); render(result, container); assert( container.querySelector("[data-testid='logged-out-footer']") !== null, ); });});
describe("mainLayoutTemplate - sidebar", () => { it("should always render the sidebar", () => { const result = mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: true, currentUser: mockUser, children: html`<div>Content</div>`, }); const container = document.createElement("div"); render(result, container); assert(container.querySelector("animated-sidebar") !== null); });});
describe("mainLayoutTemplate - notifications", () => { it("should pass notification counts to footer", () => { const result = mainLayoutTemplate({ pluginService: mockPluginService, isAuthenticated: true, currentUser: mockUser, numNotifications: 5, numChatNotifications: 3, children: html`<div>Content</div>`, }); const container = document.createElement("div"); render(result, container); // Footer should have status badges when there are notifications const badges = container.querySelectorAll("[data-testid='status-badge']"); assert(badges.length > 0); });});