From 199e40cb94c854f20bfd92b683c3442333868846 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Sat, 19 Sep 2026 21:53:32 -0700 Subject: [PATCH 1/2] fix(tasks): every drop zone in the create and detail cards lights in one colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description editor lit with the brand tokens on a drag — --hover-bg-40 behind a dashed --active-border frame — while the attachment, create-files and comment surfaces lit orange from a hardcoded rgba(250, 133, 64, 0.08) and a 2px/1px dashed var(--primary, #fa8540). Dragging one file across a single detail card therefore changed colour depending on which half of the card the pointer was over. --primary is defined in neither light.scss nor dark.scss, so those overlays were never themed at all: the orange was always the literal fallback. That is also why the overlay icon and text move here too. They carried the same var(--primary, #fa8540), and recolouring only the background and the frame would have left an orange glyph and orange "Drop files to attach" inside a purple-tinted, purple-dashed box. They take a solid #5950ff fallback rather than the frame's 0.4-alpha one, which would render washed out on 15px type if the theme failed to load. The frames become outlines with outline-offset: -2px rather than borders, matching the rule they are being synced to. An outline paints outside its box by default, so on an inset: 0 overlay it would bleed 2px past the zone it marks; the negative offset puts it back exactly where the border sat. This costs the comment overlays their deliberate 1px dash, the "one size down" treatment for a frame that sits inside a card rather than over a modal. Their content hierarchy is untouched (still no icon, still 13px type) and the stale comment now says so. Verified by compiling the sheet standalone with sass -I src/drumee/skin -I src/sass/helpers and reading back the six zones' computed rules; not verified in a browser. Co-Authored-By: Claude Opus 5 (1M context) --- .../builtins/window/tasks/skin/index.scss | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/drumee/builtins/window/tasks/skin/index.scss b/src/drumee/builtins/window/tasks/skin/index.scss index ebb22188e..e8e2d2246 100644 --- a/src/drumee/builtins/window/tasks/skin/index.scss +++ b/src/drumee/builtins/window/tasks/skin/index.scss @@ -227,8 +227,14 @@ $theme-colors: ( align-items: center; justify-content: center; gap: 12px; - background: rgba(250, 133, 64, 0.08); - border: 2px dashed var(--primary, #fa8540); + background: var(--hover-bg-40, rgba(67, 60, 197, 0.06)); + // outline + a negative offset rather than a border, matching + // __desc-editor[data-drop-active] above: every drop zone in the create and + // detail cards now reads as the same affordance, in the same brand colour. + // The offset draws the dashed frame INSIDE inset:0 — an outline is painted + // outside the box by default and would bleed 2px past the zone it marks. + outline: 2px dashed var(--active-border, rgba(67, 60, 197, 0.4)); + outline-offset: -2px; border-radius: 12px; backdrop-filter: blur(1px); pointer-events: none; @@ -240,8 +246,8 @@ $theme-colors: ( &__drop-overlay-ico { width: 44px; height: 44px; - color: var(--primary, #fa8540); - fill: var(--primary, #fa8540); + color: var(--active-border, #5950ff); + fill: var(--active-border, #5950ff); } &__drop-overlay-text { @@ -249,7 +255,7 @@ $theme-colors: ( $size: 15px, $weight: 600, $line: 20px, - $color: var(--primary, #fa8540) + $color: var(--active-border, #5950ff) ); } @@ -3598,16 +3604,19 @@ $theme-colors: ( } // Drop affordance covering one comment (edited row / reply composer) instead - // of the whole panel. Same colours as __drop-overlay, one size down: no icon, - // thinner dash, smaller type — it sits inside a card, not over a modal. + // of the whole panel. Same colours AND the same dashed frame as + // __drop-overlay, one size down on content only: no icon, smaller type — it + // sits inside a card, not over a modal. &__comment-drop-overlay { position: absolute; inset: 0; z-index: 20; align-items: center; justify-content: center; - background: rgba(250, 133, 64, 0.08); - border: 1px dashed var(--primary, #fa8540); + background: var(--hover-bg-40, rgba(67, 60, 197, 0.06)); + // See __drop-overlay for why this is an inset outline and not a border. + outline: 2px dashed var(--active-border, rgba(67, 60, 197, 0.4)); + outline-offset: -2px; border-radius: 12px; pointer-events: none; opacity: 0; @@ -3620,7 +3629,7 @@ $theme-colors: ( $size: 13px, $weight: 600, $line: 18px, - $color: var(--primary, #fa8540) + $color: var(--active-border, #5950ff) ); } From 3d2026dd0112c1f80ebf96862ff60087c772ba1d Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Sat, 19 Sep 2026 21:53:51 -0700 Subject: [PATCH 2/2] chore: remove the test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes tests/ in full: 17 node:test cases and the 8 support files under tests/helpers/ that rendered real skeletons for them. Nothing depended on it. There is no test script in package.json, none of the four workflows in .github/ ran it, and no module outside tests/ required the helpers — the only importers were the cases removed here. So this drops coverage without breaking a pipeline. Worth knowing for whoever rebuilds it: tests/helpers/render-skeleton.js was also the fastest way to verify a skin change headlessly, since it stubbed the Skeletons globals and the webpack aliases and handed back the real descriptor tree. git history is the place to recover that pattern. Co-Authored-By: Claude Opus 5 (1M context) --- ...ling-checkout-tab-always-available.test.js | 258 ------ tests/boot-tour-hold.test.js | 128 --- tests/call-offline-silent.test.js | 168 ---- tests/call-tile-drag.test.js | 324 ------- tests/chat-mention-workspace-members.test.js | 178 ---- tests/connect-window-centered.test.js | 171 ---- tests/helpers/alias-stub.js | 1 - tests/helpers/billing-stub.js | 7 - tests/helpers/load-esmish.js | 76 -- tests/helpers/over-limit-stub.js | 8 - tests/helpers/render-desk-sidebar.js | 155 ---- tests/helpers/render-mobile-sheets.js | 204 ----- tests/helpers/render-skeleton.js | 436 --------- tests/helpers/svg-asset-stub.js | 9 - tests/mobile-sheet-open-workspace.test.js | 733 --------------- tests/org-overview-cache.test.js | 221 ----- tests/player-share-click.test.js | 194 ---- tests/rail-logo-home.test.js | 228 ----- tests/secure-share-subject.test.js | 99 --- tests/task-desc-drop.test.js | 835 ------------------ tests/tour-section-screen.test.js | 269 ------ tests/utility-btn-busy.test.js | 156 ---- tests/utility-panel-close.test.js | 196 ---- tests/workspace-delete-admin-only.test.js | 178 ---- tests/ws-rename-outside-click.test.js | 359 -------- 25 files changed, 5591 deletions(-) delete mode 100644 tests/billing-checkout-tab-always-available.test.js delete mode 100644 tests/boot-tour-hold.test.js delete mode 100644 tests/call-offline-silent.test.js delete mode 100644 tests/call-tile-drag.test.js delete mode 100644 tests/chat-mention-workspace-members.test.js delete mode 100644 tests/connect-window-centered.test.js delete mode 100644 tests/helpers/alias-stub.js delete mode 100644 tests/helpers/billing-stub.js delete mode 100644 tests/helpers/load-esmish.js delete mode 100644 tests/helpers/over-limit-stub.js delete mode 100644 tests/helpers/render-desk-sidebar.js delete mode 100644 tests/helpers/render-mobile-sheets.js delete mode 100644 tests/helpers/render-skeleton.js delete mode 100644 tests/helpers/svg-asset-stub.js delete mode 100644 tests/mobile-sheet-open-workspace.test.js delete mode 100644 tests/org-overview-cache.test.js delete mode 100644 tests/player-share-click.test.js delete mode 100644 tests/rail-logo-home.test.js delete mode 100644 tests/secure-share-subject.test.js delete mode 100644 tests/task-desc-drop.test.js delete mode 100644 tests/tour-section-screen.test.js delete mode 100644 tests/utility-btn-busy.test.js delete mode 100644 tests/utility-panel-close.test.js delete mode 100644 tests/workspace-delete-admin-only.test.js delete mode 100644 tests/ws-rename-outside-click.test.js diff --git a/tests/billing-checkout-tab-always-available.test.js b/tests/billing-checkout-tab-always-available.test.js deleted file mode 100644 index 4ef95a1af..000000000 --- a/tests/billing-checkout-tab-always-available.test.js +++ /dev/null @@ -1,258 +0,0 @@ -// The Checkout tab must never be taken away from the user. -// -// THE REPORT (Lexis, 2026-09-16, lexishoang.drumee.in): "I open the Billing -// page and the Checkout option disappears from the tab slider." Her org has -// held a live Stripe subscription since 2026-09-10 (subscription_new -// status='active', plan business), and the tab used to be withdrawn the moment -// that mirror landed -- `_checkoutTabAllowed()` read `_hasActiveSub`, which -// only `_loadSubscription()` fills, so the tab could only be removed AFTER it -// had been offered. Making it move faster (372a1dc8) was not a fix; a tab that -// moves under the user is the defect. -// -// So the tier gate moved off the TAB and onto the BUTTON that spends money. -// These tests lock both halves, and they matter in opposite directions: -// -// the tab must be UNCONDITIONAL on anything fetched -- that is what makes -// "it disappeared" impossible rather than merely unlikely; -// the money must still be guarded -- a live subscriber may not start a plain -// second checkout, only a confirmed REPLACEMENT (`supersede`), -// which is the one thing payment.checkout admits. -// -// Both are exercised against the SHIPPED source: the gate and the whole -// _proceedToCheckout body are lifted out of the widget file, and the pill is -// the real skeleton/header.js render. -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { readFileSync } = require("node:fs"); -const { join } = require("node:path"); -const { installGlobals, walk } = require("./helpers/render-skeleton.js"); -const { requireEsmish } = require("./helpers/load-esmish.js"); - -const WIDGET = "src/drumee/builtins/widget/settings/account/billing/index.js"; -const HEADER = "src/drumee/builtins/widget/settings/account/billing/skeleton/header.js"; -const SRC = readFileSync(join(__dirname, "..", WIDGET), "utf8"); -const TAB_MONTHLY = 0; - -// Lift a method body out of the class so it runs without booting the widget. -// Every nested closer inside these sits at four spaces or more, so `\n }` is -// unambiguously the end of the method. -function body(name, async_ = false) { - const head = async_ ? "async " : ""; - const m = SRC.match(new RegExp(`\\n {2}${head}${name}\\(\\) \\{\\n([\\s\\S]*?)\\n {2}\\}\\n`)); - assert.ok(m, `${name}() not found in ${WIDGET}`); - return m[1]; -} - -// ---------------------------------------------------------------- the TAB -- - -// A billing widget carrying the REAL _checkoutTabAllowed. -function widget({ mayCheckout = true, sub = null, quotaPlan = "free" } = {}) { - const gates = new Function( - "Visitor", - `return { _checkoutTabAllowed() {${body("_checkoutTabAllowed")}} };`, - )({ quota: () => ({ plan: quotaPlan }) }); - - const ui = Object.assign(gates, { - _id: "w1", - fig: { family: "settings-billing" }, - state: { currentTab: TAB_MONTHLY, checkout: {}, plansTab: { cycle: "monthly" } }, - tab: TAB_MONTHLY, - _mayCheckout: () => mayCheckout, - _motionClass: () => "", - _yearlySavingPct: () => 0, - _promoYearlyActive: () => false, - }); - // Exactly what _loadSubscription() writes. Left UNSET while `sub` is null -- - // that is the pre-answer state the bug lived in, and writing `false` here - // would test a widget that never exists. - if (sub) { - ui._subLoaded = true; - ui._hasPaidSub = !!sub.subscription_id; - ui._isPromoTrial = !!sub.promo_trial; - ui._hasActiveSub = - /^(active|trialing|past_due)$/.test(sub.status || "") || !!sub.promo_trial; - } - return ui; -} - -// Which pills the REAL header skeleton emits, by their `service`. -function pills(ui) { - const restore = installGlobals(); - try { - const tree = requireEsmish(HEADER).default(ui); - const out = []; - for (const n of walk(tree)) { - if (typeof n.className === "string" - && n.className.split(/\s+/).includes(`${ui.fig.family}__tabs-trigger-item`)) { - out.push(n.service); - } - } - return out; - } finally { - restore(); - } -} - -const ALL_THREE = ["select-plan", "select-plan", "checkout"]; -const LIVE_BUSINESS = { subscription_id: "sub_1UDzoUDjnMxCeY36FtmuKVYZ", status: "active" }; - -test("a live subscriber keeps the Checkout pill — before AND after the mirror lands", () => { - // Lexis's account. The first render is the one the old code got right and - // the second is the one that used to take the tab away. - assert.deepEqual(pills(widget({ quotaPlan: "business" })), ALL_THREE); - assert.deepEqual(pills(widget({ quotaPlan: "business", sub: LIVE_BUSINESS })), ALL_THREE); -}); - -test("the pill survives every subscription state the mirror can report", () => { - // Whatever comes back, the tab bar is identical. This is the property the - // report is really about: nothing the server says may remove the tab. - for (const sub of [ - null, // still in flight - {}, // no subscription - LIVE_BUSINESS, // active - { subscription_id: "s", status: "trialing" }, - { subscription_id: "s", status: "past_due" }, // dunning - { subscription_id: "s", status: "canceled" }, // lapsed / pending - { promo_trial: true }, // LAUNCH30, no Stripe row - ]) { - assert.deepEqual(pills(widget({ quotaPlan: "business", sub })), ALL_THREE, - `pill lost for sub=${JSON.stringify(sub)}`); - } -}); - -test("the gate reads NOTHING that arrives over the network", () => { - // The shape lock. A tab can only disappear if its gate depends on something - // that is unknown at first paint, so the gate must not name any of these -- - // this is the assertion that would have caught the original bug, and it is - // what stops it being reintroduced. - const gate = body("_checkoutTabAllowed"); - for (const flag of ["_hasActiveSub", "_hasPaidSub", "_isPromoTrial", "_subLoaded", - "_subscription", "_canBuy", "_paidPlanSync"]) { - assert.ok(!gate.includes(flag), - `_checkoutTabAllowed() must not depend on ${flag} — that is how the tab vanished`); - } -}); - -test("the pill is still withheld from someone who may not buy here at all", () => { - // The one legitimate reason to have no Checkout tab: this deployment does - // not sell plans, or this caller is not the org owner. Decided synchronously - // (libs/billing canUpgradePlan), so it too cannot flicker. - assert.deepEqual(pills(widget({ mayCheckout: false })), ["select-plan", "select-plan"]); - assert.deepEqual(pills(widget({ mayCheckout: false, sub: LIVE_BUSINESS })), - ["select-plan", "select-plan"]); -}); - -// -------------------------------------------------------------- the MONEY -- - -// `this` for the lifted _proceedToCheckout. Records what it actually did. -function payer({ sub = null, supersede = false, plan = "business", cycle = "monthly" } = {}) { - const log = []; - const ui = { - log, - state: { - currentTab: 2, - checkout: { selectedPlan: plan, billingCycle: cycle, ...(supersede ? { supersede: 1 } : {}) }, - plansTab: { cycle }, - }, - _subscription: sub, - _hasPaidSub: !!(sub && sub.subscription_id), - _hasActiveSub: !!(sub && (/^(active|trialing|past_due)$/.test(sub.status || "") || sub.pending_cancel)), - _confirmReplacePlan(p, period) { log.push(`confirm-replace:${p}:${period}`); }, - postService(service, payload) { - log.push(`POST ${service} ${JSON.stringify(payload)}`); - return Promise.resolve({ status: "OK" }); - }, - warn() {}, - isDestroyed: () => false, - renderContent() {}, - _loadSubscription: () => Promise.resolve(null), - _orgIdentError: () => "err", - }; - const run = new Function( - "SERVICE", "Visitor", "LOCALE", "Wm", "TAB_MONTHLY", - `return async function () {\n${body("_proceedToCheckout", true)}\n};`, - )( - { payment: { checkout: "payment.checkout" } }, - { id: "u1", get: (k) => (k === "domain_id" ? 13 : "") }, // inside an org: no bootstrap branch - new Proxy({}, { get: (_t, k) => String(k) }), - { alert: (m) => log.push(`ALERT ${m}`) }, - TAB_MONTHLY, - ); - return { ui, run: () => run.call(ui) }; -} - -const posted = (log) => log.filter((l) => l.startsWith("POST")); - -test("a live subscriber pressing Pay is asked to confirm the replacement — nothing is bought", async () => { - // The guard the hidden tab used to provide, now on the button. The buyer - // must read "your current plan will be canceled immediately" and accept it - // BEFORE a checkout session exists. - const { ui, run } = payer({ sub: { subscription_id: "s", plan: "business", period: "month", status: "active" }, plan: "team" }); - await run(); - assert.deepEqual(ui.log, ["confirm-replace:team:month"]); - assert.equal(posted(ui.log).length, 0, "a live subscriber must not reach payment.checkout unconfirmed"); -}); - -test("buying the exact plan and cycle already held is refused, with a reason", async () => { - // The Checkout tab opens with the caller's CURRENT plan preselected, so this - // is the very first thing Lexis can do on it. It must say something. - const { ui, run } = payer({ - sub: { subscription_id: "s", plan: "business", period: "month", status: "active" }, - plan: "business", cycle: "monthly", - }); - await run(); - assert.deepEqual(ui.log, ["ALERT ALREADY_SUBSCRIBED"]); - assert.equal(posted(ui.log).length, 0); -}); - -test("once the replacement is confirmed, the checkout carries supersede", async () => { - const { ui, run } = payer({ - sub: { subscription_id: "s", plan: "business", period: "month", status: "active" }, - plan: "team", supersede: true, - }); - await run(); - const [post] = posted(ui.log); - assert.ok(post, ui.log.join(" | ")); - const payload = JSON.parse(post.replace("POST payment.checkout ", "")); - assert.equal(payload.supersede, 1, "the webhook only cancels the replaced sub when this is set"); - assert.equal(payload.plan, "team"); - // Still refused even WITH supersede when it would buy the same thing twice. - const same = payer({ - sub: { subscription_id: "s", plan: "business", period: "month", status: "active" }, - plan: "business", cycle: "monthly", supersede: true, - }); - await same.run(); - assert.equal(posted(same.ui.log).length, 0); - assert.deepEqual(same.ui.log, ["ALERT ALREADY_SUBSCRIBED"]); -}); - -test("a pending-cancel or past-due subscriber is a replacement too, not a second buy", async () => { - for (const sub of [ - { subscription_id: "s", plan: "business", period: "month", status: "past_due" }, - { subscription_id: "s", plan: "business", period: "month", status: "canceled", pending_cancel: 1 }, - ]) { - const { ui, run } = payer({ sub, plan: "team" }); - await run(); - assert.deepEqual(ui.log, ["confirm-replace:team:month"], JSON.stringify(sub)); - } -}); - -test("a first purchase is untouched — no confirm, straight to checkout", async () => { - // Nobody without a live Stripe mirror may be sent through a replacement - // warning for a subscription they do not have. - const cases = { - "no subscription at all": null, - "LAUNCH30 trial (paid by quota, no Stripe row)": { plan: "team" }, - "a lapsed mirror row left behind": { subscription_id: "s", plan: "team", period: "month", status: "canceled" }, - }; - for (const [name, sub] of Object.entries(cases)) { - const { ui, run } = payer({ sub, plan: "business" }); - await run(); - const [post] = posted(ui.log); - assert.ok(post, `${name}: expected a plain checkout, got ${ui.log.join(" | ")}`); - const payload = JSON.parse(post.replace("POST payment.checkout ", "")); - assert.equal(payload.supersede, undefined, `${name}: must not claim a supersede`); - assert.equal(payload.plan, "business"); - assert.ok(!ui.log.some((l) => l.startsWith("confirm-replace")), `${name}: nothing to replace`); - } -}); diff --git a/tests/boot-tour-hold.test.js b/tests/boot-tour-hold.test.js deleted file mode 100644 index 170d63c66..000000000 --- a/tests/boot-tour-hold.test.js +++ /dev/null @@ -1,128 +0,0 @@ -// A refresh by a user who has not finished the migrate tour shows the tour, not -// the restored workspace pane first. -// -// The desk hides the headless layer from before the restore -// (`data-boot-tour-hold`) until the tour is on screen, and releases it on every -// other way out. Methods are cut out of the SOURCE FILE and run against a fake -// `this`, as tests/rail-logo-home.test.js does. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const SKIN = resolve(__dirname, "../src/drumee/modules/desk/skin/index.scss"); -const src = readFileSync(DESK, "utf8"); - -function grab(name) { - const re = new RegExp(`\\n (async )?${name}\\(`); - const m = re.exec(src); - assert.ok(m, `${name} not found`); - const start = m.index + 1; - const end = src.indexOf("\n }\n", start) + 4; - return src.slice(start, end); -} - -function build(names, scope) { - const keys = Object.keys(scope); - const body = `return { ${names.map(grab).join(",\n")} };`; - return new Function(...keys, body)(...keys.map((k) => scope[k])); -} - -function scope({ offerable = true, intent = false, urlTour = undefined } = {}) { - const done = []; - const Tours = { - offerable: () => offerable, - whenDone: (id, cb) => done.push([id, cb]), - }; - const modules = { - "libs/tutorial-tours": Tours, - "libs/window-tutorial-intent": { has: () => intent }, - }; - return { - done, - scope: { - require: (m) => modules[m], - Visitor: { parseModuleArgs: () => ({ tutorial: urlTour }) }, - BOOT_TOUR_HOLD_MAX: 20000, - setTimeout: (f, ms) => setTimeout(f, ms).unref(), - clearTimeout, - }, - }; -} - -const METHODS = ["_holdBootTourPane", "_releaseBootTourHold", "_maybeRunBootTour"]; - -function desk(opts, raise) { - const { scope: sc, done } = scope(opts); - const d = build(METHODS, sc); - d.el = { dataset: {} }; - d._raiseBootTour = raise || (async () => true); - return { d, done }; -} - -test("holds the pane when the migrate tour is still to come", () => { - const { d } = desk(); - d._holdBootTourPane(); - assert.equal(d.el.dataset.bootTourHold, "1"); - d._releaseBootTourHold(); - assert.equal(d.el.dataset.bootTourHold, undefined); -}); - -test("no hold when the tour would not run", () => { - for (const opts of [{ offerable: false }, { intent: true }, { urlTour: "full" }]) { - const { d } = desk(opts); - d._holdBootTourPane(); - assert.equal(d.el.dataset.bootTourHold, undefined, JSON.stringify(opts)); - } - const { d } = desk(); - d._postOnboardingTutorial = true; - d._holdBootTourPane(); - assert.equal(d.el.dataset.bootTourHold, undefined); -}); - -test("a declined boot tour shows the pane at once", async () => { - const { d } = desk({}, async () => false); - d._holdBootTourPane(); - assert.equal(await d._maybeRunBootTour(), false); - assert.equal(d.el.dataset.bootTourHold, undefined); -}); - -test("a throw in the boot tour still shows the pane", async () => { - const { d } = desk({}, async () => { throw new Error("x"); }); - d._holdBootTourPane(); - await assert.rejects(d._maybeRunBootTour()); - assert.equal(d.el.dataset.bootTourHold, undefined); -}); - -test("a raised tour keeps the pane hidden until its claim is released", async () => { - const { d, done } = desk(); - d._holdBootTourPane(); - assert.equal(await d._maybeRunBootTour(), true); - assert.equal(d.el.dataset.bootTourHold, "1"); - assert.equal(done.length, 1); - assert.equal(done[0][0], "migrate"); - done[0][1](); - assert.equal(d.el.dataset.bootTourHold, undefined); -}); - -test("wired: before the restore feed, on the tour mount, on navigation, and in the skin", () => { - const load = grab("loadDefault"); - const hold = load.indexOf("this._holdBootTourPane()"); - assert.ok(hold > 0, "loadDefault never holds"); - assert.ok(hold < load.indexOf('this.feed(require("./skeleton")(this))'), "hold after the feed"); - assert.ok(hold < load.indexOf("this._restoreDeskState()")); - - assert.match(grab("_navigated"), /this\._releaseBootTourHold\(\)/); - - const part = src.slice(src.indexOf(' case "window-tutorial": {')); - const caseBody = part.slice(0, part.indexOf(" return;")); - assert.match(caseBody, /_releaseBootTourHold/); - - const skin = readFileSync(SKIN, "utf8"); - const rule = skin.slice(skin.indexOf('.desk-module[data-boot-tour-hold="1"] .window-manager__layer.headless {')); - assert.ok(rule.length < skin.length, "skin rule missing"); - const block = rule.slice(0, rule.indexOf("}")); - assert.match(block, /visibility:\s*hidden/); - assert.match(block, /opacity:\s*0/); -}); diff --git a/tests/call-offline-silent.test.js b/tests/call-offline-silent.test.js deleted file mode 100644 index d5e59cf62..000000000 --- a/tests/call-offline-silent.test.js +++ /dev/null @@ -1,168 +0,0 @@ -// Calling someone who is offline shows the panel and stays SILENT -// (builtins/window/connect/index.js — stateMachine, case 'offline'). -// -// Dialling a contact who is not connected drops the call window straight into -// the 'offline' state: it swaps the controls for a single Cancel, flags -// data-call-state="offline" and writes " is not currently online.". -// It used to ALSO start `musics/dialtones/offline-seagull.mp3` on `loop = 1`, -// so the caller got a seagull squawking over the notice until they closed the -// window. The panel is the whole message; the clip is gone. -// -// The state machine lives in a window class that needs the entire runtime -// (Skeletons, a jitsi room, the window manager) to instantiate, so this pulls -// the 'offline' case out of the SOURCE FILE and runs it against stubs — it -// tests the shipped text, not a copy of it. The negative control re-inserts the -// old playSound line into the extracted text and proves the harness fails on it, -// so a revert cannot slip through green. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -// The real String.prototype.format the app ships — LOCALE.X_IS_NOT_ONLINE -// carries a {0} placeholder and the panel text depends on it. -require("@drumee/ui-core/letc/addons/string"); - -const SRC = resolve(__dirname, "../src/drumee/builtins/window/connect/index.js"); -const src = readFileSync(SRC, "utf8"); - -// ── extract `case 'offline':` … `break;` ────────────────────────────────── -const OFFLINE_CASE = " case 'offline':"; -const start = src.indexOf(OFFLINE_CASE); -assert.ok(start > 0, "the 'offline' case is gone from " + SRC); -const end = src.indexOf(" break;", start); -assert.ok(end > start, "the 'offline' case has no break in " + SRC); -const OFFLINE = src.slice(start, end + " break;".length); - -const PRE_FIX = OFFLINE.replace( - " break;", - " Visitor.playSound(_K.dialtones.offline, 1);\n break;", -); - -const LOCALE = { X_IS_NOT_ONLINE: "{0} is not currently online." }; -const _a = { none: "none", cancel: "cancel" }; -const _K = { dialtones: { offline: "musics/dialtones/offline-seagull.mp3" } }; - -/** - * Run the extracted 'offline' branch against a stubbed call window. - * @param {string} body the case text to execute (shipped, or the pre-fix one) - * @returns {Object} what the branch did: sounds played/muted, panel state - */ -function runOffline(body, display = "Lexis") { - const sounds = { played: [], muted: 0 }; - const Visitor = { - playSound: (url, loop) => sounds.played.push({ url, loop }), - muteSound: () => sounds.muted++, - }; - const win = { - el: { dataset: {} }, - beforeLeavingState: "cancel", - defaultState: (s) => (win._defaultState = s), - stateMessage: (m) => win._messages.push(m), - mget: (k) => (k === "display" ? display : null), - _messages: [], - _defaultState: null, - }; - const run = new Function( - "Visitor", - "LOCALE", - "_a", - "_K", - `return function () { switch ('offline') {\n${body}\n} };`, - )(Visitor, LOCALE, _a, _K); - run.call(win); - return { sounds, win }; -} - -test("no sound plays on the offline panel", () => { - const { sounds } = runOffline(OFFLINE); - assert.deepStrictEqual(sounds.played, [], "the offline screen must be silent"); -}); - -test("the seagull clip is what the pre-fix code played (negative control)", () => { - const { sounds } = runOffline(PRE_FIX); - assert.strictEqual(sounds.played.length, 1); - assert.match(sounds.played[0].url, /offline-seagull\.mp3$/); - // loop = 1 — it squawked until the window closed, which is what Lexis heard. - assert.strictEqual(sounds.played[0].loop, 1); -}); - -test("dropping the clip does not silence anything else instead", () => { - const { sounds } = runOffline(OFFLINE); - assert.strictEqual( - sounds.muted, - 0, - "the fix removes a sound; it must not start muting the shared audio element", - ); -}); - -test("the panel still says who is not online", () => { - const { win } = runOffline(OFFLINE, "Somanos"); - assert.deepStrictEqual(win._messages, ["Somanos is not currently online."]); -}); - -test("the panel still flags data-call-state and the Cancel-only controls", () => { - const { win } = runOffline(OFFLINE); - assert.strictEqual(win.el.dataset.callState, "offline"); - assert.strictEqual(win._defaultState, _a.cancel); - assert.strictEqual( - win.beforeLeavingState, - _a.none, - "closing the offline panel must not fire a cancel signal", - ); -}); - -test("the offline panel survives a window with no element yet", () => { - const sounds = { played: [], muted: 0 }; - const Visitor = { - playSound: (url, loop) => sounds.played.push({ url, loop }), - muteSound: () => sounds.muted++, - }; - const win = { - el: null, - defaultState: () => { }, - stateMessage: () => { }, - mget: () => "Nobody", - }; - const run = new Function( - "Visitor", - "LOCALE", - "_a", - "_K", - `return function () { switch ('offline') {\n${OFFLINE}\n} };`, - )(Visitor, LOCALE, _a, _K); - assert.doesNotThrow(() => run.call(win)); -}); - -// ── the rest of the call sounds are untouched ───────────────────────────── - -test("the seagull clip is played nowhere in the app any more", () => { - assert.strictEqual( - (src.match(/_K\.dialtones\.offline/g) || []).length, - 0, - "nothing may play the offline dial tone", - ); -}); - -test("a live dial still gets its ring-back tone", () => { - const plays = src.match(/Visitor\.playSound\(([^)]*)\)/g) || []; - assert.deepStrictEqual( - plays, - ["Visitor.playSound(_K.dialtones.rinback, 10)"], - "the only sound this window plays is the ring-back on a dial that connects", - ); -}); - -test("an offline callee never reaches the ring-back line", () => { - // The guard that sends the caller to 'offline' sits BEFORE playSound in - // `case 'dial'`, so no looping ring-back can outlive the fix and keep - // playing over the silent panel. - const dial = src.indexOf(" case 'dial':"); - const guard = src.indexOf("this.stateMachine('offline');", dial); - const ringback = src.indexOf("Visitor.playSound(_K.dialtones.rinback", dial); - assert.ok(dial > 0 && guard > dial, "the offline guard left `case 'dial'`"); - assert.ok( - guard < ringback, - "the offline guard must stay ahead of the ring-back tone", - ); -}); diff --git a/tests/call-tile-drag.test.js b/tests/call-tile-drag.test.js deleted file mode 100644 index 3387b65dc..000000000 --- a/tests/call-tile-drag.test.js +++ /dev/null @@ -1,324 +0,0 @@ -// The parked-call tile can be dragged anywhere on screen -// (builtins/webrtc/call-parking.js — _bindCallTileDrag and friends). -// -// The drag used to live inside window/meeting's 2000-line window class; it now -// lives in the call-parking mixin, which window_meeting and window_connect are -// both given (Object.assign onto the prototype, like webrtc/reactions and -// webrtc/screenshare). That is the only thing that changed for this test — the -// methods, the constants and the arithmetic are the same shipped text, now read -// from their new home. -// -// The block still needs the whole runtime to instantiate through a real window, -// so this pulls the constants and the drag block out of the SOURCE FILE and -// evaluates them against a minimal DOM. It therefore tests the shipped text -// rather than a copy of it: rename a method or change the clamp and this fails. -// -// What is worth testing here is the arithmetic and the state machine, which is -// exactly what a browser makes hardest to see: where the tile lands, when a -// press is a click and when it is a move, the corner it keeps across a resize, -// and that nothing is left listening on `window` afterwards. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); -const _ = require("underscore"); - -const SRC = resolve(__dirname, "../src/drumee/builtins/webrtc/call-parking.js"); -const src = readFileSync(SRC, "utf8"); - -const CONSTS = src - .split("\n") - .filter((l) => l.startsWith("const CALL_TILE_")) - .join("\n"); - -// Everything from the first drag helper to the end of _unbindCallTileDrag. -const from = src.indexOf(" _callTileBox() {"); -const last = src.indexOf(" _unbindCallTileDrag() {"); -// The mixin is an object literal, so each method closes with " }," rather -// than the class body's " }". Accept either, and strip a trailing comma so the -// slice is still a run of METHOD DEFINITIONS that can be wrapped in a class. -const endTok = src.indexOf("\n },\n", last) >= 0 ? "\n },\n" : "\n }\n"; -const METHODS = src - .slice(from, src.indexOf(endTok, last) + endTok.length) - // object-literal method separators -> class-body (no separators) - .replace(/^ \},$/gm, " }"); -assert.ok(from > 0 && last > from, "drag block not found in " + SRC); - -const TILE = { width: 300, height: 180 }; -const PHONE_TILE = { width: 168, height: 100 }; - -function element(rect, parent) { - return { - dataset: {}, - style: {}, - parentNode: parent || null, - offsetLeft: rect.left, - offsetTop: rect.top, - rect, - // Position comes from the stylesheet until the drag writes it out, so the - // box reports its inline left/top once there is one — same as a browser. - getBoundingClientRect() { - const x = parseFloat(this.style.left); - const y = parseFloat(this.style.top); - return { - left: isNaN(x) ? this.rect.left : x, - top: isNaN(y) ? this.rect.top : y, - width: this.rect.width, - height: this.rect.height, - }; - }, - addEventListener() { }, - removeEventListener() { }, - }; -} - -/** - * A meeting window parked as a tile, with just enough of the class around the - * drag block for it to run. - * @param {Object} opt docked: park in the desk dock (default) or, false, in - * place the way a DMZ session does. - */ -function parked(opt = {}) { - const docked = opt.docked !== false; - const area = opt.area || { width: 1440, height: 900 }; - const size = opt.size || TILE; - const store = opt.store || {}; - const at = { left: area.width - size.width - 24, top: area.height - size.height - 24 }; - - const dock = docked ? element({ ...at, ...size }) : null; - const win = element({ ...at, ...size }, dock); - win.dataset.callTile = "1"; - - const listeners = {}; - global.window = { - innerWidth: area.width, - innerHeight: area.height, - addEventListener(t, f, opt) { - (listeners[t] = listeners[t] || []).push({ f, once: !!(opt && opt.once) }); - }, - removeEventListener(t, f) { - listeners[t] = (listeners[t] || []).filter((x) => x.f !== f); - }, - localStorage: { - getItem: (k) => (k in store ? store[k] : null), - setItem: (k, v) => { store[k] = String(v); }, - }, - }; - global._ = _; - - const Window = eval(`(() => { -${CONSTS} -return class ParkedMeeting { - constructor(el, dockEl, workArea) { - this.el = el; - this._dock = dockEl; - this._area = workArea; - } - _callDockEl() { return this._dock; } - _callTileArea() { return this._area; } -${METHODS} -}; -})()`); - - const ui = new Window(win, dock, area); - ui._bindCallTileDrag(); - - return { - ui, win, dock, store, - box: dock || win, - listeners, - live: () => Object.values(listeners).reduce((n, a) => n + a.length, 0), - /** Fire an event at the window listeners, retiring the `once` ones. */ - dispatch(type, ev) { - for (const l of [...(listeners[type] || [])]) { - if (l.once) listeners[type] = listeners[type].filter((x) => x !== l); - l.f(ev); - } - return ev; - }, - /** Press, travel in five steps, release. */ - drag(fromX, fromY, toX, toY) { - ui._callTileDown({ button: 0, clientX: fromX, clientY: fromY, pointerId: 1 }); - for (let i = 1; i <= 5; i++) { - ui._callTileMove({ - pointerId: 1, - pointerType: "mouse", - buttons: 1, - clientX: fromX + ((toX - fromX) * i) / 5, - clientY: fromY + ((toY - fromY) * i) / 5, - preventDefault() { }, - }); - } - ui._callTileUp(); - return { left: this.box.style.left, top: this.box.style.top }; - }, - }; -} - -test("a drag moves the tile to where it was dropped", () => { - const t = parked(); - // 1200,760 is on the resting tile; 500 left and 360 up from there. - assert.deepStrictEqual(t.drag(1200, 760, 700, 400), { left: "616px", top: "336px" }); - // The resting rules position the dock from the far edges: both have to be - // released, or the box is stretched between left and right instead of moved. - assert.strictEqual(t.box.style.right, "auto"); - assert.strictEqual(t.box.style.bottom, "auto"); - assert.strictEqual(t.win.dataset.callDrag, undefined, "drag state cleared on release"); -}); - -test("a press that barely travels stays a click, and is left to the click handler", () => { - const t = parked(); - t.ui._callTileDown({ button: 0, clientX: 1200, clientY: 760, pointerId: 1 }); - t.ui._callTileMove({ - pointerId: 1, pointerType: "mouse", buttons: 1, - clientX: 1202, clientY: 761, preventDefault() { }, - }); - t.ui._callTileUp(); - assert.strictEqual(t.box.style.left, undefined, "2px of travel must not move the tile"); - // _callTileClick only swallows the click when this is set — a 2px press has - // to come back to the call, which is the whole point of the slop threshold. - assert.ok(!t.ui._callTileDragAt); -}); - -test("a real drag swallows the click its own release fires", () => { - const t = parked(); - t.drag(1200, 760, 700, 400); - // The tile's own click handler is armed against it... - assert.ok(Date.now() - t.ui._callTileDragAt < 100); - - // ...and so is the desk behind it: a drag that ends off the tile fires its - // click on the common ancestor, where a bare click closes whatever menu or - // drawer was open. Nothing should happen but the tile having moved. - let stopped = 0; - t.dispatch("click", { - stopPropagation() { stopped++; }, - preventDefault() { stopped++; }, - }); - assert.strictEqual(stopped, 2, "the drag's own click must not reach the desk"); - assert.strictEqual(t.ui._callTileDragAt, 0, "and it is consumed exactly once"); -}); - -test("a click that is not the drag's own is left alone", () => { - const t = parked(); - t.drag(1200, 760, 700, 400); - // The release landed outside the browser, so no click ever came; the next one - // belongs to the user. - t.ui._callTileDragAt = Date.now() - 1000; - let stopped = 0; - t.dispatch("click", { - stopPropagation() { stopped++; }, - preventDefault() { stopped++; }, - }); - assert.strictEqual(stopped, 0); - assert.strictEqual((t.listeners.click || []).length, 0, "and the guard retires itself"); -}); - -test("a second finger cannot take over a drag in flight", () => { - const t = parked(); - t.ui._callTileDown({ button: 0, clientX: 1200, clientY: 760, pointerId: 1 }); - t.ui._callTileDown({ button: 0, clientX: 300, clientY: 300, pointerId: 2 }); - assert.strictEqual(t.ui._callTileDrag.id, 1, "the first pointer keeps the tile"); - // ...and the second pointer's moves are ignored while it does. - t.ui._callTileMove({ - pointerId: 2, pointerType: "touch", buttons: 1, - clientX: 400, clientY: 400, preventDefault() { }, - }); - assert.strictEqual(t.box.style.left, undefined); - t.ui._callTileUp(); -}); - -test("with no desk, the tile parks at the same inset the drag clamps to", () => { - const t = parked({ docked: false }); - // What _enterCallTile does after sizing the window: no stored position, so - // the bottom-right corner comes from the drag's own bounds. A corner of its - // own here is a tile that jumps 4px the moment it is picked up. - t.win.style.left = ""; - t.win.style.top = ""; - t.ui._applyCallTilePos({ fx: 1, fy: 1 }); - assert.deepStrictEqual( - { left: t.win.style.left, top: t.win.style.top }, - { left: "1116px", top: "696px" }, - ); -}); - -test("the tile cannot be dragged off screen, and goes flush at the edges", () => { - const t = parked(); - // Thrown well past the top-left corner: clamped to the inset, then snapped. - assert.deepStrictEqual(t.drag(1200, 760, -400, -400), { left: "24px", top: "24px" }); - assert.strictEqual(t.store["drumee:call-tile-pos"], '{"fx":0,"fy":0}'); - // Dropped 22px and 12px short of the far corner: the magnet takes it in. - assert.deepStrictEqual(t.drag(100, 100, 1170, 760), { left: "1116px", top: "696px" }); - assert.strictEqual(t.store["drumee:call-tile-pos"], '{"fx":1,"fy":1}'); -}); - -test("the corner it was given survives a viewport resize", () => { - const t = parked(); - t.drag(1200, 760, 30, 30); // top-left - window.innerWidth = 1024; - window.innerHeight = 680; - t.ui._applyCallTilePos(); - assert.deepStrictEqual( - { left: t.box.style.left, top: t.box.style.top }, - { left: "24px", top: "24px" }, - ); - - // ...and the far corner is re-derived from the new viewport rather than kept - // in pixels, which is what would have put it off screen. - const b = parked(); - b.drag(1200, 760, 1400, 880); - window.innerWidth = 1024; - window.innerHeight = 680; - b.ui._applyCallTilePos(); - assert.deepStrictEqual( - { left: b.box.style.left, top: b.box.style.top }, - { left: "700px", top: "476px" }, - ); -}); - -test("a tile nobody has dragged is left to the stylesheet", () => { - const t = parked(); - t.ui._applyCallTilePos(); - assert.strictEqual(t.box.style.left, undefined); - assert.strictEqual(t.box.style.top, undefined); -}); - -test("the phone breakpoint drags against the 12px inset", () => { - const t = parked({ - area: { width: 600, height: 800 }, - size: PHONE_TILE, - store: { "drumee:call-tile-pos": '{"fx":0,"fy":0}' }, - }); - t.ui._applyCallTilePos(); - assert.deepStrictEqual( - { left: t.box.style.left, top: t.box.style.top }, - { left: "12px", top: "12px" }, - ); -}); - -test("with no desk to dock into, the drag moves the window itself", () => { - const t = parked({ docked: false }); - assert.strictEqual(t.box, t.win); - assert.deepStrictEqual(t.drag(1200, 760, 900, 500), { left: "816px", top: "436px" }); -}); - -test("a mouse button released out of reach ends the drag", () => { - const t = parked(); - t.ui._callTileDown({ button: 0, clientX: 1200, clientY: 760, pointerId: 1 }); - t.ui._callTileMove({ - pointerId: 1, pointerType: "mouse", buttons: 0, - clientX: 900, clientY: 500, preventDefault() { }, - }); - assert.strictEqual(t.ui._callTileDrag, null, "the tile must not follow a pointer with nothing held"); -}); - -test("un-parking leaves nothing listening on window", () => { - const t = parked(); - t.drag(1200, 760, 700, 400); - assert.ok(t.live() > 0, "the resize re-fit is bound while parked"); - // The drag's click guard is a `once` listener that retires on the click it - // was armed for — the one the release is about to fire. - t.dispatch("click", { stopPropagation() { }, preventDefault() { } }); - t.ui._unbindCallTileDrag(); - assert.strictEqual(t.live(), 0); - assert.strictEqual(t.win.dataset.callDrag, undefined); -}); diff --git a/tests/chat-mention-workspace-members.test.js b/tests/chat-mention-workspace-members.test.js deleted file mode 100644 index 4d3bdc381..000000000 --- a/tests/chat-mention-workspace-members.test.js +++ /dev/null @@ -1,178 +0,0 @@ -// Typing "@" in a WORKSPACE chat must offer the workspace's MEMBERS, not the -// visitor's personal contact list. -// -// This was fixed once (299e5604, May 2026) by pointing the contact mention at -// hub.get_members_by_type whenever the chat's scope was `folder` — at the time -// the only scope a folder chat ever had. 37ab4fcc (Aug 2026) then moved chat to -// WORKSPACE scope, so the team chat's scope became the string `workspace`, and -// the same commit updated `postNid` beside it but not the mention branch. The -// mention condition silently stopped matching and every workspace chat fell -// through to chat.contact_rooms — the visitor's own contacts again. Nothing -// threw, and the popup still opened, which is why it read as the old bug -// coming back rather than as a new one. -// -// The scope-source decision is one `if` inside a 300-line method that walks the -// DOM, so — as tests/workspace-delete-admin-only.js and tests/rail-logo-home.js -// do — the branch is cut out of the SOURCE FILE and run against a fake `this`. -// The test therefore reads the shipped text, not a copy of it. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const CHAT = resolve(__dirname, "../src/drumee/builtins/widget/chat/index.js"); -const src = readFileSync(CHAT, "utf8"); - -// ── cut the two pieces out of the source ──────────────────────────────────── - -const HELPER = /^const isHubScopedChat = .*$/m; -const helperSrc = src.match(HELPER); -assert.ok(helperSrc, "isHubScopedChat vanished from the chat widget"); - -/** Slice from `start` to the brace that closes the block opened on that line. */ -function block(text, start) { - let depth = 0; - for (let i = start; i < text.length; i++) { - if (text[i] === "{") depth++; - else if (text[i] === "}" && --depth === 0) return text.slice(start, i + 1); - } - throw new Error("unbalanced braces"); -} - -const BRANCH = 'if (mentionType === "contact") {'; -const branchAt = src.indexOf(BRANCH); -assert.notEqual(branchAt, -1, "the contact-mention branch was renamed or removed"); -const branchSrc = block(src, branchAt); - -// The branch reads `filter`, `folderHubId` and `mentionType` from the enclosing -// method and assigns `contactsPromise`; everything else it touches is either a -// runtime global (passed in) or on `this` (the fake below). -const runBranch = new Function( - "_a", - "Visitor", - "SERVICE", - "console", - "mentionType", - "filter", - "folderHubId", - ` - ${helperSrc[0]} - let contactsPromise = null; - ${branchSrc} - return contactsPromise; - `, -); - -// ── the runtime the branch expects ────────────────────────────────────────── - -const _a = { folder: "folder", id: "id" }; -const SERVICE = { - hub: { get_members_by_type: "hub.get_members_by_type" }, - chat: { contact_rooms: "chat.contact_rooms" }, -}; -const quiet = { log() {}, warn() {} }; - -const VISITOR_ID = "visitor-personal-hub"; -const Visitor = { - id: VISITOR_ID, - get: (k) => (k === "id" ? VISITOR_ID : undefined), -}; - -/** Run the branch for one chat and report which service it asked for. */ -function sourceFor(scope, hubId, { visitor = Visitor } = {}) { - let asked = null; - const chat = { - hubId, - mget: (k) => (k === "scope" ? scope : undefined), - fetchService(payload) { - asked = payload; - return Promise.resolve([]); - }, - }; - runBranch.call(chat, _a, visitor, SERVICE, quiet, "contact", "", hubId); - assert.ok(asked, "the contact branch asked for nothing at all"); - return asked; -} - -const HUB = "workspace-hub-42"; - -// ── the bug ───────────────────────────────────────────────────────────────── - -test("a workspace chat offers the WORKSPACE MEMBERS", () => { - const asked = sourceFor("workspace", HUB); - assert.equal( - asked.service, - SERVICE.hub.get_members_by_type, - "THE BUG: the workspace team chat is back on the visitor's contact rooms", - ); - assert.equal(asked.hub_id, HUB, "the members must come from THIS workspace"); - assert.equal(asked.type, "all"); -}); - -test("a DMZ share's folder chat keeps the members it already had", () => { - // The scope that 299e5604 fixed. Widening the test must not narrow this one. - const asked = sourceFor("folder", HUB); - assert.equal(asked.service, SERVICE.hub.get_members_by_type); - assert.equal(asked.hub_id, HUB); -}); - -// ── what must NOT change ──────────────────────────────────────────────────── - -test("bigchat / a direct room still offers the visitor's contact rooms", () => { - // No scope at all: the conversation belongs to the visitor, not to a hub. - const asked = sourceFor(undefined, VISITOR_ID); - assert.equal(asked.service, SERVICE.chat.contact_rooms); - assert.equal(asked.hub_id, VISITOR_ID); -}); - -test("a PERSONAL workspace still offers contact rooms, not an empty list", () => { - // A personal workspace IS the user, so its hub_id is Visitor.id and the - // server answers [] for it (hub._members_by_type). Sending it down the - // members path would have traded the old wrong list for no list at all. - const asked = sourceFor("workspace", VISITOR_ID); - assert.equal( - asked.service, - SERVICE.chat.contact_rooms, - "a personal workspace has no members — it must not lose its suggestions", - ); -}); - -test("a workspace chat with no hub id falls back rather than asking for none", () => { - const asked = sourceFor("workspace", ""); - assert.equal(asked.service, SERVICE.chat.contact_rooms); -}); - -test("an unknown Visitor id does not make every hub look personal", () => { - // Visitor.id can be undefined before the session lands. `${hub}` === `${undefined}` - // is false anyway, but the guard says so explicitly — assert it, because a - // truthiness slip here would silently send real workspaces to contact rooms. - const blind = { id: undefined, get: () => undefined }; - const asked = sourceFor("workspace", HUB, { visitor: blind }); - assert.equal(asked.service, SERVICE.hub.get_members_by_type); -}); - -// ── the predicate itself ──────────────────────────────────────────────────── - -test("isHubScopedChat is the single list of hub-owned scopes", () => { - const isHubScopedChat = new Function( - "_a", - `${helperSrc[0]}; return isHubScopedChat;`, - )(_a); - - assert.equal(isHubScopedChat("workspace"), true); - assert.equal(isHubScopedChat("folder"), true); - assert.equal(isHubScopedChat(undefined), false); - assert.equal(isHubScopedChat(""), false); - assert.equal(isHubScopedChat("personal"), false); - - // Every scope test that means "this conversation belongs to a hub" goes - // through the predicate. Keeping them apart is what broke the mention list. - const inline = src - .replace(HELPER, "") // the predicate's own definition is the one allowed copy - .match(/scope\s*===\s*_a\.folder\s*\|\|\s*scope\s*===\s*"workspace"/g); - assert.equal( - inline, - null, - "a scope list was inlined again instead of using isHubScopedChat", - ); -}); diff --git a/tests/connect-window-centered.test.js b/tests/connect-window-centered.test.js deleted file mode 100644 index 822106754..000000000 --- a/tests/connect-window-centered.test.js +++ /dev/null @@ -1,171 +0,0 @@ -// The 1:1 call window opens centred in the desk canvas -// (builtins/window/connect/index.js — _workAreaBox, _centerInWorkArea). -// -// The shared webrtc _setSize (window/interact/webrtc.js) centres against the -// VIEWPORT, but the window lives in the call layer, whose origin is the WM work -// area — below the 46px topbar and right of the sidebar rail. A viewport-centred -// offset applied in work-area coordinates lands right and down of centre, by the -// width of the rail and the height of the bar. This is the correction. -// -// Methods are cut out of the SOURCE FILE and run against a fake `this`, the way -// the desk tests do, so this tests the shipped text rather than a copy of it. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const SRC = resolve( - __dirname, - "../src/drumee/builtins/window/connect/index.js", -); -const src = readFileSync(SRC, "utf8"); - -function num(name) { - const m = new RegExp(`\\nconst ${name} = (\\d+)`).exec(src); - assert.ok(m, `${name} not found in the source`); - return Number(m[1]); -} - -const W = num("CONNECT_W"); -const H = num("CONNECT_H"); -const INSET = num("CONNECT_INSET"); - -function grab(name) { - const m = new RegExp(`\\n (async )?${name}\\(`).exec(src); - assert.ok(m, `${name} not found`); - const start = m.index + 1; - return src.slice(start, src.indexOf("\n }\n", start) + 4); -} - -/** - * A connect window about to be placed. - * @param {Object} opt - * area the WM work area, or null for "Wm has no element yet" - * viewport the window size the fallback reads - * mounted whether this.el exists yet - */ -function win(opt = {}) { - const area = opt.area === undefined - ? { width: 1360, height: 838 } - : opt.area; - const viewport = opt.viewport || { innerWidth: 1440, innerHeight: 900 }; - - const styleSet = []; - const el = opt.mounted === false ? null : { style: {} }; - - const Wm = area - ? { el: { parentElement: { getBoundingClientRect: () => area } } } - : {}; - - const w = new Function( - "_", "window", "Wm", "CONNECT_W", "CONNECT_H", "CONNECT_INSET", - `return { ${["_workAreaBox", "_centerInWorkArea"].map(grab).join(",\n")} };`, - )( - { isFunction: (f) => typeof f === "function" }, - { ...viewport, Wm }, - Wm, - W, H, INSET, - ); - - w.el = el; - w.size = { width: W, height: H }; - w.style = { set: (o) => styleSet.push(o) }; - return { w, styleSet, el }; -} - -test("the window is centred in the work area, not the viewport", () => { - // 1360x838 is a 1440x900 viewport less an expanded rail and the topbar. - const { w } = win({ area: { width: 1360, height: 838 } }); - const { left, top } = w._centerInWorkArea(); - assert.equal(left, Math.round((1360 - W) / 2)); - assert.equal(top, Math.round((838 - H) / 2)); - - // The old viewport-based arithmetic, for contrast: it would put the window - // this far right of where it belongs. - const viewportLeft = 1440 / 2 - W / 2; - assert.ok(viewportLeft > left, "the viewport calculation skews right"); -}); - -test("the window's centre lands on the area's centre", () => { - // An ODD area, so this also pins the rounding: the offset is a whole number - // of pixels (a subpixel left/top blurs the text inside), which puts the - // centre at most half a pixel off. Exact equality here would be wrong. - const area = { width: 1193, height: 738 }; - const { w } = win({ area }); - const { left, top } = w._centerInWorkArea(); - assert.ok(Math.abs(left + W / 2 - area.width / 2) <= 0.5); - assert.ok(Math.abs(top + H / 2 - area.height / 2) <= 0.5); - assert.equal(left, Math.round(left), "whole pixels only"); - assert.equal(top, Math.round(top)); -}); - -test("a work area narrower than the window clamps to the inset", () => { - const { w } = win({ area: { width: W - 200, height: H - 200 } }); - const { left, top } = w._centerInWorkArea(); - assert.equal(left, INSET, "never a negative offset"); - assert.equal(top, INSET); -}); - -test("the viewport is the fallback when the WM has no element yet", () => { - const { w } = win({ area: null, viewport: { innerWidth: 1000, innerHeight: 800 } }); - const { left, top } = w._centerInWorkArea(); - assert.equal(left, Math.round((1000 - W) / 2)); - assert.equal(top, Math.round((800 - H) / 2)); -}); - -test("a zero-sized work area falls back rather than stacking in the corner", () => { - // The desk can measure 0x0 before it has laid out; centring against that - // would put every call window at the inset. - const { w } = win({ - area: { width: 0, height: 0 }, - viewport: { innerWidth: 1200, innerHeight: 900 }, - }); - const { left } = w._centerInWorkArea(); - assert.equal(left, Math.round((1200 - W) / 2)); -}); - -test("the position is written to the style model and to the element", () => { - const { w, styleSet, el } = win(); - const { left, top } = w._centerInWorkArea(); - assert.deepEqual(styleSet, [{ left, top }]); - assert.equal(el.style.left, `${left}px`); - assert.equal(el.style.top, `${top}px`); - assert.equal(w.size.left, left, "and kept on this.size for change_size"); - assert.equal(w.size.top, top); -}); - -test("an unmounted window still records where it will go", () => { - const { w, styleSet } = win({ mounted: false }); - const { left, top } = w._centerInWorkArea(); - assert.deepEqual(styleSet, [{ left, top }]); - assert.equal(w.size.left, left); -}); - -test("explicit dimensions override the defaults", () => { - const { w } = win({ area: { width: 1000, height: 800 } }); - const { left, top } = w._centerInWorkArea(400, 300); - assert.equal(left, Math.round((1000 - 400) / 2)); - assert.equal(top, Math.round((800 - 300) / 2)); -}); - -test("coming back from the dock restores the SAME frame it launched with", () => { - // This drifted once already: _onCallTileLeft re-seeded a literal 734x600 - // while the launch had moved to the constants, so a call opened at one size - // and came back from the dock at another. - const m = /_onCallTileLeft\(\)\s*\{[\s\S]*?\n \}/.exec(src); - assert.ok(m, "_onCallTileLeft not found"); - const body = m[0]; - assert.ok(/width: CONNECT_W/.test(body), "re-seeds the launch width"); - assert.ok(/height: CONNECT_H/.test(body), "re-seeds the launch height"); - assert.ok(/_centerInWorkArea\(\)/.test(body), "and re-centres it"); - assert.ok(!/\b734\b|height: 600\b/.test(body), "no literal dimensions"); -}); - -test("the launch geometry uses the same constants", () => { - // _setSize and the centring must agree, or the window is centred for a size - // it is not given. - const m = /_setSize\(\{\s*width: CONNECT_W,\s*height: CONNECT_H/.exec(src); - assert.ok(m, "_setSize must be called with CONNECT_W / CONNECT_H"); - assert.ok(W < 734 && H < 600, "the frame is smaller than the 734x600 it replaced"); - assert.ok(W >= 480 && H >= 420, "and not below the window's own minimums"); -}); diff --git a/tests/helpers/alias-stub.js b/tests/helpers/alias-stub.js deleted file mode 100644 index a382562d6..000000000 --- a/tests/helpers/alias-stub.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = new Proxy(function(){ return {}; }, { get: () => () => ({}), apply: () => ({}) }); diff --git a/tests/helpers/billing-stub.js b/tests/helpers/billing-stub.js deleted file mode 100644 index dd3bc8eb7..000000000 --- a/tests/helpers/billing-stub.js +++ /dev/null @@ -1,7 +0,0 @@ -// Stub for the `libs/billing` webpack alias. canUpgradePlan() decides whether -// the footer renders its "Upgrade plan" row. -const { state } = require("./render-desk-sidebar"); -module.exports = { - canUpgradePlan: () => state.canUpgrade, - planLabel: () => "Free", -}; diff --git a/tests/helpers/load-esmish.js b/tests/helpers/load-esmish.js deleted file mode 100644 index 2ecf9e03e..000000000 --- a/tests/helpers/load-esmish.js +++ /dev/null @@ -1,76 +0,0 @@ -// Load an `export`-flavoured source file under plain node `require`. -// -// Most skeletons in this app are CommonJS and load as-is, which is why -// render-skeleton's renderModule can simply require them. A handful — the -// window toolkit and the button factory it leans on — are written with -// `export function`, and webpack is the only thing that has ever read them. -// Without this they cannot be tested at all, so the markup that carries the -// workspace toolbar (search, + New, the view toggle, the file-type filter bar) -// was the one part of the window with no test able to reach it. -// -// The transform is deliberately the smallest one that works: strip the `export` -// keyword off top-level declarations and re-export those names at the end. It -// is installed as a `.js` handler for the length of one call and restored -// after, and it only rewrites files that actually carry a top-level `export` — -// every CommonJS module in the tree still compiles verbatim. -const Module = require("node:module"); -const { readFileSync } = require("node:fs"); - -const HAS_EXPORT = /^export\s/m; - -// `export function foo` / `export const foo` — declarations. -const DECL = /^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm; -// `export { a, b as c };` — a list of names declared elsewhere in the file. -const LIST = /^export\s*\{([^}]*)\}\s*;?/gm; -// `export * from "./x";` — everything another module exports. -const STAR = /^export\s+\*\s+from\s+(['"][^'"]+['"])\s*;?/gm; -// `export default foo;` — the shape every billing/window SKELETON uses, and -// the one the generic strip below cannot handle on its own (it would leave a -// bare `default foo;`). Published under `.default` because that is how the -// importing widget reads it: `require("./skeleton/header").default(this)`. -const DEFAULT = /^export\s+default\s+/gm; - -function transform(src) { - const pairs = [...src.matchAll(DECL)].map((m) => [m[1], m[1]]); - let body = src.replace(LIST, (_m, list) => { - for (const part of list.split(",")) { - const [local, exported = local] = part.trim().split(/\s+as\s+/); - if (local) pairs.push([local.trim(), exported.trim()]); - } - return ""; - }); - body = body.replace(STAR, "Object.assign(module.exports, require($1));"); - body = body.replace(DEFAULT, "module.exports.default = "); - body = body.replace(/^export\s+/gm, ""); - const tail = pairs.map(([local, exported]) => ` ${exported}: ${local},`).join("\n"); - // Merged, not assigned: a star re-export has already written to module.exports. - return `${body}\nObject.assign(module.exports, {\n${tail}\n});\n`; -} - -// Require `relPath` (from the repo root) with the transform active for it and -// for everything it pulls in. Modules loaded through here are dropped from the -// cache on the way in AND on the way out, so a later plain require of the same -// file is unaffected by the rewrite. -function requireEsmish(relPath) { - const { join } = require("node:path"); - const abs = require.resolve(join(__dirname, "..", "..", relPath)); - const js = Module._extensions[".js"]; - const touched = new Set(); - Module._extensions[".js"] = function (mod, filename) { - const src = readFileSync(filename, "utf8"); - if (!HAS_EXPORT.test(src)) return js(mod, filename); - touched.add(filename); - mod._compile(transform(src), filename); - }; - const drop = (f) => delete require.cache[f]; - drop(abs); - try { - return require(abs); - } finally { - Module._extensions[".js"] = js; - drop(abs); - touched.forEach(drop); - } -} - -module.exports = { requireEsmish, transform }; diff --git a/tests/helpers/over-limit-stub.js b/tests/helpers/over-limit-stub.js deleted file mode 100644 index c0d520d69..000000000 --- a/tests/helpers/over-limit-stub.js +++ /dev/null @@ -1,8 +0,0 @@ -// Stub for the `libs/over-limit` webpack alias. Only isLocked() changes the -// sidebar's markup; the guards are runtime-only and never run at render time. -const { state } = require("./render-desk-sidebar"); -module.exports = { - CHANGED: "over-limit:changed", - isLocked: () => state.locked, - guardWrite: () => false, -}; diff --git a/tests/helpers/render-desk-sidebar.js b/tests/helpers/render-desk-sidebar.js deleted file mode 100644 index 89f5e169b..000000000 --- a/tests/helpers/render-desk-sidebar.js +++ /dev/null @@ -1,155 +0,0 @@ -// Render the REAL desk sidebar skeleton into a descriptor tree. -// -// Same reason render-skeleton.js exists for the tasks panel: the drawer's three -// modes are picked by a `data-mode` attribute against slots the skeleton emits, -// so a hand-built fixture cannot see a slot that was never rendered or a row -// that lost its service. -// -// Unlike that helper this one cannot lean on alias-stub for everything. The -// sidebar asks `libs/over-limit` whether the org is locked and `libs/billing` -// whether the plan can be upgraded, and alias-stub answers every call with `{}` -// — truthy — which would silently drop the very rows under test. Both get real -// stubs whose answers the caller sets. -const Module = require("node:module"); -const path = require("node:path"); - -const node = (kind) => (props = {}) => ({ __kind: kind, ...props }); - -// Set by installResolver's stubs; the caller drives them through render(opt). -const state = { locked: false, canUpgrade: true }; - -function installGlobals() { - const saved = {}; - const set = (k, v) => { - saved[k] = global[k]; - global[k] = v; - }; - - const Box = node("box"); - set("Skeletons", { - Box: Object.assign(node("box"), { X: Box, Y: Box, Z: Box, G: Box }), - Note: node("note"), - Element: node("element"), - Button: { Svg: node("button.svg"), Label: node("button.label") }, - Image: { Svg: node("image.svg") }, - UserProfile: node("profile"), - Menu: node("menu"), - }); - // Keys echo back as their own name, so a missing translation is visible - // rather than blank — and `label` assertions can match on the key. - set("LOCALE", new Proxy({}, { get: (_t, k) => String(k) })); - set("Organization", { name: () => "Acme" }); - set("Platform", { get: () => null }); - // The footer's Sign out row passes Butler.logout as its on_click. - set("Butler", { logout: () => {}, say: () => {} }); - set("Visitor", { - id: "me", - get: () => "", - firstname: () => "Me", - isMobile: () => true, - device: () => "mobile", - domainCan: () => false, - }); - set("_a", new Proxy({}, { get: (_t, k) => String(k) })); - set("_e", new Proxy({}, { get: (_t, k) => String(k) })); - set("_K", { permission: { admin_member: "admin_member" }, char: { empty: "" } }); - set("_", require("lodash")); - - // `"{0} Plan".format(...)` — a runtime extension the app installs and node - // does not have. The footer calls it unconditionally. - const savedFormat = String.prototype.format; - // eslint-disable-next-line no-extend-native - String.prototype.format = function (...a) { - return this.replace(/\{(\d+)\}/g, (m, i) => (a[i] == null ? m : String(a[i]))); - }; - - return () => { - if (savedFormat === undefined) delete String.prototype.format; - else String.prototype.format = savedFormat; - for (const k of Object.keys(saved)) { - if (saved[k] === undefined) delete global[k]; - else global[k] = saved[k]; - } - }; -} - -// webpack aliases `libs/...`, `media/...`, `assets/...`. The two libs whose -// ANSWER changes the markup get real stubs; the rest fall through to -// alias-stub. -function installResolver() { - const orig = Module._resolveFilename; - const OVER_LIMIT = path.join(__dirname, "over-limit-stub.js"); - const BILLING = path.join(__dirname, "billing-stub.js"); - Module._resolveFilename = function (request, ...rest) { - if (request === "libs/over-limit") return OVER_LIMIT; - if (request === "libs/billing") return BILLING; - if (/^media\//.test(request) || /^libs\//.test(request) || /^assets\//.test(request)) { - return require.resolve("./alias-stub.js"); - } - return orig.call(this, request, ...rest); - }; - return () => { - Module._resolveFilename = orig; - }; -} - -// `ui` is the desk module. The sidebar reads its BEM family off fig.family and -// asks it for the current-workspace write privilege. -function makeUi(over = {}) { - return { - fig: { family: "desk-module" }, - mget: () => null, - _curWorkspaceCanWrite: () => true, - ...over, - }; -} - -function render(over = {}, opt = {}) { - state.locked = !!opt.locked; - state.canUpgrade = opt.canUpgrade !== false; - const restoreGlobals = installGlobals(); - const restoreResolver = installResolver(); - try { - const p = require.resolve("../../src/drumee/modules/desk/skeleton/sidebar.js"); - // The create-items list and the sidebar itself both close over LOCALE at - // require time, so both have to be re-required under these globals. - const items = require.resolve("../../src/drumee/modules/desk/skeleton/create-items.js"); - delete require.cache[p]; - delete require.cache[items]; - return require(p)(makeUi(over)); - } finally { - restoreResolver(); - restoreGlobals(); - } -} - -function* walk(n) { - if (!n || typeof n !== "object") return; - yield n; - for (const k of [].concat(n.kids || [])) yield* walk(k); -} - -const hasClass = (n, cls) => - typeof n.className === "string" && n.className.split(/\s+/).includes(cls); - -function find(tree, cls) { - for (const n of walk(tree)) if (hasClass(n, cls)) return n; - return null; -} - -function findAll(tree, cls) { - const out = []; - for (const n of walk(tree)) if (hasClass(n, cls)) out.push(n); - return out; -} - -// Every service reachable inside `n`, in render order. -const servicesIn = (n) => [...walk(n)].map((k) => k.service).filter(Boolean); - -// The visible label of a row: its Note descendant's content. -const labelsIn = (n) => - [...walk(n)] - .filter((k) => k.__kind === "note" && k.content) - .map((k) => String(k.content)); - -module.exports = { render, walk, find, findAll, hasClass, servicesIn, labelsIn, state }; diff --git a/tests/helpers/render-mobile-sheets.js b/tests/helpers/render-mobile-sheets.js deleted file mode 100644 index 24b9478a7..000000000 --- a/tests/helpers/render-mobile-sheets.js +++ /dev/null @@ -1,204 +0,0 @@ -// Render the REAL mobile-sheet builders into a descriptor tree. -// -// Same reason render-desk-sidebar.js exists: the create sheet's rows are -// privilege-gated and carry their target service as a model field, so a -// hand-built fixture cannot see a row that was silently dropped or one that -// lost its goTarget. -const Module = require("node:module"); - -/** - * What ui-core's skeleton builder does to a box's children before they render - * (toolkit/builder.js): every DIRECT kid is merged with the parent's `kidsOpt`, - * and the parent's value WINS — `_.merge(kid, kidsOpt)` there, so a kid cannot - * hold its own `active` against it. - * - * Modelled here because `kidsOpt: { active: 0 }` is not decoration: a widget - * whose model carries `active: 0` returns on the first line of - * `View.prototype.triggerHandlers` (letc/addons/letc.js), so it raises NOTHING - * when tapped. A harness that kept the descriptors verbatim showed a row with - * its `service` and its `goTarget` intact and could not tell that the row was - * inert — which is exactly how a dead workspace list passed a suite of - * descriptor assertions. - * - * One level per box, like the real thing: each widget applies its OWN kidsOpt - * to its OWN kids as it renders, so this is not recursive — it runs again for - * each nested box as that box is built. - */ -const applyKidsOpt = (props) => { - const opt = props && props.kidsOpt; - if (opt && typeof opt === "object" && Array.isArray(props.kids)) { - for (const kid of props.kids) { - if (kid && typeof kid === "object") Object.assign(kid, opt); - } - } - return props; -}; - -const node = (kind) => (props = {}) => ({ __kind: kind, ...applyKidsOpt(props) }); - -/** - * Image.Svg / Button.Svg as ui-core really builds them. - * - * Their shared builder (toolkit/builder/button/svg.js) RENAMES the prop: - * - * if (this.props.ico) { this.props.chartId = this.props.ico; - * delete this.props.ico; } - * - * so a descriptor built by that factory carries `chartId` and has no `ico` at - * all. Modelled here because code that READS BACK a descriptor someone else - * built — `_mobileWorkspaceActions` lifting the glyph off a shared contextmenu - * row — sees `chartId`, and a harness that kept `ico` verbatim reported icons - * that the real app never had. That is exactly how the sheet shipped action - * rows with no glyphs while a probe said they were fine. - */ -const svgNode = (kind) => (props = {}) => { - const p = { ...applyKidsOpt(props) }; - if (p.ico) { - p.chartId = p.ico; - delete p.ico; - } - return { __kind: kind, ...p }; -}; - -function installGlobals() { - const saved = {}; - const set = (k, v) => { - saved[k] = global[k]; - global[k] = v; - }; - - // `flow` is what ui-core stamps as data-flow, and it is the only thing that - // tells a Box.X from a Box.Y — the descriptor is otherwise identical. Carried - // here for the same reason render-skeleton.js carries it: a harness that - // renders these descriptors gets `display:flex` and its direction from - // `.box[data-flow]` (skin/lib/container.scss), so a box without it stacks its - // children the wrong way and any `flex: 1` child collapses. - const boxNode = (flow) => (props = {}) => ({ - __kind: "box", - __flow: flow, - ...applyKidsOpt(props), - }); - set("Skeletons", { - Box: Object.assign(boxNode("y"), { - X: boxNode("x"), - Y: boxNode("y"), - Z: boxNode("y"), - G: boxNode("y"), - }), - Note: node("note"), - Element: node("element"), - Button: { Svg: svgNode("button.svg"), Label: node("button.label") }, - Image: { Svg: svgNode("image.svg") }, - UserProfile: node("profile"), - }); - // Keys echo back as their own name, so a missing translation is visible - // rather than blank — and `label` assertions can match on the key. - set("LOCALE", new Proxy({}, { get: (_t, k) => String(k) })); - set("Organization", { name: () => "Acme" }); - set("Visitor", { - id: "me", - firstname: () => "Me", - lastname: () => "", - fullname: () => "Me", - }); - set("_a", new Proxy({}, { get: (_t, k) => String(k) })); - set("_e", new Proxy({}, { get: (_t, k) => String(k) })); - set("_", require("lodash")); - - return () => { - for (const k of Object.keys(saved)) { - if (saved[k] === undefined) delete global[k]; - else global[k] = saved[k]; - } - }; -} - -// webpack aliases. The sheets pull the folder-art template (media/) and the -// mute cache (builtins/) — neither answer changes the rows under test, so the -// generic alias-stub (every call answers {}) is enough for both. -function installResolver() { - const orig = Module._resolveFilename; - Module._resolveFilename = function (request, ...rest) { - if (/^(media|libs|assets|builtins)\//.test(request)) { - return require.resolve("./alias-stub.js"); - } - return orig.call(this, request, ...rest); - }; - return () => { - Module._resolveFilename = orig; - }; -} - -const UI = { fig: { family: "desk-module" } }; - -// sheet: "workspaceSheet" | "gotoSheet" | "accountSheet" | "newSheet"; -// args are passed through after `ui`. -function render(sheet, ...args) { - return renderWith({}, sheet, ...args); -} - -/** - * As `render`, with extra members on the `ui` the builder is handed. - * - * workspaceSheet is the one sheet that calls back into the desk — - * `ui._workspaceKey(row)` decides which row is the open workspace, for both the - * header and the rows' tick — so rendering it needs more than `fig`. The caller - * supplies that method (lifted from desk/index.js, so the test runs the shipped - * one) rather than this file carrying a copy that could drift from it. - */ -function renderWith(extra, sheet, ...args) { - const restoreGlobals = installGlobals(); - const restoreResolver = installResolver(); - try { - const p = require.resolve("../../src/drumee/modules/desk/skeleton/mobile-sheets.js"); - const items = require.resolve("../../src/drumee/modules/desk/skeleton/create-items.js"); - delete require.cache[p]; - delete require.cache[items]; - const tree = require(p)[sheet]({ ...UI, ...extra }, ...args); - return { __kind: "box", kids: tree }; - } finally { - restoreResolver(); - restoreGlobals(); - } -} - -function* walk(n) { - if (!n || typeof n !== "object") return; - yield n; - for (const k of [].concat(n.kids || [])) yield* walk(k); -} - -const hasClass = (n, cls) => - typeof n.className === "string" && n.className.split(/\s+/).includes(cls); - -function find(tree, cls) { - for (const n of walk(tree)) if (hasClass(n, cls)) return n; - return null; -} - -function findAll(tree, cls) { - const out = []; - for (const n of walk(tree)) if (hasClass(n, cls)) out.push(n); - return out; -} - -// Every sheet row re-dispatches through "mobile-sheet-go"; the REAL service -// travels as goTarget. This is the sheet-side analogue of servicesIn. -const goTargetsIn = (n) => - [...walk(n)].map((k) => k.goTarget).filter(Boolean); - -const labelsIn = (n) => - [...walk(n)] - .filter((k) => k.__kind === "note" && k.content) - .map((k) => String(k.content)); - -module.exports = { - render, - renderWith, - walk, - find, - findAll, - hasClass, - goTargetsIn, - labelsIn, -}; diff --git a/tests/helpers/render-skeleton.js b/tests/helpers/render-skeleton.js deleted file mode 100644 index cf8f0f825..000000000 --- a/tests/helpers/render-skeleton.js +++ /dev/null @@ -1,436 +0,0 @@ -// Render the REAL task-panel skeleton into a plain tree so tests can assert -// against markup the panel actually produces. -// -// Every hand-built fixture in this suite has, at least once, been green while -// the shipped markup was wrong: data-comment-id existed only on the edit-mode -// row, and the normal row had no drop overlay. A fixture cannot see either. -// This can. -const Module = require("node:module"); - -// Descriptor factory: keeps props verbatim and normalises children to `kids`. -const node = (kind) => (props = {}) => ({ __kind: kind, ...props }); - -function installGlobals() { - const saved = {}; - const set = (k, v) => { - saved[k] = global[k]; - global[k] = v; - }; - - // `flow` is what ui-core stamps as data-flow, and it is the only thing that - // tells a Box.X from a Box.Y — the descriptor is otherwise identical. - const boxNode = (flow) => (props = {}) => ({ __kind: "box", __flow: flow, ...props }); - set("Skeletons", { - Box: Object.assign(boxNode("y"), { - X: boxNode("x"), Y: boxNode("y"), Z: boxNode("y"), G: boxNode("y"), - }), - Note: node("note"), - Element: node("element"), - Entry: node("entry"), - // The reminder-style entry (ui-core toolkit maps EntryBox -> entry/reminder). - EntryBox: node("entrybox"), - Textarea: node("textarea"), - Button: { Svg: node("button.svg"), Label: node("button.label") }, - Image: { Svg: node("image.svg") }, - UserProfile: node("profile"), - Wrapper: Object.assign(node("wrapper"), { Y: node("wrapper"), X: node("wrapper") }), - FileSelector: node("fileselector"), - }); - set("LOCALE", new Proxy({}, { get: (_t, k) => String(k) })); - // Reached by the workspace preview's topbar (toolkit/app-preview.js), which - // names the org on its pill. - set("Organization", { name: () => "Org-name", id: "org", get: () => "" }); - set("Visitor", { - id: "me", - get: () => "", - isMobile: () => false, - device: () => "desktop", - }); - const dayjs = () => ({ - format: () => "Jan 1", - isBefore: () => false, - isSame: () => false, - isValid: () => true, - add: () => dayjs(), - valueOf: () => 0, - fromNow: () => "just now", - startOf: () => dayjs(), - endOf: () => dayjs(), - diff: () => 0, - date: () => 1, - month: () => 0, - year: () => 2026, - day: () => 1, - unix: () => 0, - }); - dayjs.unix = () => dayjs(); - set("Dayjs", dayjs); - set("_a", new Proxy({}, { get: (_t, k) => String(k) })); - set("_e", new Proxy({}, { get: (_t, k) => String(k) })); - // `privilege` mirrors lex/constants — skeleton/toolkit/permission builds its - // role table from it at MODULE load, so any skeleton reaching that toolkit - // (the workspace-members panels) cannot even be required without it. - set("_K", { - order: { descending: "desc" }, - char: { empty: "" }, - tag: { div: "div" }, - privilege: { - owner: 0b0111111, - admin: 0b0011111, - delete: 0b0001111, - write: 0b0001111, - modify: 0b0001111, - upload: 0b0001111, - get: 0b0000111, - download: 0b0000111, - chat: 0b0000111, - read: 0b0000011, - view: 0b0000011, - anonymous: 0b0000001, - }, - // The single BITS, as opposed to the cumulative words above — the role - // table tests a privilege word against these. - permission: { - owner: 0b0100000, - admin: 0b0010000, - delete: 0b0001000, - write: 0b0001000, - modify: 0b0001000, - upload: 0b0001000, - get: 0b0000100, - download: 0b0000100, - chat: 0b0000110, - read: 0b0000010, - view: 0b0000010, - anonymous: 0b0000001, - anyone: 0b0000001, - guest: 0b0000001, - }, - }); - // Kind registry lookups. Widgets name kinds two levels deep (KIND.menu.topic) - // and the real registry answers with the snake_case seed key, so this returns - // `menu_topic`; a one-level read (KIND.menu) still stringifies to `menu`. - const kindLeaf = (name) => - new Proxy({}, { - get: (_t, k) => { - if (k === Symbol.toPrimitive || k === "toString" || k === "valueOf") { - return () => name; - } - return `${name}_${String(k)}`; - }, - }); - set("KIND", new Proxy({}, { get: (_t, k) => kindLeaf(String(k)) })); - set("bootstrap", () => ({ endpoint: "", keysel: "" })); - set("_", require("lodash")); - // A few descriptors touch the DOM while building (date pickers, editors). - const stubEl = () => ({ - style: {}, - dataset: {}, - classList: { add() {}, remove() {}, contains: () => false }, - appendChild() {}, - setAttribute() {}, - querySelector: () => null, - querySelectorAll: () => [], - }); - if (typeof global.document === "undefined") { - set("document", { - createElement: stubEl, - createTextNode: () => ({}), - querySelector: () => null, - querySelectorAll: () => [], - body: stubEl(), - }); - } - if (typeof global.window === "undefined") { - set("window", { innerHeight: 900, innerWidth: 1440, getSelection: () => null }); - } - return () => { - for (const k of Object.keys(saved)) { - if (saved[k] === undefined) delete global[k]; - else global[k] = saved[k]; - } - }; -} - -// webpack aliases `media/...`, `libs/...` and `assets/...`; stub them for node. -// `desk/...` and `builtins/...` are aliased to real directories -// (webpack/resolve.js) and are resolved for real, because what they point at is -// pure JS and is exactly what the caller wants to assert against: the tour -// registry under `desk/`, and the shared role table -// (builtins/skeleton/toolkit/permission) every workspace-members panel builds -// its privilege decisions from. -function installResolver() { - const { join } = require("node:path"); - const DESK = join(__dirname, "..", "..", "src", "drumee", "modules", "desk"); - const BUILTINS = join(__dirname, "..", "..", "src", "drumee", "builtins"); - const orig = Module._resolveFilename; - Module._resolveFilename = function (request, ...rest) { - if (/^desk\//.test(request)) { - return orig.call(this, join(DESK, request.replace(/^desk\//, "")), ...rest); - } - if (/^builtins\//.test(request)) { - return orig.call( - this, - join(BUILTINS, request.replace(/^builtins\//, "")), - ...rest, - ); - } - if (/^media\//.test(request) || /^libs\//.test(request) || /^assets\//.test(request)) { - return require.resolve("./alias-stub.js"); - } - return orig.call(this, request, ...rest); - }; - return () => { - Module._resolveFilename = orig; - }; -} - -const DEFAULT_COMMENT = { - id: "c1", - task_id: "t1", - author_uid: "me", - body: "hello", - ctime: 0, - reactions: [], - attachments: [], -}; - -function makeUi(over = {}) { - const cols = [ - { key: "todo", name: "To do", theme: "default", color: "#AEAEB2", is_done: 0, position: 0, custom: 1 }, - ]; - const base = { - fig: { family: "tasks-panel" }, - mget: () => null, - getState: () => ({ todo: [] }), - getColumns: () => cols, - getColumnThemes: () => ({ default: "#AEAEB2" }), - isDoneStatus: () => false, - isColumnWatched: () => false, - getBoardModalState: () => ({ open: false, theme: "default", title: "", isDefault: true }), - getColMenuFor: () => null, - getColRenameDraft: () => null, - getPriorities: () => [{ key: "medium", label: "PRIORITY_MEDIUM", color: "#71A3F4" }], - getMembers: () => [{ id: "me", firstname: "Me", lastname: "You" }], - getMember: () => ({ firstname: "Me", lastname: "You" }), - getLabels: () => [], - getLabel: () => null, - getKnownAssignees: () => [], - getFilterUids: () => [], - getFilters: () => ({ keyword: "", priority: [], status: [], due: null, files: null }), - isFilterCatOpen: () => false, - isFilterDimActive: () => false, - isFilterActive: () => false, - getView: () => "board", - getSort: () => null, - getCalMode: () => "month", - getCalCursor: () => null, - getGanttMode: () => "weeks", - getGanttSelected: () => new Set(), - isCreating: () => false, - getCreateDraft: () => null, - getPickerOpen: () => null, - getFileSearch: () => ({ query: "", results: [], scope: null, page: 1, hasMore: false }), - getDetailTask: () => null, - getDetailDraft: () => null, - getDetailAttachments: () => [], - getComments: () => [], - getEditingCommentId: () => null, - getReplyingTo: () => null, - getReactPickerFor: () => null, - getCommentDraft: () => null, - getCommentEditDraft: () => null, - getReplyDraft: () => null, - getActivityTab: () => "comments", - getTaskHistory: () => [], - getRowUploads: () => [], - // Child items drafted while the parent is still being created — the create - // modal reads this whenever it draws, so a fixture without it cannot render - // that modal at all. - getPendingSubtasks: () => [], - // The rest of the reader surface the skeleton calls. Every one of these was - // absent, so `render()` threw on the FIRST column it drew (cardWindow) and - // nothing could use it — the two tests that render a skeleton today both go - // through renderModule with their own stub, which is what hid it. - // - // Defaults are the "nothing here yet" answer in each case, so a test that - // cares about one overrides it and a test that does not is unaffected. - cardWindow: () => 60, - getDefaultStatus: () => "todo", - getFilteredTasks: () => [], - getTopLevelTasks: () => [], - getTaskById: () => null, - getSubtasks: () => [], - getSubtaskDraft: () => null, - getSubtaskCount: () => ({ done: 0, total: 0 }), - isSubtask: () => false, - isSubtasksOpen: () => false, - getActivity: () => [], - isCommentRowBusy: () => false, - // Per-section in-flight flags for the detail card (attachments / comments / - // history). False = "fetched, and there are none", which is what an - // already-settled fixture should look like. - isLoading: () => false, - // Which overlays have already played their entrance. 0 keeps the fixture - // in the state a freshly-opened overlay is in. - hasPainted: () => 0, - // Mirrors tasks_panel.pickerService — the assignee/reporter scopes' service - // names, which the pickers stamp on their rows. - pickerService: (scope) => { - if (scope === "create") return "create-assignee"; - if (scope === "create-reporter") return "create-reporter"; - if (scope === "detail-reporter") return "set-reporter"; - return "set-assignee"; - }, - }; - return { ...base, ...over }; -} - -// Render and return the descriptor tree. -function render(over = {}) { - const restoreGlobals = installGlobals(); - const restoreResolver = installResolver(); - try { - const path = require.resolve( - "../../src/drumee/builtins/window/tasks/skeleton/index.js", - ); - delete require.cache[path]; - const make = require(path); - return make(makeUi(over)); - } finally { - restoreResolver(); - restoreGlobals(); - } -} - -// Render ANY skeleton module, with a caller-supplied ui stub. -// -// `render()` above is the tasks panel with its own large stub; this is the same -// machinery for every other skeleton in the app, where the ui a skeleton needs -// is usually two or three methods. -// -// @param {String} relPath from the repo root -// @param {Object} ui the stub the skeleton will be called with -// @param {...*} rest further arguments the skeleton takes — a step -// skeleton's SCREENS entry and its state, say, which decide what it draws -function renderModule(relPath, ui, ...rest) { - const { join } = require("node:path"); - const restoreGlobals = installGlobals(); - const restoreResolver = installResolver(); - try { - const path = require.resolve(join(__dirname, "..", "..", relPath)); - delete require.cache[path]; - const make = require(path); - return make(ui, ...rest); - } finally { - restoreResolver(); - restoreGlobals(); - } -} - -// Depth-first walk over `kids`. -function* walk(n) { - if (!n || typeof n !== "object") return; - yield n; - for (const k of [].concat(n.kids || [])) yield* walk(k); -} - -const hasClass = (n, cls) => - typeof n.className === "string" && n.className.split(/\s+/).includes(cls); - -function find(tree, cls) { - for (const n of walk(tree)) if (hasClass(n, cls)) return n; - return null; -} - -function findAll(tree, cls) { - const out = []; - for (const n of walk(tree)) if (hasClass(n, cls)) out.push(n); - return out; -} - -// Direct children only — the overlay skin rules use `>`. -const childrenWithClass = (n, cls) => - [].concat((n && n.kids) || []).filter((k) => k && hasClass(k, cls)); - -module.exports = { - render, - renderModule, - installGlobals, - installResolver, - walk, - find, - findAll, - hasClass, - childrenWithClass, - DEFAULT_COMMENT, -}; - -// Descriptor tree → HTML, so a browser can lay out what the skeleton really -// emits. Only the attributes layout and hit-testing depend on. -// -// A BOX MUST CARRY ITS AXIS OR NOTHING LAYS OUT. ui-core renders every Box as -// `.box[data-flow=x|y]`, and skin/lib/container.scss is what turns that into -// `display:flex` with a direction — without it a Box.X stacks its children -// vertically and any `flex: 1` child collapses to zero. Measurements taken -// that way look like a broken layout and are simply a broken fixture. -// -// The axis is not on the descriptor (Skeletons.Box.X and .Y are the same -// factory), so `flow` is stamped by the factory itself — see installGlobals. -function toHtml(n) { - if (n == null || typeof n !== "object") return ""; - const box = n.__flow ? ` data-flow="${n.__flow}"` : ""; - const cls = n.className - ? ` class="${n.__flow ? "box " : ""}${n.className}"` - : (n.__flow ? ' class="box"' : ""); - // BOTH ATTRIBUTE CHANNELS. ui-core takes plain HTML attributes through - // `attribute` (that is how Skeletons.Element carries an 's src — see - // card() in tutorial/skeleton/toolkit/empty-state.js) and data-* through - // `attrOpt`. A harness that emitted only the second could never render an - // image at all, which is how a carousel of photographs measured as a - // carousel of empty boxes. - const attrs = Object.entries({ ...(n.attribute || {}), ...(n.attrOpt || {}) }) - .filter(([, v]) => v != null) - .map(([k, v]) => ` ${k}="${String(v)}"`) - .join(""); - const ds = Object.entries(n.dataset || {}) - .filter(([, v]) => v != null) - .map(([k, v]) => ` data-${k}="${String(v)}"`) - .join(""); - // INLINE STYLE IS PART OF THE LAYOUT, not decoration. The workspace preview - // composes its window at the app's real width by setting `style.width` on - // one box and scaling the result down; dropped, that box shrink-to-fits its - // container and the miniature is measured at the wrong size — which looks - // like a broken component and is a broken fixture. - // BOTH CHANNELS. ui-core reads `opt.style || opt.styleOpt` (letc.js), and the - // tour's tracker views use the second one for everything that is computed — - // the donut's conic-gradient, the gantt's bar offsets, the board's progress - // fill. A harness that honours only the first draws them all as empty boxes. - const style = Object.entries({ ...(n.styleOpt || {}), ...(n.style || {}) }) - .filter(([, v]) => v != null) - .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${v}`) - .join(";"); - const css = style ? ` style="${style}"` : ""; - const kids = [].concat(n.kids || []).map(toHtml).join(""); - const text = n.content != null && !kids ? String(n.content) : ""; - // `tagName` is what an Element uses to be something other than a div — an - // , mostly. A void element takes no children and no text. - if (n.__kind === "element" && n.tagName) { - const tag = String(n.tagName).toLowerCase(); - const open = `<${tag}${cls}${box}${attrs}${ds}${css}>`; - return /^(img|br|hr|input)$/.test(tag) ? open : `${open}${text}${kids}`; - } - // AN ICON IS AN , not a div. ui-core renders Image.Svg as a - // reference into the sprite (`#--icon-`), and emitting a bare box for - // it left every glyph out of the picture — which is fine for a descriptor - // test and useless for a harness that is comparing a rendering to a design. - // The caller inlines icons/sprites/normalized.sprite.svg for these to - // resolve against; with no sprite on the page they render as nothing, which - // is what they did before anyway. - if (n.__kind === "image.svg" && n.ico) { - return `` - + ``; - } - return `${text}${kids}`; -} -module.exports.toHtml = toHtml; diff --git a/tests/helpers/svg-asset-stub.js b/tests/helpers/svg-asset-stub.js deleted file mode 100644 index 7918cb804..000000000 --- a/tests/helpers/svg-asset-stub.js +++ /dev/null @@ -1,9 +0,0 @@ -// What `require("some.svg")` evaluates to under webpack: url-loader inlines -// the file as a data URI STRING (webpack/module.js). -// -// alias-stub.js cannot stand in for it. That one is a callable Proxy whose -// every property is a function, so a skeleton doing `${LOGO.default || LOGO}` -// interpolates a function's SOURCE into its markup — and that source contains -// `>` (from `=>`), which silently breaks any assertion matching an HTML tag. -// Cost an otherwise-green run to find. -module.exports = "data:image/svg+xml;base64,PHN2ZyBzdHViLz4="; diff --git a/tests/mobile-sheet-open-workspace.test.js b/tests/mobile-sheet-open-workspace.test.js deleted file mode 100644 index 28340b06c..000000000 --- a/tests/mobile-sheet-open-workspace.test.js +++ /dev/null @@ -1,733 +0,0 @@ -// The phone's workspace sheet names the workspace you are in. -// -// The desktop switcher (`desk-module-topbar__ws-menu`) is three blocks: the -// header that NAMES the open workspace (`__ws-head`), the list of everywhere -// else, and the pinned "New workspace" button. The sheet had the last two only, -// so on a phone the one thing the panel could not tell you was which workspace -// you had open — the list's tick is the whole answer, and it is below the fold -// as soon as there are more than a few. -// -// This renders the REAL builder, so a header that loses its glyph, its name or -// its place above the list shows up here. -// -// The second half is the collision that made the header worth testing at all. -// Every PERSONAL workspace carries the user's own hub_id (a personal workspace -// IS the user), so any "is this the open one?" test written against hub_id -// answers YES for all of them at once. The desktop surfaces resolve this with -// `_workspaceKey` — `folder:` vs `hub:` — and the sheet did not: it was -// handed `cur.hub_id` and compared ids, which ticked the entire PERSONAL -// section whenever a personal workspace was open, and would have pointed the -// new header at the first personal row whichever one was really open. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); -const { - renderWith, - find, - findAll, - walk, - hasClass, -} = require("./helpers/render-mobile-sheets.js"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -// One class method lifted out of index.js, the same way -// tests/workspace-delete-admin-only.js lifts _mayDeleteWorkspace: it closes at -// the first line that is exactly " }", and nothing inside it is indented that -// shallowly. -function grab(name) { - const start = src.indexOf(` ${name}(`); - assert.ok(start > 0, `${name} not found in ${DESK}`); - const end = src.indexOf("\n }\n", start) + 4; - assert.ok(end > start, `${name} has no end`); - return src.slice(start, end); -} - -// The SHIPPED _workspaceKey, run against the globals the sheet builder sees. -// _a echoes its keys (so `_a.folder` is "folder") and Visitor.id is "me", which -// is what makes the personal rows below collide the way the real ones do. -const _workspaceKey = new Function( - "_a", - "Visitor", - `return { ${grab("_workspaceKey")} }._workspaceKey;`, -)( - new Proxy({}, { get: (_t, k) => String(k) }), - { id: "me" }, -); - -// The SHIPPED grouping too — the sheet calls `ui._groupWorkspaces` now, so the -// headings under test are the desk's real taxonomy and not a copy. It reads no -// `this`, only _a and LOCALE, both of which echo their keys here (so -// `LOCALE.INTERNAL` is "INTERNAL" and `_a.private` is "private"). -const _groupWorkspaces = new Function( - "_a", - "LOCALE", - `return { ${grab("_groupWorkspaces")} }._groupWorkspaces;`, -)( - new Proxy({}, { get: (_t, k) => String(k) }), - new Proxy({}, { get: (_t, k) => String(k) }), -); - -// Two hubs and two personal workspaces. Both personal rows carry hub_id "me" — -// that is the point, not an accident of the fixture. -const HUB_A = { hub_id: "h1", filename: "Marketing", area: "private", filetype: "hub" }; -const HUB_B = { hub_id: "h2", filename: "Partners", area: "share", filetype: "hub" }; -const MINE_1 = { hub_id: "me", nid: "n1", filename: "My files", filetype: "folder" }; -const MINE_2 = { hub_id: "me", nid: "n2", filename: "Drafts", filetype: "folder" }; -const ROWS = [HUB_A, HUB_B, MINE_1, MINE_2]; - -// The areas that only the shared taxonomy has an opinion about: `restricted` -// belongs with INTERNAL and `dmz` with EXTERNAL, and the sheet's old -// `filetype !== folder` test put both under one WORKSPACES heading. -const HUB_R = { hub_id: "h3", filename: "Legal", area: "restricted", filetype: "hub" }; -const HUB_D = { hub_id: "h4", filename: "Clients", area: "dmz", filetype: "hub" }; -const HUB_P = { hub_id: "h5", filename: "Website", area: "public", filetype: "hub" }; - -const sheet = (cur, rows = ROWS) => - renderWith({ _workspaceKey, _groupWorkspaces }, "workspaceSheet", rows, cur); - -const HEAD = "desk-module__msheet-ws-head"; -const LIST = "desk-module__msheet-list"; -const CHECK = "desk-module__msheet-check"; - -const textIn = (n) => - [...walk(n)].filter((k) => k.__kind === "note" && k.content).map((k) => String(k.content)); - -const HEADING = "desk-module__msheet-heading"; - -const headingsOf = (tree) => - findAll(tree, HEADING).map((h) => String(h.content)); - -// The list is a FLAT array — heading, its rows, next heading — so "under" is -// document order, not nesting. Reading it this way is also what proves the -// rows land beneath the right heading rather than merely existing somewhere. -const rowsUnder = (tree, label) => { - const kids = [].concat((find(tree, LIST) || {}).kids || []); - const at = kids.findIndex( - (k) => hasClass(k, HEADING) && String(k.content) === label, - ); - if (at < 0) return null; - const out = []; - for (const k of kids.slice(at + 1)) { - if (hasClass(k, HEADING)) break; - out.push(textIn(k)[0]); - } - return out; -}; - -test("the header names the open HUB workspace", () => { - const head = find(sheet(HUB_A), HEAD); - assert.ok(head, "no open-workspace header"); - assert.deepEqual(textIn(head), ["Marketing"]); -}); - -test("the header names the open PERSONAL workspace, not the first one", () => { - // The whole point: MINE_2 and MINE_1 are indistinguishable by hub_id. - const head = find(sheet(MINE_2), HEAD); - assert.ok(head, "no open-workspace header"); - assert.deepEqual(textIn(head), ["Drafts"]); -}); - -test("the header carries the area-tinted glyph", () => { - const head = find(sheet(HUB_B), HEAD); - const ico = [...walk(head)].find( - (n) => typeof n.className === "string" && n.className.includes("__msheet-ws-ico"), - ); - assert.ok(ico, "header has no workspace glyph"); - // The area rides the class list — that is what tints the folder shape. - assert.ok(ico.className.includes("share"), `glyph lost its area: ${ico.className}`); - assert.ok( - ico.className.includes("desk-module__msheet-ws-ico--head"), - `glyph lost its header size: ${ico.className}`, - ); -}); - -test("the header sits above the list, outside it", () => { - const tree = sheet(HUB_A); - const top = tree.kids.map((k) => (typeof k.className === "string" ? k.className : "")); - const headAt = top.findIndex((c) => c.includes("__msheet-ws-head")); - const listAt = top.findIndex((c) => c.includes("__msheet-list")); - assert.ok(headAt > -1, "header is not a top-level block"); - assert.ok(listAt > -1, "list is not a top-level block"); - assert.ok(headAt < listAt, "header must precede the list"); - // Outside the scroller, or it scrolls away with the rows it names. - assert.equal(find(find(tree, LIST), HEAD), null); -}); - -test("no header when the open workspace is not in the payload", () => { - // First paint, and the signed-out-of-everything case. A header fed empty - // would still draw its rule and its padding. - assert.equal(find(sheet(null), HEAD), null); - assert.equal(find(sheet({ hub_id: "gone", filetype: "hub" }), HEAD), null); -}); - -// ── the header and the list do not say the same thing twice ──────────────── -// -// The open workspace is drawn once, by the header. It is NOT repeated in the -// rows below, and there is no tick — the list is everywhere ELSE to go, so -// there is nothing in it to mark as "you are here". -// -// The personal cases carry the weight. Every personal workspace has the user's -// own hub_id, so a filter written against the id would empty the whole PERSONAL -// section the moment any one of them was open. Only the key distinguishes them. -test("the open workspace is not repeated in the list", () => { - for (const [cur, gone] of [ - [HUB_A, "Marketing"], - [MINE_1, "My files"], - [MINE_2, "Drafts"], - ]) { - const tree = sheet(cur); - const names = findAll(find(tree, LIST), "desk-module__msheet-row").map( - (r) => textIn(r)[0], - ); - assert.ok( - !names.includes(gone), - `"${gone}" is in the header AND the list: ${JSON.stringify(names)}`, - ); - // The header is where it went, not nowhere. - assert.deepEqual(textIn(find(tree, HEAD)), [gone]); - } -}); - -test("no row is ticked any more", () => { - for (const cur of [HUB_A, MINE_1, MINE_2]) { - assert.equal(findAll(sheet(cur), CHECK).length, 0, "a tick survived"); - } -}); - -test("only the open workspace leaves — its siblings stay", () => { - // The precise shape of the personal collision: MINE_2 open must remove - // "Drafts" and keep "My files", which shares its hub_id. - const names = findAll(find(sheet(MINE_2), LIST), "desk-module__msheet-row").map( - (r) => textIn(r)[0], - ); - assert.deepEqual(names, ["Marketing", "Partners", "My files"]); -}); - -test("the list is whole when there is no header to duplicate", () => { - // Nothing open, and a workspace reached by deep link that this payload does - // not carry: no header is built, so nothing may be hidden either. - for (const cur of [null, { hub_id: "gone", filetype: "hub" }]) { - const tree = sheet(cur); - assert.equal(find(tree, HEAD), null); - const names = findAll(find(tree, LIST), "desk-module__msheet-row").map( - (r) => textIn(r)[0], - ); - assert.deepEqual(names, ["Marketing", "Partners", "My files", "Drafts"]); - } -}); - -test("a section disappears when its last workspace is the open one", () => { - // One internal hub, one personal, the hub open: INTERNAL must go with its - // only row rather than stand over nothing. - const headings = headingsOf(sheet(HUB_A, [HUB_A, MINE_1])); - assert.deepEqual(headings, ["PERSONAL"]); -}); - -// ── the headings ─────────────────────────────────────────────────────────── -// -// INTERNAL / EXTERNAL / PUBLIC / PERSONAL, the create dialog's vocabulary, via -// the desk's own _groupWorkspaces. The sheet used to put every hub under one -// WORKSPACES heading, which said the same word about a private workspace and -// one shared with people outside the organisation. -test("workspaces are split into internal and external", () => { - const rows = [HUB_A, HUB_B, MINE_1]; - const tree = sheet(null, rows); - assert.deepEqual(headingsOf(tree), ["INTERNAL", "EXTERNAL", "PERSONAL"]); - assert.deepEqual(rowsUnder(tree, "INTERNAL"), ["Marketing"]); - assert.deepEqual(rowsUnder(tree, "EXTERNAL"), ["Partners"]); - assert.deepEqual(rowsUnder(tree, "PERSONAL"), ["My files"]); -}); - -test("restricted joins internal and dmz joins external", () => { - // The two areas the old `filetype !== folder` split had no opinion on. - const rows = [HUB_A, HUB_R, HUB_B, HUB_D]; - const tree = sheet(null, rows); - assert.deepEqual(headingsOf(tree), ["INTERNAL", "EXTERNAL"]); - assert.deepEqual(rowsUnder(tree, "INTERNAL"), ["Marketing", "Legal"]); - assert.deepEqual(rowsUnder(tree, "EXTERNAL"), ["Partners", "Clients"]); -}); - -test("public is its own heading, not folded into external", () => { - const tree = sheet(null, [HUB_B, HUB_P]); - assert.deepEqual(headingsOf(tree), ["EXTERNAL", "PUBLIC"]); - assert.deepEqual(rowsUnder(tree, "PUBLIC"), ["Website"]); -}); - -test("an unknown area still gets a row", () => { - // _groupWorkspaces keeps a WORKSPACES bucket for whatever matches no rule, so - // a new area cannot make a workspace vanish from this sheet. - const odd = { hub_id: "h9", filename: "Experiment", area: "brand-new", filetype: "hub" }; - const tree = sheet(null, [HUB_A, odd]); - assert.deepEqual(headingsOf(tree), ["INTERNAL", "WORKSPACES"]); - assert.deepEqual(rowsUnder(tree, "WORKSPACES"), ["Experiment"]); -}); - -test("the open workspace leaves its group, not another", () => { - // Filtered BEFORE grouping: with the external one open, EXTERNAL empties and - // INTERNAL is untouched. - const tree = sheet(HUB_B, [HUB_A, HUB_B, HUB_D]); - assert.deepEqual(headingsOf(tree), ["INTERNAL", "EXTERNAL"]); - assert.deepEqual(rowsUnder(tree, "EXTERNAL"), ["Clients"]); - assert.deepEqual(rowsUnder(tree, "INTERNAL"), ["Marketing"]); -}); - -// ── the rows have to be TAPPABLE, not merely well-formed ──────────────────── -// -// Everything below the surface was right — `service`, `goTarget`, `wsKey` all -// present — and tapping a workspace still did nothing, because the rows were -// inert. `View.prototype.triggerHandlers` (ui-core letc/addons/letc.js) opens -// with `if (this.mget(_a.active) === 0) return;`, so a widget carrying -// `active: 0` raises no ui event at all: no "mobile-sheet-go", no re-dispatch, -// no switch, and nothing thrown to notice. -// -// The 0 came from the wrapper. `__msheet-list` was given -// `kidsOpt: { active: 0 }` when the list was split into its own scrolling box -// (c59b3d2a, "Fix/access panel header and mobile sheet scroll"), and ui-core's -// builder merges a box's kidsOpt into every DIRECT kid — which here is the -// workspace rows themselves. The row builder's OWN `kidsOpt: { active: 0 }` is -// the correct use of it: that one deactivates a row's icon and label so a child -// cannot swallow the tap meant for the row. -// -// It fitted the report exactly: the "New workspace" button is a SIBLING of the -// list, not a kid of it, so creating still worked; and the other three sheets -// put their rows straight into __msheet-content, which has no kidsOpt, so they -// were never affected. -test("every workspace row is tappable", () => { - const rows = findAll(find(sheet(HUB_A), LIST), "desk-module__msheet-row"); - assert.ok(rows.length, "no rows to tap"); - for (const r of rows) { - assert.notEqual( - r.active, - 0, - `row "${textIn(r)[0]}" is inert — triggerHandlers returns on active:0`, - ); - } -}); - -test("the list wrapper does not deactivate what it holds", () => { - // The guard at the source, so the wrapper cannot quietly regain a kidsOpt - // that reaches the rows. A row's own kidsOpt is a different thing and stays. - const list = find(sheet(HUB_A), LIST); - assert.ok(list, "no list"); - assert.equal( - list.kidsOpt && list.kidsOpt.active, - undefined, - "__msheet-list is deactivating its kids again", - ); -}); - -test("the New workspace button stays tappable too", () => { - const btn = find(sheet(HUB_A), "desk-module__msheet-row--new"); - assert.ok(btn, "no create button"); - assert.notEqual(btn.active, 0, "create button is inert"); - assert.equal(btn.goTarget, "new-workspace"); -}); - -test("every workspace row still re-dispatches with a key to switch on", () => { - const rows = findAll(find(sheet(HUB_A), LIST), "desk-module__msheet-row"); - for (const r of rows) { - assert.equal(r.service, "mobile-sheet-go"); - assert.equal(r.goTarget, "switch-workspace"); - // _switchWorkspace bails on a falsy key, so a row without one closes the - // sheet and changes nothing. - assert.ok(r.wsKey, `row ${textIn(r)[0]} lost its wsKey`); - } - // hub:h1 is absent because HUB_A is the open one and the header has it. - assert.deepEqual( - rows.map((r) => r.wsKey), - ["hub:h2", "folder:n1", "folder:n2"], - ); -}); - -test("the desk hands the sheet the workspace OBJECT, not its hub_id", () => { - // _openWorkspaceSheet is the single call site now — all three states (open, - // back out of the actions, and the actions) go through it. Passing - // cur.hub_id again would restore the personal-workspace collision without - // any of the assertions above noticing. - const body = grab("_openWorkspaceSheet"); - assert.ok(/workspaceSheet\(/.test(body), "call site moved"); - assert.ok( - !/cur\s*&&\s*cur\.hub_id/.test(body), - "the sheet is being handed cur.hub_id again", - ); -}); - -// ── the header's action button ───────────────────────────────────────────── -// -// The phone's answer to the desktop header's ⋯. That one floats a panel beside -// its card; a bottom sheet has no card to float beside, so the sheet itself -// becomes the panel — the actions take the list's place and the button becomes -// the way back. -const ACTION_BTN = "desk-module__msheet-ws-head-action--more"; -const LINK_BTN = "desk-module__msheet-ws-head-action--link"; -const CHIPS = "desk-module__msheet-ws-head-action"; - -// The glyph is a child now, not a prop on the control itself. -// -// `chartId ?? ico`: ui-core's svg factory renames the prop and DELETES `ico` -// (toolkit/builder/button/svg.js), so a descriptor it built carries chartId. -// Reading only `ico` is what let the action rows ship with no glyphs. -const glyphOf = (n) => (n && (n.chartId || n.ico)) || undefined; -const icoOf = (btn) => { - const kid = [...walk(btn)].find((n) => glyphOf(n)); - return glyphOf(kid); -}; - -const ACTIONS = [ - { key: "workspace-access", label: "ACCESS", ico: "apps-link-simple", service: "workspace-access", onDesk: 1 }, - { key: "rename", label: "RENAME", ico: "ph-pencil", service: "workspace-rename", onDesk: 1 }, - { key: "makeACopy", label: "MAKE_A_COPY", ico: "ph-copy", service: "duplicate" }, - { key: "trash", label: "MOVE_TO_TRASH", ico: "ph-trash", service: "remove" }, -]; - -const withActions = (cur, actions) => - renderWith( - { _workspaceKey, _groupWorkspaces }, - "workspaceSheet", - ROWS, - cur, - { actions }, - ); - -test("the header carries an action button that opens the actions", () => { - const btn = find(sheet(HUB_A), ACTION_BTN); - assert.ok(btn, "no action button in the header"); - assert.equal(btn.service, "mobile-ws-actions"); - assert.equal(icoOf(btn), "app-dots-horizontal"); -}); - -test("the action button is built like every other control in this sheet", () => { - // A BOX carrying the service, with an inert glyph inside — the shape - // __msheet-row / __msheet-tile / __msheet-row--new all have. It was a bare - // image_svg with a service on it, which is not a control this sheet renders: - // ui-core's Button.Svg and Image.Svg are literally the same function. - const tree = sheet(HUB_A); - const btn = find(tree, ACTION_BTN); - assert.equal(btn.__kind, "box"); - assert.notEqual(btn.active, 0, "the action button is inert"); - // ...and the header must not have regained a kidsOpt that deactivates it. - const head = find(tree, HEAD); - assert.equal(head.kidsOpt && head.kidsOpt.active, undefined); -}); - -test("no action button when there is no header", () => { - assert.equal(find(sheet(null), ACTION_BTN), null); -}); - -// `closed` is not a free value in this app. skin/lib/utils.scss and -// skin/lib/align.scss both carry an unscoped -// `[data-state="closed"] { visibility: hidden !important; height: 0 !important }`, -// so ANY element stamped with it vanishes wherever it lives. The action button -// used data-state to carry its own two faces and was struck out by that rule in -// list mode — rendered, laid out, then hidden by a file it has nothing to do -// with. Nothing this builder emits may carry a reserved global state. -test("no sheet element stamps a reserved global data-state", () => { - const RESERVED = { "data-state": ["closed", "open"], "data-hide": ["yes", "no"] }; - for (const tree of [ - sheet(HUB_A), - sheet(null), - withActions(HUB_A, ACTIONS), - ]) { - for (const n of walk(tree)) { - const attrs = { ...(n.attrOpt || {}), ...(n.attribute || {}) }; - for (const [k, bad] of Object.entries(RESERVED)) { - if (attrs[k] == null) continue; - assert.ok( - !bad.includes(String(attrs[k])), - `${n.className || n.__kind} sets ${k}="${attrs[k]}" — a global rule hides it`, - ); - } - } - } -}); - -test("the action button carries its face on data-mode", () => { - assert.equal(find(sheet(HUB_A), ACTION_BTN).attrOpt["data-mode"], "list"); - assert.equal( - find(withActions(HUB_A, ACTIONS), ACTION_BTN).attrOpt["data-mode"], - "actions", - ); -}); - -test("in actions mode the button closes instead", () => { - const btn = find(withActions(HUB_A, ACTIONS), ACTION_BTN); - assert.equal(btn.service, "mobile-ws-actions-close"); - assert.equal(icoOf(btn), "cross"); -}); - -test("both faces of the button name a glyph the sprite actually has", () => { - // An `ico` with no matching symbol builds ``, - // which resolves to nothing: a button that is there, is pressable, and looks - // empty. Read from the sprite rather than trusted. - const sprite = readFileSync( - resolve(__dirname, "../icons/sprites/normalized.sprite.svg"), - "utf8", - ); - for (const ico of [ - icoOf(find(sheet(HUB_A), ACTION_BTN)), - icoOf(find(withActions(HUB_A, ACTIONS), ACTION_BTN)), - ]) { - assert.ok( - sprite.includes(`id="--icon-${ico}"`), - `the sprite has no symbol for "${ico}"`, - ); - } -}); - -test("the actions replace the workspace list", () => { - const tree = withActions(HUB_A, ACTIONS); - const labels = findAll(find(tree, LIST), "desk-module__msheet-row").map( - (r) => textIn(r)[0], - ); - assert.deepEqual(labels, ["ACCESS", "RENAME", "MAKE_A_COPY", "MOVE_TO_TRASH"]); - // No workspaces and no group headings while the actions are up. - assert.deepEqual(headingsOf(tree), []); - assert.ok(!labels.includes("Partners"), "a workspace row survived"); -}); - -test("the header still names the workspace while the actions are up", () => { - assert.deepEqual(textIn(find(withActions(HUB_A, ACTIONS), HEAD)), ["Marketing"]); -}); - -test("New workspace stays reachable in actions mode", () => { - const btn = find(withActions(HUB_A, ACTIONS), "desk-module__msheet-row--new"); - assert.ok(btn, "the create button went away with the list"); - assert.equal(btn.goTarget, "new-workspace"); -}); - -test("action rows dispatch to the media item, not the desk", () => { - const rows = findAll(find(withActions(HUB_A, ACTIONS), LIST), "desk-module__msheet-row"); - const byLabel = Object.fromEntries(rows.map((r) => [textIn(r)[0], r])); - for (const r of rows) { - // NOT "mobile-sheet-go" — that one re-dispatches on the desk, which cannot - // answer trash/duplicate/download. - assert.equal(r.service, "mobile-ws-action", `${textIn(r)[0]} routes to the desk`); - assert.ok(r.goTarget, `${textIn(r)[0]} lost its service`); - assert.notEqual(r.active, 0, `${textIn(r)[0]} is inert`); - } - assert.equal(byLabel.MOVE_TO_TRASH.goTarget, "remove"); - assert.equal(byLabel.MOVE_TO_TRASH.onDesk, 0); - // Rename and Manage access are the two the desktop menu answers itself. - assert.equal(byLabel.RENAME.onDesk, 1); - assert.equal(byLabel.RENAME.goTarget, "workspace-rename"); - assert.equal(byLabel.ACCESS.onDesk, 1); -}); - - -// ── the header's action cluster ──────────────────────────────────────────── -// -// The desktop header's __ws-head-actions, on a phone: the chain chip on an -// EXTERNAL workspace, then the ⋯. Internal and personal workspaces get the ⋯ -// alone — they are reached by membership or ownership, so there is no share -// link for a chip to open. -const chipsOf = (tree) => - findAll(tree, CHIPS).map((c) => - String(c.className).includes("--link") ? "link" : "more", - ); - -test("an external workspace shows share AND more", () => { - for (const ws of [HUB_B /* share */, HUB_D /* dmz */]) { - const tree = sheet(ws, [HUB_A, HUB_B, HUB_D, MINE_1]); - assert.deepEqual(chipsOf(tree), ["link", "more"], `${ws.filename}`); - } -}); - -test("internal and personal workspaces show ONLY more", () => { - for (const ws of [HUB_A /* private */, HUB_R /* restricted */, MINE_1, MINE_2]) { - const tree = sheet(ws, [HUB_A, HUB_R, HUB_B, MINE_1, MINE_2]); - assert.deepEqual(chipsOf(tree), ["more"], `${ws.filename}`); - assert.equal(find(tree, LINK_BTN), null, `${ws.filename} grew a share chip`); - } -}); - -test("public is not external either", () => { - // _feedWorkspaceHead gates its chain chip on share/dmz only; public is its - // own area and reaches sharing elsewhere. - assert.deepEqual(chipsOf(sheet(HUB_P, [HUB_P, HUB_A])), ["more"]); -}); - -test("the share chip closes the sheet and opens access on the desk", () => { - const link = find(sheet(HUB_B, [HUB_A, HUB_B]), LINK_BTN); - assert.ok(link, "no share chip"); - // Not raising workspace-access directly: that toggles the secure-share view, - // which would come up underneath an open sheet. - assert.equal(link.service, "mobile-ws-action"); - assert.equal(link.goTarget, "workspace-access"); - assert.equal(link.onDesk, 1); - assert.notEqual(link.active, 0, "the share chip is inert"); -}); - -test("the cluster does not deactivate its chips", () => { - // kidsOpt on the wrapper would merge active:0 into both chips and neither - // would raise anything — the trap that killed the workspace rows once. - const tree = sheet(HUB_B, [HUB_A, HUB_B]); - const cluster = find(tree, "desk-module__msheet-ws-head-actions"); - assert.ok(cluster, "no action cluster"); - assert.equal(cluster.kidsOpt && cluster.kidsOpt.active, undefined); - for (const c of findAll(tree, CHIPS)) { - assert.notEqual(c.active, 0, `${c.className} is inert`); - } -}); - -test("every chip glyph is a symbol the sprite has", () => { - const sprite = readFileSync( - resolve(__dirname, "../icons/sprites/normalized.sprite.svg"), - "utf8", - ); - const trees = [ - sheet(HUB_B, [HUB_A, HUB_B]), - withActions(HUB_A, ACTIONS), - ]; - const icos = new Set(); - for (const t of trees) for (const c of findAll(t, CHIPS)) icos.add(icoOf(c)); - assert.ok(icos.size >= 3, `expected link + more + close, got ${[...icos]}`); - for (const ico of icos) { - assert.ok(sprite.includes(`id="--icon-${ico}"`), `sprite has no "${ico}"`); - } -}); - -test("the desk no longer offers Manage access as a menu row", () => { - // The header's chain chip is that door now. A row here would be the third - // one, which is exactly what the desktop ⋯ filters secureShare/share out to - // avoid. - const body = grab("_mobileWorkspaceActions"); - assert.ok( - !/workspace-access/.test(body), - "Manage access is back in the actions menu, duplicating the header chip", - ); -}); - -test("the desktop more-button uses the same glyph as the phone's", () => { - const head = grab("_feedWorkspaceHead"); - assert.ok( - /ico: "app-dots-horizontal"/.test(head), - "the desktop \u22ef glyph drifted from the phone's", - ); - assert.ok(!/ph-dots-three/.test(head), "ph-dots-three survived"); -}); - -// ── the actions menu wears the desktop context menu's icons ──────────────── -// -// Those rows ARE the desktop workspace context menu -// (`drumee-contextmenu media-grid desk-module-topbar`), so their glyphs must be -// that menu's — read off the shared icon map, not invented here — and drawn the -// way it draws them. -const ACTION_ICO = "desk-module__msheet-action-ico"; - -test("action icons use the shared contextmenu artwork", () => { - // The real map, so a rename glyph that changes there changes here too. - // The icon map reads globals the app provides at runtime. - const savedLodash = global._; - const savedLs = global.localStorage; - global._ = require("lodash"); - global.localStorage = { getItem: () => null, setItem: () => {} }; - const icons = require("../src/drumee/builtins/contextmenu/skeleton/icons.js")({ - fig: { group: "media-grid" }, - }); - if (savedLodash === undefined) delete global._; else global._ = savedLodash; - if (savedLs === undefined) delete global.localStorage; else global.localStorage = savedLs; - const acts = [ - { key: "rename", label: "Rename", ico: icons.rename, service: "workspace-rename", onDesk: 1 }, - { key: "makeACopy", label: "Make a copy", ico: icons.makeACopy, service: "duplicate" }, - { key: "download", label: "Download", ico: icons.download, service: "download" }, - { key: "trash", label: "Move to trash", ico: icons.trash, service: "remove" }, - ]; - const tree = withActions(HUB_A, acts); - const got = findAll(tree, ACTION_ICO).map(glyphOf); - assert.deepEqual(got, [ - "ctxmenu-rename", - "ctxmenu-copy", - "ctxmenu-download", - "ctxmenu-delete", - ]); - // Every one exists — a name with no symbol renders an empty row. - const sprite = readFileSync( - resolve(__dirname, "../icons/sprites/normalized.sprite.svg"), - "utf8", - ); - for (const ico of got) { - assert.ok(sprite.includes(`id="--icon-${ico}"`), `sprite has no "${ico}"`); - } -}); - -test("action icons are NOT the sheet's own 22px sprite class", () => { - // __msheet-ico forces svg {22×22}, which oversizes ctxmenu-* artwork and - // squares off the glyphs that are not square. - const list = find(withActions(HUB_A, ACTIONS), LIST); - // Scoped to the LIST: the pinned "New workspace" button keeps __msheet-ico, - // and it is not part of the actions menu. - assert.equal(findAll(list, "desk-module__msheet-ico").length, 0); - assert.equal(findAll(list, ACTION_ICO).length, ACTIONS.length); -}); - -test("each action icon stamps its sprite name for the size exceptions", () => { - // The two exceptions (topbar-add, topbar-invite) are keyed on the artwork. - const acts = [ - { key: "inviteMember", label: "Invite", ico: "topbar-invite", service: "invite-member" }, - { key: "addNew", label: "New", ico: "topbar-add", service: "add-new" }, - ]; - for (const n of findAll(withActions(HUB_A, acts), ACTION_ICO)) { - assert.equal(n.attrOpt["data-ico"], glyphOf(n), "data-ico must name the sprite"); - } -}); - -test("a row with no artwork gets no icon, not a stand-in", () => { - const tree = withActions(HUB_A, [ - { key: "odd", label: "Something", service: "whatever" }, - ]); - assert.equal(findAll(tree, ACTION_ICO).length, 0); - // The row itself survives — only its glyph is absent. - const rows = findAll(find(tree, LIST), "desk-module__msheet-row"); - assert.deepEqual(rows.map((r) => textIn(r)[0]), ["Something"]); -}); - -// ── the extraction reads what ui-core really produces ────────────────────── -// -// _mobileWorkspaceActions lifts each glyph off a row built by the SHARED -// contextmenu builder. That builder uses Skeletons.Image.Svg, whose factory -// (ui-core toolkit/builder/button/svg.js) does: -// -// if (this.props.ico) { this.props.chartId = this.props.ico; -// delete this.props.ico; } -// -// so the glyph arrives as `chartId` and `ico` does not exist. Reading only -// `ico` found nothing and the sheet drew action rows with no glyph at all — -// while a probe using a verbatim stub reported the icons were fine. -test("the desk reads the glyph off chartId, not just ico", () => { - const body = grab("_mobileWorkspaceActions"); - assert.ok( - /chartId/.test(body), - "_mobileWorkspaceActions reads only `ico` — ui-core renames it to chartId", - ); -}); - -test("extraction survives ui-core's ico -> chartId rename", () => { - // The shipped extraction, run against a row shaped exactly as the real - // builder emits one: the icon kid carries chartId and NO ico. - const body = grab("_mobileWorkspaceActions"); - const m = body.match( - /const kids = \[\][\s\S]*?const ico = iconKid && \([^;]+\);/, - ); - assert.ok(m, "the extraction block moved — update this test"); - const extract = new Function( - "row", - `${m[0].replace(/^\s*const kids/m, "const kids")} return ico;`, - ); - const realShape = { - kids: [ - { __kind: "image.svg", chartId: "ctxmenu-rename", className: "contextmenu-item__icon" }, - { __kind: "note", content: "Rename", className: "contextmenu-item__label" }, - ], - }; - assert.equal(extract(realShape), "ctxmenu-rename"); - // A hand-made row that still spells it `ico` keeps working. - assert.equal( - extract({ kids: [{ ico: "ctxmenu-delete" }, { content: "Trash" }] }), - "ctxmenu-delete", - ); - // No artwork at all -> no glyph, so the row renders without one. - assert.ok(!extract({ kids: [{ content: "Something" }] })); -}); diff --git a/tests/org-overview-cache.test.js b/tests/org-overview-cache.test.js deleted file mode 100644 index 3f3af308e..000000000 --- a/tests/org-overview-cache.test.js +++ /dev/null @@ -1,221 +0,0 @@ -// The organisation screen showed a workspace list from page load. -// -// Duy, 2026-09-16: a workspace was missing from the org management tab. -// libs/org-overview keeps ONE module-level promise for the whole page session -// and hands it to every reader. Its only invalidator was org-tab._refresh, -// which runs on the "org:refresh" broadcast, raised only from inside -// desk_org_view — so a workspace created or deleted while that screen was shut -// never reached the cache. desk_org_view is destroy-on-close, so the next open -// re-rendered from the boot-time answer. -// -// TWO ENDS, BOTH REAL. The lib is required and exercised as shipped; the desk's -// chokepoint is lifted out of index.js and run against a fake `this`, the same -// technique tests/call-tile-drag.test.js uses. The perf guard is a first-class -// case here, not an afterthought: the obvious one-line fix (org-tab._refresh) -// would have put an organization.overview round trip — three result sets, a -// whole-domain scan — on every workspace mutation. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const LIB = resolve(__dirname, "../src/drumee/libs/org-overview.js"); -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -// ── the cache itself ──────────────────────────────────────────────────────── - -// A fresh copy of the module per case: `__pending` is module state, so a cached -// require would leak one case's answer into the next. -function freshLib({ domain = 29 } = {}) { - const saved = { Visitor: global.Visitor, SERVICE: global.SERVICE }; - global.Visitor = { id: "me", get: (k) => (k === "domain_id" ? domain : null) }; - global.SERVICE = { organization: { overview: "organization.overview" } }; - delete require.cache[LIB]; - const lib = require(LIB); - return { lib, restore: () => Object.assign(global, saved) }; -} - -// Counts round trips. Resolves whatever the caller queued. -function fetcher(answers) { - const view = { - calls: 0, - fetchService() { - view.calls += 1; - return Promise.resolve(answers[Math.min(view.calls - 1, answers.length - 1)]); - }, - }; - return view; -} - -test("the cache still works — two readers, one round trip", async () => { - // The property the fix must NOT break. The chip and the org view ask the same - // question and share one answer; that is why this module exists. - const { lib, restore } = freshLib(); - try { - const view = fetcher([{ workspaces: [{ hub_id: "a" }] }]); - const [first, second] = await Promise.all([ - lib.orgOverview(view), - lib.orgOverview(view), - ]); - assert.equal(view.calls, 1); - assert.equal(first.workspaces.length, 1); - assert.equal(second.workspaces.length, 1); - // And a later reader is still served from the cache — no TTL. - await lib.orgOverview(view); - assert.equal(view.calls, 1); - } finally { - restore(); - } -}); - -test("invalidate() makes the next read fetch again — and sees the new workspace", async () => { - const { lib, restore } = freshLib(); - try { - const view = fetcher([ - { workspaces: [{ hub_id: "a" }] }, - { workspaces: [{ hub_id: "a" }, { hub_id: "sc2" }] }, - ]); - const before = await lib.orgOverview(view); - assert.deepEqual(before.workspaces.map((w) => w.hub_id), ["a"]); - - lib.invalidate(); - - const after = await lib.orgOverview(view); - assert.equal(view.calls, 2); - assert.deepEqual(after.workspaces.map((w) => w.hub_id), ["a", "sc2"]); - } finally { - restore(); - } -}); - -test("invalidate() costs nothing on its own", async () => { - // It must not reach the network. The whole point of doing this instead of - // org-tab._refresh() is that a workspace mutation pays for no request. - const { lib, restore } = freshLib(); - try { - const view = fetcher([{ workspaces: [] }]); - await lib.orgOverview(view); - assert.equal(view.calls, 1); - for (let i = 0; i < 50; i += 1) lib.invalidate(); - assert.equal(view.calls, 1); - } finally { - restore(); - } -}); - -test("an account with no organisation never fetches at all", async () => { - // domain 1 — the majority. Dropping a cache that was never populated is a - // no-op, and the read still costs nothing. - const { lib, restore } = freshLib({ domain: 1 }); - try { - const view = fetcher([{ workspaces: [{ hub_id: "a" }] }]); - const data = await lib.orgOverview(view); - lib.invalidate(); - await lib.orgOverview(view); - assert.equal(view.calls, 0); - assert.deepEqual(data.workspaces, []); - assert.equal(data.can_browse, 0); - } finally { - restore(); - } -}); - -// ── the desk's chokepoint ─────────────────────────────────────────────────── - -// One class method, lifted out of index.js as shipped. -function grab(name) { - const start = src.indexOf(` async ${name}(`); - assert.ok(start > 0, `${name} not found in ${DESK}`); - const end = src.indexOf("\n }\n", start) + 4; - assert.ok(end > start, `${name} has no end`); - return src.slice(start, end); -} - -function runCreated(payload = {}, { wsListPart = { el: {} } } = {}) { - const log = []; - const org = { - invalidate: () => log.push("invalidate"), - // Present so the test can prove it is NEVER reached. - orgOverview: () => { - log.push("FETCH"); - return Promise.resolve({}); - }, - orgFeature: () => true, - }; - const api = new Function("require", `return { ${grab("_onWorkspaceCreated")} };`)( - (m) => { - assert.equal(m, "libs/org-overview"); - return org; - }, - ); - const self = { - ...api, - el: { dataset: {} }, - _wsListPart: wsListPart, - _wsHeadPart: null, - _renderWorkspaceMenu: async () => log.push("render-switcher"), - _syncWorkspaceLabel: () => log.push("sync-label"), - _refreshHomeGrid: () => log.push("home-grid"), - _openWorkspaceOrEmptyScreen: async () => log.push("open-empty"), - _openWorkspaceAfterAccessPanel: () => log.push("open-after-access"), - _walkthroughRunning: () => false, - _workspaceKey: (w) => (w ? "key" : null), - _openCreatedWorkspace: async () => log.push("open-created"), - _showEmptyWorkspaceScreen: async () => log.push("empty-screen"), - _fetchWorkspaces: async () => { - log.push("fetch-workspaces"); - return [{}]; - }, - }; - return self._onWorkspaceCreated(payload).then(() => log); -} - -test("a workspace change drops the org cache", async () => { - const log = await runCreated(); - assert.ok(log.includes("invalidate"), log.join(" > ")); -}); - -test("...and does not pay for a refetch", async () => { - // THE PERF GUARD. org-tab._refresh() would have fetched here; this must not. - // If someone later 'improves' this into a refresh, this case fails. - const log = await runCreated(); - assert.ok(!log.includes("FETCH"), `org overview refetched on a create: ${log.join(" > ")}`); -}); - -test("the cache is dropped BEFORE the first await", async () => { - // A fetch already in flight must not be able to re-cache the stale answer - // behind the invalidate. Nothing may run ahead of it. - const log = await runCreated(); - assert.equal(log[0], "invalidate", log.join(" > ")); -}); - -test("every branch of the handler still drops it", async () => { - // The three early returns: created-from-empty (personal), created-from-empty - // (hub), and the switch-to-it path. All are downstream of the invalidate, but - // asserting it keeps a future reorder honest. - for (const [label, payload, opts] of [ - ["personal from empty", { personal: 1 }, {}], - ["hub from empty", {}, {}], - ["open the created one", { open: 1, workspace: { hub_id: "x" } }, {}], - ["no switcher parts yet", {}, { wsListPart: null }], - ]) { - const self = { el: { dataset: { noWorkspace: label.includes("empty") ? "1" : "0" } } }; - const log = await runCreated(payload, opts).catch((e) => { - assert.fail(`${label} threw: ${e.message}`); - }); - assert.equal(log[0], "invalidate", `${label}: ${log.join(" > ")}`); - assert.ok(!log.includes("FETCH"), `${label} refetched`); - void self; - } -}); - -test("nothing else in the desk refetches the overview on workspace:refresh", async () => { - // The two "workspace:refresh" subscriptions are _onWorkspaceCreated (above) - // and _onWorkspaceListChanged. The second one resyncs the SWITCHER, which is - // a different cache (desk.home) — it must not have grown an org read. - const start = src.indexOf(" _onWorkspaceListChanged() {"); - assert.ok(start > 0); - const body = src.slice(start, src.indexOf("\n }\n", start)); - assert.ok(!/org-overview|orgOverview/.test(body), body); -}); diff --git a/tests/player-share-click.test.js b/tests/player-share-click.test.js deleted file mode 100644 index 5b3dcff8c..000000000 --- a/tests/player-share-click.test.js +++ /dev/null @@ -1,194 +0,0 @@ -// Share in a player's gear menu opens the secure-share panel -// (builtins/player/widget/share — click()). -// -// It used to open it only for a file in an external workspace; a private-area -// file got the "External File Sharing" dead-end modal. The server never -// restricted by area (secure_share.create checks only the write bit), so the -// row now always opens the panel: through the source MFS view when it is -// alive — it owns the share tour — and directly through Wm otherwise. -// -// A loading card covers the wait for the panel's lazy chunk (./loading): its -// bar moves on real milestones and it stays up for a short floor, then fades -// as the panel is asked for. -// -// The module is required as shipped. Its webpack-only requires (the scss skin, -// the `dmz/sharebox/area` alias, the logo asset) are resolved here by hand. -const test = require("node:test"); -const assert = require("node:assert"); -const Module = require("node:module"); -const { resolve } = require("node:path"); - -const ROOT = resolve(__dirname, "../src/drumee"); -const SHARE = resolve(ROOT, "builtins/player/widget/share/index.js"); -const LOADING = resolve(ROOT, "builtins/player/widget/share/loading.js"); -const STUB = resolve(__dirname, "helpers/alias-stub.js"); -const SVG_STUB = resolve(__dirname, "helpers/svg-asset-stub.js"); - -const origResolve = Module._resolveFilename; -Module._resolveFilename = function (req, parent, ...rest) { - if (parent && parent.filename === SHARE) { - if (req === "./skin") return STUB; - if (req === "dmz/sharebox/area") return resolve(ROOT, "modules/dmz/sharebox/area.js"); - } - if (req === "assets/drumee-logo.svg") return SVG_STUB; - return origResolve.call(this, req, parent, ...rest); -}; -global._ = require("underscore"); -global._a = new Proxy({}, { get: (_t, k) => k }); -const share = require(SHARE); -const loading = require(LOADING); -Module._resolveFilename = origResolve; - -// A player as click() sees it: a model, an optional MFS view, and _delegate -// with the players' real contract (false when there is no live media). -function player({ area = "private", media = true } = {}) { - const calls = []; - const attrs = { area, nid: "n1", hub_id: "h1", filetype: "document" }; - const mediaView = media - ? { isDestroyed: () => false, mget: (k) => attrs[k], onUiEvent: () => {} } - : null; - const ui = { - media: mediaView, - mget: (k) => attrs[k], - _delegate(cmd, args) { - if (!this.media || this.media.isDestroyed()) return false; - calls.push(args); - return true; - }, - }; - return { ui, calls }; -} - -async function withWm(fn) { - const launched = []; - const saved = global.Wm; - global.Wm = { launch: (item, opts) => launched.push({ item, opts }) }; - try { - await fn(launched); - } finally { - global.Wm = saved; - } -} - -// Records what click() does to the loading card, in order, alongside the -// panel opening — so the tests can say WHEN the panel opens relative to it. -function withCard(fn) { - const events = []; - const saved = loading.show; - loading.show = () => ({ - progress: (pct) => events.push(`progress:${pct}`), - hide: () => { - events.push("hide"); - return Promise.resolve(); - }, - }); - return Promise.resolve() - .then(() => fn(events)) - .finally(() => { - loading.show = saved; - }); -} - -test("private area: delegates to the MFS view as a floating panel", async () => { - await withWm(async (launched) => { - const { ui, calls } = player({ area: "private" }); - await share.click(ui, { service: "secure-share" }); - assert.deepStrictEqual(calls, [{ service: "secure-share", floating: 1 }]); - assert.strictEqual(launched.length, 0); - }); -}); - -test("share area: still delegates the same way", async () => { - await withWm(async (launched) => { - const { ui, calls } = player({ area: "share" }); - await share.click(ui, { service: "secure-share" }); - assert.deepStrictEqual(calls, [{ service: "secure-share", floating: 1 }]); - assert.strictEqual(launched.length, 0); - }); -}); - -test("no live MFS view: launches window_secure_share directly, floating", async () => { - await withWm(async (launched) => { - const { ui } = player({ area: "private", media: false }); - await share.click(ui, { service: "secure-share" }); - assert.strictEqual(launched.length, 1); - const { item, opts } = launched[0]; - assert.strictEqual(item.kind, "window_secure_share"); - assert.strictEqual(item.wm_unique_id, "window_secure_share-n1"); - assert.strictEqual(item.nid, "n1"); - assert.strictEqual(item.hub_id, "h1"); - assert.strictEqual(item.filetype, "document"); - assert.strictEqual(item.floating, 1); - assert.deepStrictEqual(opts, { explicit: 1, singleton: 1 }); - }); -}); - -test("the card fills on milestones, then fades as the panel opens", async () => { - await withWm(() => withCard(async (events) => { - let release; - const savedKind = global.Kind; - global.Kind = { waitFor: () => new Promise((r) => { release = r; }) }; - try { - const { ui, calls } = player({ area: "private" }); - ui._delegate = function (cmd, args) { - events.push("open"); - calls.push(args); - return true; - }; - const started = Date.now(); - const pending = share.click(ui, { service: "secure-share" }); - assert.deepStrictEqual(events, ["progress:15"], "card up, nothing open before the chunk"); - release(); - await pending; - assert.deepStrictEqual(events, ["progress:15", "progress:70", "progress:100", "hide", "open"]); - // The floor: a chunk that resolves at once still keeps the card up. - assert.ok(Date.now() - started >= 500, "card stayed up for its minimum"); - } finally { - global.Kind = savedKind; - } - })); -}); - -test("a chunk that fails to load still takes the card down and still opens", async () => { - await withWm(() => withCard(async (events) => { - const savedKind = global.Kind; - global.Kind = { waitFor: () => Promise.reject(new Error("chunk")) }; - try { - const { ui, calls } = player({ area: "private" }); - await share.click(ui, { service: "secure-share" }); - assert.strictEqual(calls.length, 1); - assert.ok(events.includes("hide")); - } finally { - global.Kind = savedKind; - } - })); -}); - -test("no DOM to draw into: the card is a no-op and the click still opens", async () => { - await withWm(async () => { - const { ui, calls } = player({ area: "private" }); - const card = loading.show(ui); - card.progress(50); - await card.hide(); - await share.click(ui, { service: "secure-share" }); - assert.strictEqual(calls.length, 1); - }); -}); - -test("never raises the External File Sharing modal", async () => { - await withWm(async () => { - let opened = 0; - const saved = global.document; - global.document = { - querySelector: () => null, - createElement: () => { opened++; throw new Error("modal opened"); }, - }; - try { - await share.click(player({ area: "private" }).ui, { service: "secure-share" }); - await share.click(player({ area: "private", media: false }).ui, { service: "secure-share" }); - } finally { - global.document = saved; - } - assert.strictEqual(opened, 0); - }); -}); diff --git a/tests/rail-logo-home.test.js b/tests/rail-logo-home.test.js deleted file mode 100644 index 185743a58..000000000 --- a/tests/rail-logo-home.test.js +++ /dev/null @@ -1,228 +0,0 @@ -// The rail's drumee logo is the desk's temporary Home. -// -// Lexis, 2026-09-15: people who open the Calendar / Inbox / a Settings screen -// have no single way back and "get lost in navigation". The logo, which used to -// be decoration, now leads out — to the organisation screen when this account -// has one it may browse, and back to the workspace otherwise. -// -// TWO HALVES, TESTED TWO WAYS. The skeleton half is rendered for real through -// tests/helpers/render-desk-sidebar.js, so a glyph that loses its service or a -// pin toggle that accidentally gains one shows up here. The desk half is a pair -// of methods inside a 9000-line class that needs the whole runtime to -// instantiate, so — exactly as tests/call-tile-drag.test.js does — they are cut -// out of the SOURCE FILE and run against a fake `this`. Both therefore test the -// shipped text rather than a copy of it. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); -const { render, find, findAll, walk, servicesIn } = - require("./helpers/render-desk-sidebar.js"); - -const FIG = "desk-module-sidebar"; -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -// ── the skeleton ──────────────────────────────────────────────────────────── - -test("both logo glyphs carry the Home gesture", () => { - const tree = render(); - const wordmark = find(tree, `${FIG}__logo-icon`); - const mark = find(tree, `${FIG}__logo-mark`); - - // Both, because the skin swaps one for the other at the mini/expanded - // boundary — whichever is on screen has to be the one that works. - for (const [name, n] of [["wordmark", wordmark], ["mark", mark]]) { - assert.ok(n, `${name} not rendered`); - assert.equal(n.service, "rail-home", `${name} lost its service`); - assert.ok(Array.isArray(n.uiHandler) && n.uiHandler.length === 1, - `${name} needs uiHandler as a one-element ARRAY (getHandlers returns it verbatim)`); - // The flag desk_module.onUiEvent reads off the clicked view to drop the - // transient cards a navigation gesture must not leave standing. - assert.equal(n.railRow, 1, `${name} lost railRow`); - } - - // Same desk, but NOT the same array instance: the renderer keeps `uiHandler` - // per descriptor, so a shared literal would hand two views one array. - assert.equal(wordmark.uiHandler[0], mark.uiHandler[0]); - assert.notStrictEqual(wordmark.uiHandler, mark.uiHandler); -}); - -test("nothing around the logo fires Home by accident", () => { - const tree = render(); - - // The row also holds the collapse toggle. If the ROW carried the gesture, - // pinning the rail would navigate. - assert.equal(find(tree, `${FIG}__logo-row`).service, undefined); - assert.equal(find(tree, `${FIG}__logo`).service, undefined); - - const pin = find(tree, `${FIG}__logo-pin-btn`); - assert.equal(pin.service, "toggle-sidebar-pin"); - - // The organisation name under the wordmark stays inert — it is a label, and - // it is the one node in the block that is NOT the logo. - const header = find(tree, `${FIG}__header`); - assert.equal(header.service, undefined); - assert.equal(header.active, 0); - - // Exactly two nodes in the whole rail fire it. - const homes = [...walk(tree)].filter((n) => n.service === "rail-home"); - assert.equal(homes.length, 2); -}); - -test("the five tabs and the two footer rows are untouched", () => { - const tree = render(); - assert.deepEqual( - servicesIn(find(tree, `${FIG}__nav-main`)), - ["rail-files", "rail-chat", "rail-task", "rail-meet", "rail-access"], - ); - assert.deepEqual( - servicesIn(find(tree, `${FIG}__footer`)), - ["invite-member", "upgrade-plan"], - ); - // The logo is deliberately NOT in the rail's radio group: it is not a sixth - // tab, and lighting it would claim a screen it does not own. - for (const n of findAll(tree, `${FIG}__logo-icon`).concat(findAll(tree, `${FIG}__logo-mark`))) { - assert.equal(n.radio, undefined); - } -}); - -// ── the desk ──────────────────────────────────────────────────────────────── - -// One class method, lifted out of index.js. Every one of these closes at the -// first line that is exactly " }" — nothing inside them is indented that -// shallowly — so the slice is unambiguous. -function grab(name) { - const start = src.indexOf(` ${name}(`); - assert.ok(start > 0, `${name} not found in ${DESK}`); - const end = src.indexOf("\n }\n", start) + 4; - assert.ok(end > start, `${name} has no end`); - return src.slice(start, end); -} - -function build(names, scope = {}) { - const keys = Object.keys(scope); - const body = `return { ${names.map(grab).join(",\n")} };`; - return new Function(...keys, body)(...keys.map((k) => scope[k])); -} - -test("the logo is wired to _railHome, once", () => { - const cases = src.match(/case "rail-home":/g) || []; - assert.equal(cases.length, 1); - assert.match( - src.slice(src.indexOf('case "rail-home":')), - /^case "rail-home":\n\s*return this\._railHome\(\);/, - ); -}); - -// The three destinations. What decides between them is `can_browse`, which -// only the server knows (dom_admin_security or above — server-team -// service/private/organization.js), so the answer cannot be baked into the -// skeleton and has to be resolved on the click. -function railHome({ orgFeature, overview }) { - const calls = []; - const fakeRequire = (m) => { - assert.equal(m, "libs/org-overview"); - return { orgFeature: () => orgFeature, orgOverview: () => Promise.resolve(overview) }; - }; - const api = build(["_railHome", "_railHomeWorkspace"], { require: fakeRequire }); - const self = { - ...api, - isDestroyed: () => false, - _openOrgView: () => calls.push("org-view"), - _railTab: (t) => calls.push(`rail-tab:${t}`), - _resetRailToFiles: () => calls.push("light-files"), - }; - return Promise.resolve(self._railHome()).then(() => calls); -} - -test("an org admin lands on the organisation screen", async () => { - assert.deepEqual( - await railHome({ orgFeature: true, overview: { can_browse: 1 } }), - ["org-view"], - ); -}); - -test("a plain member goes back to the workspace, not to an empty org screen", async () => { - // The topbar chip withholds "Open" from exactly these accounts because the - // server sends them no departments and no workspaces. - assert.deepEqual( - await railHome({ orgFeature: true, overview: { can_browse: 0 } }), - ["rail-tab:files", "light-files"], - ); -}); - -test("an account with no organisation still has somewhere to go", async () => { - // domain 1 — 79% of accounts. A dead click here would be worse than the bug. - assert.deepEqual( - await railHome({ orgFeature: false, overview: null }), - ["rail-tab:files", "light-files"], - ); - // And a server with no org endpoints at all resolves to the EMPTY shape. - assert.deepEqual( - await railHome({ orgFeature: true, overview: { can_browse: 0, organisation: null } }), - ["rail-tab:files", "light-files"], - ); -}); - -test("the fallback lights Files AFTER leaving the screen", async () => { - // Order matters: _railTab closes the section screen, and _resetRailToFiles - // then re-lights the row the close left dark. - const calls = await railHome({ orgFeature: true, overview: { can_browse: 0 } }); - assert.ok(calls.indexOf("rail-tab:files") < calls.indexOf("light-files")); -}); - -test("a desk destroyed mid-fetch navigates nowhere", async () => { - const fakeRequire = () => ({ - orgFeature: () => true, - orgOverview: () => Promise.resolve({ can_browse: 1 }), - }); - const api = build(["_railHome", "_railHomeWorkspace"], { require: fakeRequire }); - const calls = []; - const self = { - ...api, - isDestroyed: () => true, - _openOrgView: () => calls.push("org-view"), - _railTab: () => calls.push("rail-tab"), - _resetRailToFiles: () => calls.push("light"), - }; - await self._railHome(); - assert.deepEqual(calls, []); -}); - -// ── the rail must not claim a screen it cannot see ────────────────────────── - -function openOrgView(orgFeature) { - const calls = []; - const scope = { - require: () => ({ orgFeature: () => orgFeature }), - RADIO_BROADCAST: { trigger: (ch) => calls.push(`broadcast:${ch}`) }, - Organization: { name: () => "Acme" }, - LOCALE: { ORGANIZATION: "Organization" }, - }; - const api = build(["_openOrgView"], scope); - const self = { - ...api, - _railUnlight: () => calls.push("unlight"), - togglePanel: (kind) => calls.push(`panel:${kind}`), - }; - self._openOrgView(); - return calls; -} - -test("opening the org screen puts the rail out", () => { - // settings-main-slot is inset:0 over the workspace pane, so a lit Files row - // would be naming a surface nobody can see — the same disagreement - // toggle-apps / toggle-inbox / toggle-calendar each fix on their way in. - assert.deepEqual(openOrgView(true), [ - "unlight", - "broadcast:breadcrumb:context", - "panel:desk_org_view", - ]); -}); - -test("an open that refuses leaves the rail alone", () => { - // No organisation, no server: nothing changed on screen, so nothing may go - // dark. The gate has to come first. - assert.deepEqual(openOrgView(false), []); -}); diff --git a/tests/secure-share-subject.test.js b/tests/secure-share-subject.test.js deleted file mode 100644 index ab972a19c..000000000 --- a/tests/secure-share-subject.test.js +++ /dev/null @@ -1,99 +0,0 @@ -// The secure-share panel names what is being shared at the top of "Recipients -// mode" (builtins/window/secure-share/skeleton/subject.js): a file gets its -// type glyph in a box, a folder the plain folder shape, a workspace the folder -// shape with its area emblem. -// -// The skeleton is required as shipped, with the real folder template and the -// real glyph map behind it; only the webpack aliases are resolved by hand. -const test = require("node:test"); -const assert = require("node:assert"); -const Module = require("node:module"); -const { resolve } = require("node:path"); - -const ROOT = resolve(__dirname, "../src/drumee"); -const SUBJECT = resolve(ROOT, "builtins/window/secure-share/skeleton/subject.js"); -const ALIASES = { - "media/grid/template/folder": resolve(ROOT, "builtins/media/grid/template/folder/index.js"), - "libs/file-meta": resolve(ROOT, "libs/file-meta.js"), -}; - -const origResolve = Module._resolveFilename; -Module._resolveFilename = function (req, parent, ...rest) { - if (ALIASES[req]) return ALIASES[req]; - return origResolve.call(this, req, parent, ...rest); -}; -global._ = require("underscore"); -global._a = new Proxy({}, { get: (_t, k) => k }); -global.Visitor = { inDmz: 0 }; -const node = (kind) => (props = {}) => ({ __kind: kind, ...props }); -global.Skeletons = { - Box: { X: node("box.x"), Y: node("box.y") }, - Note: node("note"), - Element: node("element"), - Image: { Svg: node("image.svg") }, -}; -const subject = require(SUBJECT); -Module._resolveFilename = origResolve; - -function ui(attrs) { - return { fig: { family: "window-secure-share" }, mget: (k) => attrs[k] }; -} - -test("file: type glyph in a box, then the name", () => { - const row = subject(ui({ - subject: "file", - subject_data: { name: "spec_v2.docx", filetype: "document", ext: "docx", area: "private" }, - })); - assert.strictEqual(row.className, "window-secure-share__subject"); - assert.deepStrictEqual(row.dataset, { subject: "file" }); - const [icon, name] = row.kids; - assert.strictEqual(icon.className, "window-secure-share__subject-ico"); - assert.strictEqual(icon.kids[0].ico, "app-doc-file"); - assert.strictEqual(name.content, "spec_v2.docx"); -}); - -test("image: picture mark, by filetype or by extension", () => { - const byType = subject(ui({ subject: "file", subject_data: { name: "a", filetype: "image" } })); - const byExt = subject(ui({ subject: "file", subject_data: { name: "b.JPG", filetype: "", ext: "JPG" } })); - assert.strictEqual(byType.kids[0].className, "window-secure-share__subject-ico"); - assert.strictEqual(byType.kids[0].kids[0].ico, "bg-image"); - assert.strictEqual(byExt.kids[0].kids[0].ico, "bg-image"); -}); - -test("folder: area-tinted folder shape, no workspace emblem", () => { - const row = subject(ui({ - subject: "folder", - subject_data: { name: "Contracts", filetype: "folder", area: "private" }, - })); - const [art, name] = row.kids; - assert.strictEqual(art.className, "window-secure-share__subject-art"); - assert.match(art.content, /class="folder-shape private"/); - assert.doesNotMatch(art.content, /badge/); - assert.doesNotMatch(art.content, /folder-trigger/, "no kebab"); - assert.strictEqual(name.content, "Contracts"); -}); - -test("workspace: folder shape with its area emblem", () => { - const row = subject(ui({ - subject: "workspace", - subject_data: { name: "Acme external", filetype: "hub", area: "share" }, - })); - const [art, name] = row.kids; - assert.deepStrictEqual(row.dataset, { subject: "workspace" }); - assert.match(art.content, /class="folder-shape share"/); - assert.match(art.content, /badge/); - assert.strictEqual(name.content, "Acme external"); -}); - -test("no subject passed: derived from filetype", () => { - const hub = subject(ui({ filetype: "hub", filename: "WS" })); - const folder = subject(ui({ filetype: "folder", filename: "F" })); - const file = subject(ui({ filetype: "image", filename: "a.png" })); - assert.strictEqual(hub.dataset.subject, "workspace"); - assert.strictEqual(folder.dataset.subject, "folder"); - assert.strictEqual(file.dataset.subject, "file"); -}); - -test("no name: no row", () => { - assert.strictEqual(subject(ui({ subject: "file", subject_data: {} })), null); -}); diff --git a/tests/task-desc-drop.test.js b/tests/task-desc-drop.test.js deleted file mode 100644 index b39348242..000000000 --- a/tests/task-desc-drop.test.js +++ /dev/null @@ -1,835 +0,0 @@ -// Dropping a file on a task DESCRIPTION. -// -// Until now the description refused every drop: resolveZone had no entry for -// it, so the pointer resolved to nothing and window/tasks/index.js stamped -// `dropEffect = "none"` — a deliberate refusal, documented in the dragover -// handler. This adds the zone, and the whole of the new decision lives in -// drop-zones.js, which is pure: an element, a prefix and a two-method context. -// So it is tested directly, against a DOM small enough to read. -// -// Two things make this worth a test rather than a glance: -// -// 1. THE FALL-THROUGH. The three comment editors are mention editors too. A -// zone that matched them would steal a drop from the composer and the -// reply box, which own it today. What keeps them out is that they pass -// their own `editorClass` and so never carry `__desc-editor` — and that -// is a fact about skeleton/index.js, not about drop-zones.js, which is -// why the second half of this file renders the REAL skeleton to check it. -// The unknown-scope case is tested anyway: it must fall through to the -// zone that encloses it, NOT refuse like a foreign comment row does. -// -// 2. THE SCOPE. `detail` and `create` are one selector apart from each -// other — both are `__desc-editor` — so the scope has to ride an -// attribute. sys_pn never reaches the DOM (it is read with mget), hence -// data-desc-scope. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); -const { resolveZone } = require("../src/drumee/builtins/window/tasks/drop-zones.js"); -const { render, walk } = require("./helpers/render-skeleton.js"); - -const PFX = "tasks-panel"; - -// ── A DOM with exactly as much as resolveZone touches ────────────────── -// -// `closest` over a parent chain, matching the two selector shapes the ZONES -// table uses: ".cls" and ".cls[attr]". Anything else is a typo in the table -// and should fail loudly rather than silently match nothing. -const SEL = /^\.([A-Za-z0-9_-]+)(?:\[([A-Za-z0-9_-]+)\])?$/; - -function el(className, attrs = {}, kids = []) { - const node = { - className, - attrs, - parentNode: null, - getAttribute: (k) => (k in attrs ? attrs[k] : null), - closest(sel) { - const m = SEL.exec(sel); - assert.ok(m, `unsupported selector in ZONES: ${sel}`); - const [, cls, attr] = m; - let n = this; - while (n) { - const classes = String(n.className || "").split(/\s+/); - if (classes.includes(cls) && (!attr || n.getAttribute(attr) != null)) { - return n; - } - n = n.parentNode; - } - return null; - }, - }; - for (const k of kids) k.parentNode = node; - return node; -} - -// Everything is inside the panel, and `me` owns every comment, unless a test -// says otherwise. -const ctx = (over = {}) => ({ - contains: () => true, - isOwnComment: () => true, - ...over, -}); - -const descEditor = (scope) => - el(`${PFX}__desc-editor`, scope == null ? {} : { "data-desc-scope": scope }); - -test("a drop on the detail description resolves the detail desc zone", () => { - const editor = descEditor("detail"); - el(`${PFX}__detail-row`, {}, [editor]); - - const zone = resolveZone(PFX, editor, ctx()); - assert.ok(zone, "the description must now accept a drop"); - assert.equal(zone.scope, "desc"); - assert.equal(zone.descScope, "detail"); - assert.equal(zone.key, "desc:detail"); - // The zone carries its own element so the lit affordance and the resolved - // scope cannot drift apart — the reason resolveZone returns `el` at all. - assert.equal(zone.el, editor); -}); - -test("a drop on the create-modal description resolves the create desc zone", () => { - const editor = descEditor("create"); - el(`${PFX}__create-field-grow`, {}, [editor]); - - const zone = resolveZone(PFX, editor, ctx()); - assert.equal(zone.scope, "desc"); - assert.equal(zone.descScope, "create"); - assert.equal(zone.key, "desc:create"); -}); - -test("a drop on a CHILD of the description still resolves the editor", () => { - // An inline image or a mention chip is a real element inside the editor, and - // a drop lands on whichever one is under the pointer. - const chip = el(`${PFX}__mention-chip`); - const editor = el( - `${PFX}__desc-editor`, - { "data-desc-scope": "detail" }, - [chip], - ); - el(`${PFX}__detail-row`, {}, [editor]); - - const zone = resolveZone(PFX, chip, ctx()); - assert.equal(zone.descScope, "detail"); - assert.equal(zone.el, editor, "the zone element is the editor, not the chip"); -}); - -test("a desc editor with an unknown scope FALLS THROUGH to its enclosing zone", () => { - // The refusal rule is not the comment-row rule. A foreign comment row refuses - // outright, because nothing above it legitimately owns the drop. A mention - // editor is different: it sits inside a composer that does own it, so an - // unrecognised scope must keep looking rather than swallow the drop. - const editor = descEditor("something-new"); - const composer = el(`${PFX}__comment-composer`, {}, [editor]); - assert.ok(composer); - - const zone = resolveZone(PFX, editor, ctx()); - assert.ok(zone, "must not refuse — the composer owns this drop"); - assert.equal(zone.scope, "comment"); -}); - -test("a desc editor with NO scope attribute falls through too", () => { - const editor = descEditor(null); - el(`${PFX}__comment-composer`, {}, [editor]); - - const zone = resolveZone(PFX, editor, ctx()); - assert.equal(zone && zone.scope, "comment"); -}); - -// ── The zones that already existed keep working ──────────────────────── - -test("the existing zones are unchanged", () => { - const cases = [ - [`${PFX}__comment-replybox`, {}, "comment-reply", "comment-reply"], - [`${PFX}__comment-composer`, {}, "comment", "comment"], - [`${PFX}__attachments`, {}, "detail", "detail"], - [`${PFX}__create-files`, {}, "create", "create"], - ]; - for (const [cls, attrs, scope, key] of cases) { - const n = el(cls, attrs); - const zone = resolveZone(PFX, n, ctx()); - assert.equal(zone && zone.scope, scope, `${cls} -> ${scope}`); - assert.equal(zone && zone.key, key); - } - - const row = el(`${PFX}__comment-row`, { "data-comment-id": "c1" }); - const rowZone = resolveZone(PFX, row, ctx()); - assert.equal(rowZone.scope, "comment-row"); - assert.equal(rowZone.key, "comment-row:c1"); - assert.equal(rowZone.commentId, "c1"); -}); - -test("someone else's comment row is still refused outright", () => { - const row = el(`${PFX}__comment-row`, { "data-comment-id": "c1" }); - const zone = resolveZone(PFX, row, ctx({ isOwnComment: () => false })); - assert.equal(zone, null); -}); - -test("a pointer on panel chrome still refuses", () => { - assert.equal(resolveZone(PFX, el(`${PFX}__viewbar`), ctx()), null); -}); - -test("an element outside the panel is refused even if it matches", () => { - const editor = descEditor("detail"); - const zone = resolveZone(PFX, editor, ctx({ contains: () => false })); - assert.equal(zone, null); -}); - -// ── The skeleton half: which editors carry the hook ──────────────────── -// -// resolveZone can only be right about scope if the markup agrees, and the -// markup is what decides that the three comment editors are out of scope: -// they pass their own editorClass, so they never carry __desc-editor at all. -// A fixture cannot see that — this renders the shipped skeleton. - -const DRAFT = { - title: "t", - description: "", - mention_uids: [], - due_date: "", - start_date: "", - duration_on: false, - status: "todo", - priority: "medium", - reporter_uid: "me", - assignees: [], - labels: [], - pending_files: [], -}; - -const editors = (tree) => { - const out = []; - for (const n of walk(tree)) { - const cls = String(n.className || ""); - if (/__desc-editor|__comment-input|__comment-reply-input|__comment-edit-input/.test(cls)) { - out.push({ cls, scope: (n.attrOpt || {})["data-desc-scope"] }); - } - } - return out; -}; - -test("the detail description editor carries its scope", () => { - const tree = render({ - getDetailTask: () => ({ id: "t1", title: "t", status: "todo", created_by: "me" }), - getDetailDraft: () => DRAFT, - }); - const desc = editors(tree).filter((e) => /__desc-editor/.test(e.cls)); - assert.equal(desc.length, 1, "the detail panel draws one description editor"); - assert.equal(desc[0].scope, "detail"); -}); - -test("the create-modal description editor carries its scope", () => { - const tree = render({ - isCreating: () => true, - getCreateDraft: () => ({ ...DRAFT, subtasks: [] }), - }); - const desc = editors(tree).filter((e) => /__desc-editor/.test(e.cls)); - assert.equal(desc.length, 1); - assert.equal(desc[0].scope, "create"); -}); - -test("the comment editors do NOT carry __desc-editor", () => { - // This is what keeps them out of the new zone. If a future edit drops the - // custom editorClass, the composer's drop silently changes meaning — from - // "ride the comment draft, commit on Send" to "inline into the body". - const tree = render({ - getDetailTask: () => ({ id: "t1", title: "t", status: "todo", created_by: "me" }), - getDetailDraft: () => DRAFT, - }); - const found = editors(tree); - const comment = found.filter((e) => !/__desc-editor/.test(e.cls)); - assert.ok(comment.length >= 1, "the detail panel draws a comment composer"); - for (const c of comment) { - assert.ok( - !/__desc-editor/.test(c.cls), - `${c.cls} must not carry __desc-editor — it would join the desc zone`, - ); - } -}); - -// ── The panel half: what the resolved zone is then USED for ──────────── -// -// The panel is a 10 000-line class that needs the whole runtime to -// instantiate, so — as tests/call-tile-drag.test.js and -// tests/workspace-delete-admin-only.test.js do — the methods are cut out of -// the SOURCE FILE and run against a fake `this`. They therefore test the -// shipped text: rename one of these or change the split and this fails. -const PANEL = resolve( - __dirname, - "../src/drumee/builtins/window/tasks/index.js", -); -const panelSrc = readFileSync(PANEL, "utf8"); - -// One method, by name. Methods sit at two-space indent and close with a " }" -// on its own line, which is what bounds the slice. -function method(name) { - const head = new RegExp(`\\n (?:async )?${name}\\(`).exec(panelSrc); - assert.ok(head, `method ${name} not found in ${PANEL}`); - const from = head.index + 1; - const end = panelSrc.indexOf("\n }\n", from); - assert.ok(end > from, `method ${name} is not closed as expected`); - return panelSrc.slice(from, end + "\n }\n".length); -} - -const Panel = new Function( - `return class { ${[ - "_dropOnDescEditor", - "_isDroppableImage", - "_splitFilename", - "_isImageExt", - "_rememberDropScope", - "_pasteZone", - ] - .map(method) - .join("\n")} }`, -)(); - -const file = (name, type) => ({ name, type, __file: 1 }); - -// A panel with the calls _dropOnDescEditor makes recorded rather than run. -// -// The two image seams are recorded separately because the split between them -// is load-bearing: `placed` is what the user sees IMMEDIATELY (synchronous), -// `settled` is the upload behind it. -function panel(over = {}) { - const p = new Panel(); - p.calls = { attached: [], placed: [], settled: [] }; - p._attachFilesToZone = async (zone, files) => { - p.calls.attached.push({ zone, files }); - }; - p._beginInlineImage = (f, scope, el, range) => { - p.calls.placed.push({ file: f, scope, el, range }); - return { __placeholderFor: f }; - }; - p._settleInlineImage = async (ph, f, scope, el) => { - p.calls.settled.push({ ph, file: f, scope, el }); - }; - return Object.assign(p, over); -} - -const EDITOR = { isConnected: true }; -const descZone = (scope = "detail", range = { r: 1 }) => ({ - scope: "desc", - key: `desc:${scope}`, - descScope: scope, - el: EDITOR, - range, -}); - -test("an image dropped on the description goes INTO it, at the drop point", async () => { - const p = panel(); - const png = file("shot.png", "image/png"); - await p._dropOnDescEditor(descZone(), [png]); - - assert.equal(p.calls.attached.length, 0, "an image must not be attached"); - assert.equal(p.calls.placed.length, 1); - const ins = p.calls.placed[0]; - assert.equal(ins.file, png); - assert.equal(ins.scope, "detail", "inlines against the editor's own scope"); - assert.equal(ins.el, EDITOR); - assert.deepEqual(ins.range, { r: 1 }, "the drop point is carried through"); - assert.equal(p.calls.settled.length, 1, "and its upload is then run"); -}); - -test("a non-image dropped on the description attaches to the task instead", async () => { - const p = panel(); - const pdf = file("report.pdf", "application/pdf"); - await p._dropOnDescEditor(descZone(), [pdf]); - - assert.equal(p.calls.placed.length, 0, "nothing goes into the body"); - assert.equal(p.calls.attached.length, 1); - // The zone it attaches with is the FORM's, not the desc zone — otherwise - // _draftForKey would be handed "desc:detail" and find no draft. - assert.deepEqual(p.calls.attached[0].zone, { scope: "detail", key: "detail" }); - assert.deepEqual(p.calls.attached[0].files, [pdf]); -}); - -test("a video attaches, exactly as a pasted one does", async () => { - const p = panel(); - await p._dropOnDescEditor(descZone(), [file("clip.mp4", "video/mp4")]); - assert.equal(p.calls.placed.length, 0); - assert.equal(p.calls.attached.length, 1); -}); - -test("a mixed drop splits: images in, the rest beside", async () => { - const p = panel(); - const png = file("a.png", "image/png"); - const pdf = file("b.pdf", "application/pdf"); - const jpg = file("c.jpg", "image/jpeg"); - await p._dropOnDescEditor(descZone("create"), [png, pdf, jpg]); - - assert.deepEqual(p.calls.attached[0].files, [pdf], "only the non-images"); - assert.deepEqual( - p.calls.placed.map((i) => i.file), - [png, jpg], - "images land in the order they were dropped", - ); - for (const i of p.calls.placed) assert.equal(i.scope, "create"); -}); - -test("an editor torn out mid-upload stops the rest of the batch", async () => { - const p = panel(); - const el = { isConnected: true }; - p._settleInlineImage = async () => { - el.isConnected = false; // the task was switched while this one uploaded - p.calls.settled.push({}); - }; - await p._dropOnDescEditor( - { scope: "desc", descScope: "detail", el, range: null }, - [file("a.png", "image/png"), file("b.png", "image/png")], - ); - assert.equal(p.calls.settled.length, 1, "the second upload is abandoned"); -}); - -test("a zone with no element or no scope does nothing at all", async () => { - const p = panel(); - await p._dropOnDescEditor({ scope: "desc", descScope: "detail" }, [file("a.png", "image/png")]); - await p._dropOnDescEditor({ scope: "desc", el: EDITOR }, [file("a.png", "image/png")]); - assert.equal(p.calls.placed.length, 0); - assert.equal(p.calls.attached.length, 0); -}); - -test("an image is recognised by type first, by extension only when there is none", () => { - const p = panel(); - assert.equal(p._isDroppableImage(file("a.png", "image/png")), true); - assert.equal(p._isDroppableImage(file("a.webp", "image/webp")), true); - // No type at all — a drag out of an archive or off a share. - assert.equal(p._isDroppableImage(file("a.png", "")), true); - assert.equal(p._isDroppableImage(file("a.PNG", undefined)), true); - assert.equal(p._isDroppableImage(file("a.pdf", "")), false); - // A DECLARED type is taken at its word, so a mislabelled file attaches - // rather than rendering as a broken inline image. - assert.equal(p._isDroppableImage(file("a.png", "application/pdf")), false); - assert.equal(p._isDroppableImage(null), false); -}); - -test("a desc zone is never REMEMBERED for the positionless route", () => { - // Same rule as detail/create: a task surface is recoverable from the - // pointer, and remembering it would let a stale hover write with no - // overlay ever shown. - const p = panel(); - p._rememberDropScope(descZone()); - assert.equal(p._lastDropScope, null); - - p._rememberDropScope({ scope: "comment-row", key: "comment-row:c1", commentId: "c1" }); - assert.deepEqual(p._lastDropScope, { - scope: "comment-row", - key: "comment-row:c1", - commentId: "c1", - }); - - p._rememberDropScope(descZone("create")); - assert.equal(p._lastDropScope, null, "and it clears a remembered one"); -}); - -test("a PASTE over the description is left to the composer, as before", () => { - // Pasting INTO the description never reaches here — the caret is in a - // contenteditable, so _onPasteAttach returns early and _onEditorPaste - // inlines at the caret. This is the other case: the caret is elsewhere and - // only the pointer is over the editor. - const p = panel({ - _lastPointer: { x: 10, y: 10 }, - _dropPointEl: () => ({}), - _activeUploadScope: () => descZone(), - _detailId: "t1", - _mayWriteTasks: () => true, - }); - assert.deepEqual(p._pasteZone(), { scope: "comment", key: "comment" }); -}); - -test("a paste over any OTHER zone still claims it", () => { - const zone = { scope: "comment-row", key: "comment-row:c1", commentId: "c1" }; - const p = panel({ - _lastPointer: { x: 10, y: 10 }, - _dropPointEl: () => ({}), - _activeUploadScope: () => zone, - _detailId: "t1", - _mayWriteTasks: () => true, - }); - assert.equal(p._pasteZone(), zone); -}); - -// ── Loading state for an inline image ───────────────────────────────── -// -// An image dropped or pasted into a description uploads before it can be -// shown, and until now NOTHING appeared during those seconds — the drop read -// as one that had been ignored. _beginInlineImage puts a placeholder in at -// once and _settleInlineImage swaps it for the real image, or turns it red -// with a retry. -// -// The thing that most needs proving is not the spinner. It is that the -// placeholder CANNOT REACH THE SAVED BODY: _onDescInput serializes the editor -// on every keystroke, and a placeholder is not something the marker grammar -// can express. So the real _serializeEditor is run over an editor holding one. - -// A DOM with what these methods touch, and nothing else. -function fakeDom() { - const revoked = []; - let seq = 0; - const mk = (tag) => { - const n = { - tagName: String(tag).toUpperCase(), - nodeType: 1, - childNodes: [], - parentNode: null, - style: {}, - dataset: {}, - attrs: {}, - className: "", - listeners: [], - get classList() { - const own = () => String(n.className || "").split(/\s+/).filter(Boolean); - return { - contains: (c) => own().includes(c), - }; - }, - get isConnected() { - let p = n; - while (p) { - if (p.__root) return true; - p = p.parentNode; - } - return false; - }, - get textContent() { - return n.childNodes - .map((c) => (c.nodeType === 3 ? c.textContent : c.textContent)) - .join(""); - }, - setAttribute: (k, v) => { - n.attrs[k] = String(v); - }, - getAttribute: (k) => (k in n.attrs ? n.attrs[k] : null), - appendChild: (c) => { - c.parentNode = n; - n.childNodes.push(c); - return c; - }, - contains: (o) => { - let p = o; - while (p) { - if (p === n) return true; - p = p.parentNode; - } - return false; - }, - remove: () => { - const p = n.parentNode; - if (!p) return; - p.childNodes.splice(p.childNodes.indexOf(n), 1); - n.parentNode = null; - }, - replaceWith: (x) => { - const p = n.parentNode; - if (!p) return; - p.childNodes.splice(p.childNodes.indexOf(n), 1, x); - x.parentNode = p; - n.parentNode = null; - }, - querySelector: (sel) => { - const want = sel.toUpperCase(); - const hunt = (m) => { - for (const c of m.childNodes) { - if (c.tagName === want) return c; - const deep = hunt(c); - if (deep) return deep; - } - return null; - }; - return hunt(n); - }, - addEventListener: (ev, fn) => n.listeners.push({ ev, fn }), - // Fire a click as the browser would, with `target` set to a descendant. - __click(target) { - const e = { - target: { - ...target, - classList: target.classList, - closest: () => target, - }, - preventDefault() {}, - stopPropagation() {}, - }; - for (const l of n.listeners) if (l.ev === "click") l.fn(e); - }, - }; - return n; - }; - const text = (s) => ({ nodeType: 3, textContent: s, childNodes: [] }); - return { - mk, - text, - revoked, - document: { - createElement: mk, - createRange: () => null, - }, - URL: { - createObjectURL: () => `blob:fake/${++seq}`, - revokeObjectURL: (u) => revoked.push(u), - }, - window: { - getSelection: () => ({ removeAllRanges() {}, addRange() {} }), - }, - }; -} - -const IMG_METHODS = [ - "_insertInlineNode", - "_beginInlineImage", - "_releaseInlinePreview", - "_settleInlineImage", - "_wireInlineImageRecovery", - "_dropOnDescEditor", - "_isDroppableImage", - "_splitFilename", - "_isImageExt", - "_serializeEditor", -]; - -// A panel whose upload is controllable, on a fake DOM. -function imgPanel({ upload } = {}) { - const dom = fakeDom(); - const markers = require("../src/drumee/builtins/window/tasks/mention-markers.js"); - const Cls = new Function( - "document", - "URL", - "window", - "Butler", - "LOCALE", - "imgMarker", - "linkMarker", - "safeUrl", - `return class { ${IMG_METHODS.map(method).join("\n")} }`, - )( - dom.document, - dom.URL, - dom.window, - { said: [], say(m) { this.said.push(m); } }, - { ERROR_NETWORK: "ERROR_NETWORK" }, - markers.imgMarker, - markers.linkMarker, - markers.safeUrl, - ); - const p = new Cls(); - p.fig = { family: "tasks-panel" }; - p.dom = dom; - p.editor = dom.mk("div"); - p.editor.__root = 1; // everything under it counts as connected - p.synced = 0; - p._onDescInput = () => { - p.synced += 1; - }; - p.attached = []; - p._attachFilesToZone = async (zone, files) => { - p.attached.push({ zone, files }); - }; - p._makeInlineImage = (nid, hub) => { - const wrap = dom.mk("span"); - wrap.className = "tasks-panel__inline-img"; - wrap.dataset.nid = String(nid); - if (hub) wrap.dataset.hub = String(hub); - wrap.appendChild(dom.mk("img")); - return wrap; - }; - p.uploads = 0; - p._uploadInlineImage = async () => { - p.uploads += 1; - if (upload === "fail") throw new Error("http 500"); - if (typeof upload === "function") return upload(p.uploads); - return { nid: "n1", hub: "h1" }; - }; - return p; -} - -const PH = "tasks-panel__inline-img-pending"; -const kidsOf = (n) => n.childNodes.map((c) => c.className || c.tagName); - -test("a placeholder appears the moment the image is dropped, before any upload", () => { - const p = imgPanel(); - const ph = p._beginInlineImage(file("a.png", "image/png"), "detail", p.editor, null); - - assert.equal(p.uploads, 0, "synchronous — nothing has been sent yet"); - assert.equal(ph.className, PH); - assert.equal(ph.dataset.status, "uploading"); - assert.equal(ph.attrs.contenteditable, "false", "the caret must skip it"); - assert.equal(p.editor.childNodes[0], ph, "and it is in the editor"); - // The local file is shown while it uploads, as a queued attachment is. - assert.match(ph.querySelector("img").src, /^blob:/); - assert.deepEqual(kidsOf(ph), [ - "IMG", - "tasks-panel__inline-img-spinner", - "tasks-panel__inline-img-retry", - "tasks-panel__inline-img-discard", - ]); -}); - -test("the placeholder CANNOT reach the saved description", () => { - // The whole safety argument, executed rather than asserted in a comment. - const p = imgPanel(); - p.editor.appendChild(p.dom.text("before ")); - p._beginInlineImage(file("a.png", "image/png"), "detail", p.editor, null); - p.editor.appendChild(p.dom.text(" after")); - - assert.equal( - p._serializeEditor(p.editor), - "before after", - "a placeholder serializes to nothing at all", - ); -}); - -test("...while a COMMITTED inline image still serializes to its marker", () => { - // Positive control: the class test is a whole-token match, so - // __inline-img-pending is not __inline-img — and this proves the real one - // still is, i.e. that the exclusion was not achieved by breaking both. - const p = imgPanel(); - const real = p._makeInlineImage("n9", "h9"); - p.editor.appendChild(real); - assert.equal(p._serializeEditor(p.editor), "![img](file:n9@h9)"); -}); - -test("a successful upload swaps the placeholder for the real image", async () => { - const p = imgPanel(); - const ph = p._beginInlineImage(file("a.png", "image/png"), "detail", p.editor, null); - await p._settleInlineImage(ph, file("a.png", "image/png"), "detail", p.editor); - - assert.equal(p.editor.childNodes.length, 1); - assert.equal(p.editor.childNodes[0].className, "tasks-panel__inline-img"); - assert.equal(p.editor.childNodes[0].dataset.nid, "n1"); - assert.equal(ph.isConnected, false, "the placeholder is gone"); - assert.equal(p.dom.revoked.length, 1, "and its object URL was released"); - assert.ok(p.synced > 0, "the draft is resynced from the editor"); -}); - -test("a failed upload keeps the placeholder, in its error state", async () => { - const p = imgPanel({ upload: "fail" }); - const ph = p._beginInlineImage(file("a.png", "image/png"), "detail", p.editor, null); - await p._settleInlineImage(ph, file("a.png", "image/png"), "detail", p.editor); - - assert.equal(ph.isConnected, true, "it must not vanish silently"); - assert.equal(ph.dataset.status, "error"); - assert.equal(p.dom.revoked.length, 0, "the preview stays — retry still needs it"); - // And it is still invisible to the serializer, which is what makes leaving - // a failed placeholder on screen safe at all. - assert.equal(p._serializeEditor(p.editor), ""); -}); - -test("retry re-runs the upload and the image lands", async () => { - let attempt = 0; - const p = imgPanel({ - upload: () => { - attempt += 1; - if (attempt === 1) throw new Error("http 500"); - return { nid: "n2", hub: "h2" }; - }, - }); - const f = file("a.png", "image/png"); - const ph = p._beginInlineImage(f, "detail", p.editor, null); - await p._settleInlineImage(ph, f, "detail", p.editor); - assert.equal(ph.dataset.status, "error"); - - ph.__click({ className: "tasks-panel__inline-img-retry", classList: { contains: (c) => c === "tasks-panel__inline-img-retry" } }); - await new Promise((r) => setImmediate(r)); - - assert.equal(p.uploads, 2); - assert.equal(p.editor.childNodes[0].dataset.nid, "n2"); -}); - -test("retry wires exactly one listener however often it fails", async () => { - const p = imgPanel({ upload: "fail" }); - const f = file("a.png", "image/png"); - const ph = p._beginInlineImage(f, "detail", p.editor, null); - await p._settleInlineImage(ph, f, "detail", p.editor); - await p._settleInlineImage(ph, f, "detail", p.editor); - await p._settleInlineImage(ph, f, "detail", p.editor); - assert.equal( - ph.listeners.filter((l) => l.ev === "click").length, - 1, - "a stacked listener would fire N uploads on one click", - ); -}); - -test("discard removes a failed placeholder and releases its preview", async () => { - const p = imgPanel({ upload: "fail" }); - const f = file("a.png", "image/png"); - const ph = p._beginInlineImage(f, "detail", p.editor, null); - await p._settleInlineImage(ph, f, "detail", p.editor); - - ph.__click({ className: "tasks-panel__inline-img-discard", classList: { contains: (c) => c === "tasks-panel__inline-img-discard" } }); - - assert.equal(ph.isConnected, false); - assert.equal(p.editor.childNodes.length, 0); - assert.equal(p.dom.revoked.length, 1); -}); - -test("a placeholder wiped by a re-render still lets its image land", async () => { - // _renderEditorContent rebuilds the body from the draft's markers, and a - // placeholder is not a marker — so a render mid-upload takes it. The image - // must still arrive: that is the behaviour this path had before there were - // placeholders at all. - const p = imgPanel(); - const f = file("a.png", "image/png"); - const ph = p._beginInlineImage(f, "detail", p.editor, null); - ph.remove(); // the render - await p._settleInlineImage(ph, f, "detail", p.editor); - - assert.equal(p.editor.childNodes.length, 1); - assert.equal(p.editor.childNodes[0].dataset.nid, "n1"); -}); - -test("a drop of several images shows ALL their spinners at once", async () => { - // The point of the loading state: settling inside the placing loop would - // mean the second spinner only appeared once the first upload had finished. - const p = imgPanel(); - const gate = []; - p._uploadInlineImage = () => - new Promise((resolve) => gate.push(() => resolve({ nid: "n1", hub: "h1" }))); - - const zone = { scope: "desc", descScope: "detail", el: p.editor, range: null }; - const run = p._dropOnDescEditor(zone, [ - file("a.png", "image/png"), - file("b.png", "image/png"), - file("c.png", "image/png"), - ]); - await new Promise((r) => setImmediate(r)); - - assert.equal( - p.editor.childNodes.filter((n) => n.className === PH).length, - 3, - "three placeholders, before a single upload has resolved", - ); - assert.equal(gate.length, 1, "and the uploads themselves are still one at a time"); - - // Pump: each upload only queues its gate entry once the previous one has - // resolved, so a single drain would leave the drop hanging. - let done = false; - run.then(() => { done = true; }); - for (let i = 0; i < 20 && !done; i++) { - while (gate.length) gate.shift()(); - await new Promise((r) => setImmediate(r)); - } - await run; -}); - -test("a mixed drop still attaches the non-images and inlines the rest", async () => { - const p = imgPanel(); - const zone = { scope: "desc", descScope: "detail", el: p.editor, range: null }; - await p._dropOnDescEditor(zone, [ - file("a.png", "image/png"), - file("b.pdf", "application/pdf"), - ]); - - assert.deepEqual(p.attached[0].zone, { scope: "detail", key: "detail" }); - assert.deepEqual(p.attached[0].files.map((f) => f.name), ["b.pdf"]); - assert.equal(p.editor.childNodes[0].dataset.nid, "n1"); -}); - -test("a paste still goes through the same begin+settle path", async () => { - // _insertPastedImage keeps its signature, which is what gives paste the - // loading state for free — the two must not diverge. - const src = readFileSync(PANEL, "utf8"); - const body = /\n async _insertPastedImage\([^)]*\)\s*\{([\s\S]*?)\n \}\n/.exec(src); - assert.ok(body, "_insertPastedImage not found"); - assert.match(body[1], /_beginInlineImage/); - assert.match(body[1], /_settleInlineImage/); -}); diff --git a/tests/tour-section-screen.test.js b/tests/tour-section-screen.test.js deleted file mode 100644 index c40749d8d..000000000 --- a/tests/tour-section-screen.test.js +++ /dev/null @@ -1,269 +0,0 @@ -// A full-canvas screen (Calendar, Inbox, Admin Console…) opened during an -// in-window tour shows the screen, never the workspace pane first. -// -// togglePanel used to end the tour before the screen's lazy chunk had painted, -// so the tour's fade uncovered window-folder__split-body. It now ends the tour -// only after the screen is up. Methods are cut out of the SOURCE FILE, as -// tests/rail-logo-home.test.js does. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -function grab(name) { - const m = new RegExp(`\\n (async )?${name}\\(`).exec(src); - assert.ok(m, `${name} not found`); - const start = m.index + 1; - return src.slice(start, src.indexOf("\n }\n", start) + 4); -} - -const tick = () => new Promise((r) => setImmediate(r)); - -function desk({ chunk } = {}) { - const scope = { - _: { isFunction: (f) => typeof f === "function" }, - Kind: { get: () => ({}), waitFor: () => chunk || Promise.resolve({}) }, - requestAnimationFrame: (f) => setImmediate(f), - setTimeout: (f, ms) => setTimeout(f, ms).unref(), - clearTimeout, - }; - const keys = Object.keys(scope); - const d = new Function(...keys, `return { ${["_hasWindowTour", "_endWindowTourAfter"].map(grab).join(",\n")} };`)( - ...keys.map((k) => scope[k]), - ); - d.ended = 0; - d._endWindowTour = () => { d.ended++; }; - return d; -} - -test("the tour stays up until the screen's chunk has landed and painted", async () => { - let land; - const d = desk({ chunk: new Promise((r) => (land = r)) }); - let feed; - d._endWindowTourAfter(new Promise((r) => (feed = r)), "calendar_main"); - await tick(); - assert.equal(d.ended, 0, "ended before the screen was even fed"); - feed(); - for (let i = 0; i < 4; i++) await tick(); - assert.equal(d.ended, 0, "ended before the chunk landed"); - land({}); - for (let i = 0; i < 6; i++) await tick(); - assert.equal(d.ended, 1); -}); - -test("a failed open still ends the tour, once", async () => { - const d = desk(); - d._endWindowTourAfter(Promise.reject(new Error("x")), "calendar_main"); - for (let i = 0; i < 6; i++) await tick(); - assert.equal(d.ended, 1); -}); - -test("_hasWindowTour", () => { - const d = desk(); - assert.equal(d._hasWindowTour(), false); - d._windowTour = { isDestroyed: () => false }; - assert.equal(d._hasWindowTour(), true); - d._windowTour = { isDestroyed: () => true }; - assert.equal(d._hasWindowTour(), false); -}); - -test("togglePanel defers the end for the main slot only, and never ends it up front there", () => { - const body = grab("togglePanel"); - assert.match(body, /const tourWaitsForScreen = pn === "settings-main-slot" && this\._hasWindowTour\(\);/); - assert.match(body, /if \(!tourWaitsForScreen && !tourStaysUp\) this\._endWindowTour\(\);/); - // No other, unconditional end left behind. - assert.equal((body.match(/this\._endWindowTour\(/g) || []).length, 1); - assert.match(body, /if \(tourWaitsForScreen\) this\._endWindowTourAfter\(settled, kind\);\n return settled;/); -}); - -// Files pressed during a tour parks `_railTab("files")` on the tour's release, -// cancelled only when _navSeq has moved. A full-canvas screen must move it, or -// the parked tab lands over that screen when the tour ends. -test("opening a full-canvas screen counts as a navigation, side panels do not", () => { - const body = grab("togglePanel"); - const nav = body.indexOf('if (pn === "settings-main-slot") this._navigated();'); - assert.ok(nav > 0, "togglePanel never bumps _navSeq for the main slot"); - assert.ok(nav < body.indexOf("const settled = this.ensurePart(pn)"), "must bump before the screen opens"); - - // And the parked tab really is guarded by that counter. - const railTour = grab("_railTabWithTour"); - assert.match(railTour, /whenDone\(tour, \(\) => \{[\s\S]*\(this\._navSeq \|\| 0\) === seq\) this\._railTab\(tab\)/); -}); - -// A Wm.confirm during a tour (the "Unlock Admin Console" card) dissolves the -// window manager's isolation, which released the pane at 50001 over the tour's -// overlay at 50000. The skin caps the window layers under the overlay while -// both flags are up. -test("a wrapper-modal during a tour keeps the pane under the tour", () => { - const skin = readFileSync(resolve(__dirname, "../src/drumee/modules/desk/skin/index.scss"), "utf8"); - const at = skin.indexOf('.desk-module[data-window-tour="1"][data-wm-modal="open"] {'); - assert.ok(at > 0, "cap rule missing"); - const block = skin.slice(at, skin.indexOf("\n}\n", at)); - assert.match(block, /\.window-manager__layer:not\(\.upload-progress-layer\):not\(\.meeting-toast-layer\) \{\s*z-index: (\d+) !important;/); - const [base, focused] = [...block.matchAll(/z-index: (\d+) !important;/g)].map((m) => +m[1]); - assert.ok(base < 50000 && focused < 50000 && focused > base, `pane must stay under the tour (50000): ${base}/${focused}`); -}); - -// Admin Console upsell while the migrate tour is owed: tour first, THEN leave -// the section screen underneath it, THEN the card — and resolve without -// waiting for the card to close (the icon spinner waits on this promise). -function upsellDesk({ offerable = true, tourUp = false, raised = true, mounts = true, ws = {} } = {}) { - const log = []; - const modules = { "libs/tutorial-tours": { offerable: () => offerable } }; - const d = new Function("require", `return { ${grab("_showAdminUnlockOverTour")} };`)((m) => modules[m]); - d._hasWindowTour = () => tourUp; - d._railWorkspace = () => ws; - d._raiseRailTour = async (t) => { log.push(`raise:${t}`); return raised; }; - d._awaitWindowTour = async () => { log.push("tour-up"); return mounts; }; - d._leaveSectionScreen = () => log.push("leave-section"); - d._showAdminUnlockModal = () => { log.push("card"); return new Promise(() => {}); }; - return { d, log }; -} - -test("upsell: raises the owed migrate tour, leaves the screen under it, then the card", async () => { - const { d, log } = upsellDesk(); - await d._showAdminUnlockOverTour(); // must not hang on the never-settling card - assert.deepEqual(log, ["raise:migrate", "tour-up", "leave-section", "card"]); -}); - -test("upsell: the card alone when there is no tour to show", async () => { - for (const [name, opts] of [ - ["tour done", { offerable: false }], - ["tour already up", { tourUp: true }], - ["no workspace", { ws: null }], - ]) { - const { d, log } = upsellDesk(opts); - await d._showAdminUnlockOverTour(); - assert.deepEqual(log, ["card"], name); - } - // Refused or never mounted: the section screen is NOT left (that would - // uncover the pane with no tour over it), the card still opens. - let { d, log } = upsellDesk({ raised: false }); - await d._showAdminUnlockOverTour(); - assert.deepEqual(log, ["raise:migrate", "card"]); - ({ d, log } = upsellDesk({ mounts: false })); - await d._showAdminUnlockOverTour(); - assert.deepEqual(log, ["raise:migrate", "tour-up", "card"]); -}); - -test("toggle-apps routes the upsell through _showAdminUnlockOverTour", () => { - const start = src.indexOf(' case "toggle-apps": {'); - const body = src.slice(start, src.indexOf(" }", start)); - assert.match(body, /if \(needsAdminConsoleUpgrade\(\)\) \{\s*return this\._showAdminUnlockOverTour\(\);/); -}); - -// Contacts and Trash keep an in-window tour up (like the bell), and slide in -// over it on the lifted right panel container. -test("contacts and trash keep the tour; the right container is lifted over it", () => { - const body = grab("togglePanel"); - assert.match(body, /\(kind === "address_book" && pn === "chat-panel"\)/); - assert.match(body, /\(kind === "panel_trash" && pn === "trash-panel"\)/); - assert.ok( - body.indexOf("const tourStaysUp") < body.indexOf("this._endWindowTour()"), - "decided after the tour was already ended", - ); - const skin = readFileSync(resolve(__dirname, "../src/drumee/modules/desk/skin/index.scss"), "utf8"); - const tour = skin.slice(skin.indexOf('.desk-module[data-window-tour="1"] {')); - const block = tour.slice(tour.indexOf(".desk-module__panel-container.right {")); - const z = +/z-index:\s*(\d+)/.exec(block)[1]; - assert.ok(z > 50000, "contacts would open under the tour"); -}); - -// Files raises the migrate tour, a lazy chunk; the Calendar pressed before it -// lands used to get the tour dropped on top of it. The mount records _navSeq, -// and a tour that arrives after a navigation is ended at once, unseen. -test("a tour that lands after the user navigated away is dropped", async () => { - const mount = grab("mountWindowTutorial"); - const stamp = mount.indexOf("this._windowTourSeq = this._navSeq || 0;"); - assert.ok(stamp > 0, "mount does not record the navigation"); - assert.ok(stamp < mount.indexOf('this.ensurePart("overlay")'), "recorded after the async feed"); - - const part = src.slice(src.indexOf(' case "window-tutorial": {')); - const body = part.slice(0, part.indexOf("\n case ")); - const check = body.indexOf("if ((this._navSeq || 0) !== (this._windowTourSeq || 0)) {"); - assert.ok(check > 0, "ready handler never compares"); - assert.ok(check > body.indexOf("child.once(_e.destroy"), "must drop AFTER the release handler is bound"); - assert.match(body.slice(check), /this\._endWindowTour\(\{ immediate: true \}\)/); - - // Run the ready handler's decision against a fake desk. - const decide = new Function("child", "_navSeq", "_windowTourSeq", ` - const self = { _navSeq, _windowTourSeq, _windowTour: child, ended: [] }; - self._endWindowTour = (o) => self.ended.push(o); - (function () { ${body.slice(check, body.indexOf("\n return;", check))} }).call(self); - return self; - `); - const same = decide({}, 3, 3); - const moved = decide({}, 4, 3); - await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(same.ended, []); - assert.deepEqual(moved.ended, [{ immediate: true }]); -}); - -// Files pressed while the migrate tour is ALREADY up used to ask for migrate -// again; that request waited for the running tour to release, the Calendar's -// togglePanel released it, and a fresh migrate tour mounted over the Calendar. -function railDesk({ offerable = true, running = null, raise } = {}) { - const log = []; - const whenDone = []; - const modules = { - "libs/tutorial-tours": { - offerable: () => offerable, - whenDone: (t, cb) => whenDone.push(cb), - }, - }; - const d = new Function( - "require", "_", - `return { ${["_railTabWithTour", "_windowTourIs", "_hasWindowTour", "_raiseRailTour"].map(grab).join(",\n")} };`, - )((m) => modules[m], { isFunction: (f) => typeof f === "function" }); - d._navSeq = 0; - d._navigated = () => ++d._navSeq; - d._railWorkspace = () => ({}); - d._leaveSectionScreen = () => {}; - d._endWindowTourUnlessAbout = () => {}; - d._railTab = (tab) => log.push(`tab:${tab}`); - d._windowTour = running ? { isDestroyed: () => false, mget: (k) => (k === "tour" ? running : null) } : null; - if (raise) d._raiseRailTour = raise; - return { d, log, whenDone }; -} - -test("Files during its own tour shows the tab at once and asks for nothing", async () => { - let asked = 0; - const { d, log, whenDone } = railDesk({ running: "migrate", raise: async () => { asked++; return true; } }); - await d._railTabWithTour("files", "migrate"); - assert.equal(asked, 0, "asked for the tour that is already up"); - assert.deepEqual(log, ["tab:files"]); - assert.equal(whenDone.length, 0); -}); - -test("a navigation while the tour was being asked for drops both the tour and the tab", async () => { - let release; - const { d, log, whenDone } = railDesk({ - running: "chat", - raise: (tour, seq) => new Promise((r) => (release = () => r(true))), - }); - const pressing = d._railTabWithTour("files", "migrate"); - await Promise.resolve(); - d._navigated(); // the Calendar - release(); - await pressing; - assert.deepEqual(log, [], "the Files tab landed over the Calendar"); - assert.equal(whenDone.length, 0, "a tab switch was parked anyway"); -}); - -test("_raiseRailTour refuses to claim after a navigation during its wait", async () => { - const { d } = railDesk(); - let fired = 0; - d._whenToursIdle = async () => { d._navigated(); }; - const Tours = { fire: () => { fired++; return true; } }; - d._raiseRailTour = new Function("require", `return { ${grab("_raiseRailTour")} };`)(() => Tours)._raiseRailTour.bind(d); - assert.equal(await d._raiseRailTour("migrate", 0), false); - assert.equal(fired, 0); - // Without a seq (boot tour, upsell) it claims as before. - d._navSeq = 5; - d._whenToursIdle = async () => {}; - assert.equal(await d._raiseRailTour("migrate"), true); - assert.equal(fired, 1); -}); diff --git a/tests/utility-btn-busy.test.js b/tests/utility-btn-busy.test.js deleted file mode 100644 index a534c1430..000000000 --- a/tests/utility-btn-busy.test.js +++ /dev/null @@ -1,156 +0,0 @@ -// Topbar utility icons (bell / calendar / inbox / contacts / trash / admin -// console) show a spinner on the pressed icon and disable the others until the -// screen behind it is up. -// -// The desk class needs the whole runtime to instantiate, so — as -// tests/rail-logo-home.test.js does — the methods are cut out of the SOURCE -// FILE and run against a fake `this` and fake elements. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -function grab(name) { - const start = src.indexOf(` ${name}(`); - assert.ok(start > 0, `${name} not found in ${DESK}`); - const end = src.indexOf("\n }\n", start) + 4; - return src.slice(start, end); -} - -function build(scope) { - const keys = Object.keys(scope); - const body = `return { ${["_isUtilityBtn", "_runUtilityBusy"].map(grab).join(",\n")} };`; - return new Function(...keys, body)(...keys.map((k) => scope[k])); -} - -function fakeDom() { - const cluster = { dataset: {} }; - const btn = (cls = "desk-module-topbar__utility-btn") => ({ - dataset: {}, - classList: { contains: (c) => c === cls }, - closest: (sel) => - sel === ".desk-module-topbar__utility-cluster" ? cluster : null, - }); - return { cluster, btn }; -} - -const tick = () => new Promise((r) => setImmediate(r)); - -function desk({ waitFor } = {}) { - const waited = []; - const scope = { - _: { isFunction: (f) => typeof f === "function" }, - Kind: { - waitFor: (k) => { - waited.push(k); - return waitFor ? waitFor(k) : Promise.resolve({}); - }, - }, - requestAnimationFrame: (f) => setImmediate(f), - UTILITY_KINDS: { "toggle-trash": "panel_trash" }, - UTILITY_BUSY_MAX: 10000, - setTimeout: (f, ms) => setTimeout(f, ms).unref(), - clearTimeout, - }; - return { d: build(scope), waited }; -} - -test("only a real utility button is intercepted", () => { - const { d } = desk(); - const { btn } = fakeDom(); - assert.equal(d._isUtilityBtn({ el: btn() }), true); - assert.equal(d._isUtilityBtn({ el: btn("other") }), false); - // Synthetic dispatches (_deskServiceShim) carry no element. - assert.equal(d._isUtilityBtn({ mget: () => null }), false); -}); - -test("spins the button and locks the cluster until the kind has loaded", async () => { - let loadChunk; - const chunk = new Promise((r) => (loadChunk = r)); - const { d, waited } = desk({ waitFor: () => chunk }); - const { cluster, btn } = fakeDom(); - const el = btn(); - const cmd = { el }; - let ran = 0; - let inner; - d._runUtilityBusy(cmd, "toggle-trash", () => { - ran++; - inner = d._utilityInner; - return Promise.resolve(); - }); - - assert.equal(ran, 1); - assert.equal(inner, cmd, "the inner onUiEvent must see its own guard"); - assert.equal(d._utilityInner, null); - assert.equal(el.dataset.loading, "1"); - assert.equal(cluster.dataset.busy, "1"); - - await tick(); - await tick(); - assert.deepEqual(waited, ["panel_trash"]); - assert.equal(el.dataset.loading, "1", "released before the chunk landed"); - - loadChunk({}); - for (let i = 0; i < 6; i++) await tick(); - assert.equal(el.dataset.loading, undefined); - assert.equal(cluster.dataset.busy, undefined); -}); - -test("a second press while busy does nothing", async () => { - const { d } = desk(); - const { cluster, btn } = fakeDom(); - let ran = 0; - const pending = new Promise(() => {}); - d._runUtilityBusy({ el: btn() }, "toggle-trash", () => (ran++, pending)); - d._runUtilityBusy({ el: btn() }, "toggle-inbox", () => (ran++, pending)); - assert.equal(ran, 1); - assert.equal(cluster.dataset.busy, "1"); -}); - -test("a failing or throwing service still releases", async () => { - const { d } = desk(); - const { cluster, btn } = fakeDom(); - const el = btn(); - d._runUtilityBusy({ el }, "toggle-apps", () => Promise.reject(new Error("x"))); - for (let i = 0; i < 6; i++) await tick(); - assert.equal(el.dataset.loading, undefined); - assert.equal(cluster.dataset.busy, undefined); - - const el2 = btn(); - assert.throws(() => - d._runUtilityBusy({ el: el2 }, "toggle-apps", () => { - throw new Error("boom"); - }), - ); - assert.equal(el2.dataset.loading, undefined); - assert.equal(cluster.dataset.busy, undefined); - assert.equal(d._utilityInner, null); -}); - -test("onUiEvent routes utility presses through _runUtilityBusy before the switch", () => { - const body = src.slice(src.indexOf(" onUiEvent(cmd, args = {}) {")); - const guard = body.indexOf("this._utilityInner !== cmd && this._isUtilityBtn(cmd)"); - assert.ok(guard > 0, "guard missing"); - assert.ok(guard < body.indexOf("switch (service)")); - assert.ok(body.indexOf("pointerDragged") < guard); -}); - -// The bell leaves an in-window tour (e.g. migrate) running and shows the panel -// OVER it: no _endWindowTour in its case, and the skin lifts the right panel -// container above the tour overlay (50000) while the tour flag is up. -test("the bell keeps an in-window tour and its panel is lifted over it", () => { - const start = src.indexOf(' case "toggle-activity":'); - assert.ok(start > 0); - const body = src.slice(start, src.indexOf(' case "toggle-inbox":', start)); - assert.ok(!/this\._endWindowTour\(/.test(body), "the bell must not end the tour"); - - const skin = readFileSync( - resolve(__dirname, "../src/drumee/modules/desk/skin/index.scss"), "utf8"); - const tour = skin.slice(skin.indexOf('.desk-module[data-window-tour="1"] {')); - const block = tour.slice(tour.indexOf(".desk-module__panel-container.right {")); - const z = /z-index:\s*(\d+)/.exec(block); - assert.ok(z && +z[1] > 50000 && +z[1] < 100002, "panel must sit above the tour and below the topbar"); -}); diff --git a/tests/utility-panel-close.test.js b/tests/utility-panel-close.test.js deleted file mode 100644 index 71ff60481..000000000 --- a/tests/utility-panel-close.test.js +++ /dev/null @@ -1,196 +0,0 @@ -// Topbar bell / Contacts / Trash: pressing the icon while its panel is open -// closes the panel, and an icon is lit only while its panel is open. -// -// Methods are cut out of the SOURCE FILE and run against a fake `this`, as -// tests/rail-logo-home.test.js does. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const TOPBAR = resolve(__dirname, "../src/drumee/modules/desk/skeleton/topbar.js"); -const src = readFileSync(DESK, "utf8"); - -function grab(name) { - const m = new RegExp(`\\n (async )?${name}\\(`).exec(src); - assert.ok(m, `${name} not found`); - const start = m.index + 1; - return src.slice(start, src.indexOf("\n }\n", start) + 4); -} - -const UTILITY_PANELS = new Function( - /\nconst UTILITY_PANELS = (\[[\s\S]*?\]);/.exec(src)[1].replace(/^/, "return "), -)(); - -// A view with a model state and an element. -function view(state = 0, dataset = {}) { - const v = { - _state: state, - el: { dataset }, - mget: (k) => (k === "state" ? v._state : undefined), - setState: (s) => { v._state = s; v.el.dataset.state = String(s); }, - }; - return v; -} - -// A slot holding one mounted panel. -function slot(childAnim) { - const child = childAnim === null ? null : { el: { dataset: { anim: childAnim } } }; - return { - child, - isEmpty: () => !child, - children: { last: () => child }, - }; -} - -function desk({ activity = 0, contacts = null, trash = null, screen = null } = {}) { - const parts = { - "activity-panel": view(activity), - "chat-panel": slot(contacts), - "trash-panel": slot(trash), - "utility-activity": view(1), - "utility-contacts": view(1), - "utility-trash": view(1), - }; - let restored = 0; - parts.breadcrumb = { _restoreCurrentPath: () => restored++ }; - const d = new Function( - "_", "_a", "UTILITY_PANELS", - `return { ${["_utilityPanelOpen", "_closeUtilityPanel", "_syncUtilityLights"].map(grab).join(",\n")} };`, - )({ isFunction: (f) => typeof f === "function" }, { state: "state" }, UTILITY_PANELS); - d.getPart = (pn) => parts[pn]; - d.ensurePart = (pn) => Promise.resolve(parts[pn]); - d._pendingKinds = { - "chat-panel": contacts === null ? null : "address_book", - "trash-panel": trash === null ? null : "panel_trash", - }; - d._hidePanel = (p) => { p.children.last().el.dataset.anim = "out"; }; - d._currentScreenService = () => screen; - return { d, parts, restored: () => restored }; -} - -test("reads open from the panel itself", () => { - let { d } = desk({ activity: 1, contacts: "in", trash: "out" }); - assert.equal(d._utilityPanelOpen("toggle-activity"), true); - assert.equal(d._utilityPanelOpen("toggle-contacts"), true); - assert.equal(d._utilityPanelOpen("toggle-trash"), false); - - ({ d } = desk({ activity: 0, contacts: null, trash: "in" })); - assert.equal(d._utilityPanelOpen("toggle-activity"), false); - assert.equal(d._utilityPanelOpen("toggle-contacts"), false); - assert.equal(d._utilityPanelOpen("toggle-trash"), true); - - // The Inbox shares nothing with chat-panel any more, but a different kind in - // the slot must never read as Contacts being open. - ({ d } = desk({ contacts: "in" })); - d._pendingKinds["chat-panel"] = "chat_p2p"; - assert.equal(d._utilityPanelOpen("toggle-contacts"), false); -}); - -for (const [service, opts, check] of [ - ["toggle-activity", { activity: 1 }, (parts) => parts["activity-panel"]._state === 0], - ["toggle-contacts", { contacts: "in" }, (parts) => parts["chat-panel"].child.el.dataset.anim === "out"], - ["toggle-trash", { trash: "in" }, (parts) => parts["trash-panel"].child.el.dataset.anim === "out"], -]) { - test(`${service}: closing an open panel unlights its icon and restores the path`, async () => { - const { d, parts, restored } = desk(opts); - const btn = UTILITY_PANELS.find((u) => u.service === service).button; - assert.equal(d._utilityPanelOpen(service), true); - await d._closeUtilityPanel(service); - assert.ok(check(parts), "panel still open"); - assert.equal(d._utilityPanelOpen(service), false); - assert.equal(parts[btn]._state, 0, "icon still lit"); - assert.equal(restored(), 1); - }); -} - -test("closing over a section screen leaves that screen's title alone", async () => { - const { d, restored } = desk({ trash: "in", screen: "toggle-calendar" }); - await d._closeUtilityPanel("toggle-trash"); - assert.equal(restored(), 0); -}); - -test("lights: an icon whose panel closed elsewhere goes off; open or loading stays", () => { - const { d, parts } = desk({ activity: 1, contacts: "out", trash: null }); - parts["utility-trash"].el.dataset.loading = "1"; - d._syncUtilityLights(); - assert.equal(parts["utility-activity"]._state, 1, "open panel unlit"); - assert.equal(parts["utility-contacts"]._state, 0, "closed panel still lit"); - assert.equal(parts["utility-trash"]._state, 1, "a spinning icon was touched"); -}); - -test("lights: a panel still sliding in keeps its icon lit, but is not closable", () => { - const { d, parts } = desk({ contacts: "" }); - // Mounted, no data-anim yet — address_book before its fetches land. - delete parts["chat-panel"].child.el.dataset.anim; - d._pendingKinds["chat-panel"] = "address_book"; - d._syncUtilityLights(); - assert.equal(parts["utility-contacts"]._state, 1); - assert.equal(d._utilityPanelOpen("toggle-contacts"), false); - assert.equal(d._utilityPanelOpen("toggle-contacts", { pending: true }), true); -}); - -test("wired: each case closes an open panel first; the icons carry part names", () => { - for (const [svc, next] of [ - ["toggle-activity", "toggle-inbox"], - ["toggle-contacts", "toggle-settings"], - ["toggle-trash", "upgrade-plan"], - ]) { - const start = src.indexOf(` case "${svc}":`); - assert.ok(start > 0, svc); - const body = src.slice(start, src.indexOf(` case "${next}":`, start)); - const guard = body.indexOf("if (this._utilityPanelOpen(service)) return this._closeUtilityPanel(service);"); - assert.ok(guard > 0, `${svc} has no close guard`); - assert.ok(guard < body.indexOf("breadcrumb:context") || body.indexOf("breadcrumb:context") < 0, - `${svc} retitles the bar before closing`); - } - const topbar = readFileSync(TOPBAR, "utf8"); - for (const { service, button } of UTILITY_PANELS) { - const at = topbar.indexOf(`service: "${service}",`); - assert.ok(at > 0, service); - assert.match(topbar.slice(at, topbar.indexOf("}),", at)), new RegExp(`pn: "${button}"`)); - } - assert.match(grab("onDomRefresh"), /this\._installUtilityLights\(\)/); -}); - -// A press on a rail __nav-main row closes Notifications / Contacts / Trash and -// clears their topbar icons. -test("rail nav press closes the slide-outs and clears all three icons", async () => { - const { d, parts } = desk({ activity: 1, contacts: "in", trash: "in" }); - const extra = new Function( - "_", "_a", "UTILITY_PANELS", "Promise", - `return { ${grab("_closeUtilityPanelsForRail")} };`, - )({ isFunction: (f) => typeof f === "function" }, { state: "state" }, UTILITY_PANELS, Promise); - Object.assign(d, extra); - let closed = 0; - d.closeOtherSidebarPanels = (except) => { - assert.equal(except, undefined, "must close all three, with no exception"); - closed++; - parts["activity-panel"].setState(0); - parts["chat-panel"].child.el.dataset.anim = "out"; - parts["trash-panel"].child.el.dataset.anim = "out"; - return Promise.resolve(); - }; - // A spinning icon is cleared too. - parts["utility-trash"].el.dataset.loading = "1"; - await d._closeUtilityPanelsForRail(); - assert.equal(closed, 1); - for (const { service, button } of UTILITY_PANELS) { - assert.equal(parts[button]._state, 0, `${button} still lit`); - assert.equal(d._utilityPanelOpen(service), false, `${service} still open`); - } -}); - -test("wired: only a real __nav-main press closes them, not the footer or a shim", () => { - const set = /const RAIL_NAV_SERVICES = new Set\(\[([\s\S]*?)\]\);/.exec(src); - assert.ok(set, "RAIL_NAV_SERVICES missing"); - const services = [...set[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort(); - assert.deepEqual(services, ["rail-access", "rail-chat", "rail-files", "rail-meet", "rail-task"]); - - const body = src.slice(src.indexOf(" onUiEvent(cmd, args = {}) {")); - const railRow = body.indexOf('cmd.mget("railRow")'); - const call = body.indexOf("if (RAIL_NAV_SERVICES.has(service)) this._closeUtilityPanelsForRail();"); - assert.ok(railRow > 0 && call > railRow, "must sit inside the clicked-rail-row branch"); - assert.ok(call < body.indexOf("switch (service)"), "must run before the rail service"); -}); diff --git a/tests/workspace-delete-admin-only.test.js b/tests/workspace-delete-admin-only.test.js deleted file mode 100644 index 02337defd..000000000 --- a/tests/workspace-delete-admin-only.test.js +++ /dev/null @@ -1,178 +0,0 @@ -// Only an Admin (or the Owner) may delete a workspace. -// -// Lexis, 2026-09-16: "member có permission là edit cũng đang delete được -// workspace". The Folder Setting panel's Delete row is the ONLY surface in the -// app that reaches confirmFolderDelete — the home-grid tile and the desk -// topbar ⋯ both route their "Move to trash" through Wm.removeMediaSelection, -// which buckets a hub the caller does not own into confirmLeaveHub. So this -// one row is the whole reported bug, and the row was gated on the WRITE bit: -// exactly what Edit holds. -// -// The panel half renders the REAL skeleton, so a row that loses or changes its -// `need` shows up here. The window half is one method inside a 7000-line class -// that needs the whole runtime to instantiate, so — as tests/rail-logo-home.js -// and tests/call-tile-drag.js do — it is cut out of the SOURCE FILE and run -// against a fake `this`. Both therefore test the shipped text, not a copy. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); -const { renderModule, walk } = require("./helpers/render-skeleton.js"); - -const PANEL = - "src/drumee/builtins/window/folder/skeleton/settings-action-panel.js"; -const WINDOW = resolve(__dirname, "../src/drumee/builtins/window/folder/index.js"); -const src = readFileSync(WINDOW, "utf8"); - -// The stored privilege WORDS, weakest first — lex/constants.js `privilege`, -// which is what hub.set_privilege writes and what user_permission() hands back. -const VIEW = 0b0000011; -const CHAT = 0b0000111; -const EDIT = 0b0001111; -const ADMIN = 0b0011111; -const OWNER = 0b0111111; - -// The single BITS the helpers test against — lex/constants.js `permission`. -const BIT = { download: 0b0000100, write: 0b0001000, admin: 0b0010000 }; - -// A folder window as the panel reads it. The three permission helpers are -// ui-core's (node_modules/@drumee/ui-core/letc/mfs.js) — reproduced here as the -// one-line bitmask tests they are, so a role is expressed as its privilege word -// and nothing else has to be stubbed. -function ui(privilege, over = {}) { - return { - fig: { family: "window-folder" }, - canDownload: () => privilege & BIT.download, - canUpload: () => privilege & BIT.write, - canAdmin: () => privilege & BIT.admin, - // The members matrix: empty is the honest default here. hub.get_members_by_type - // does not exist in a drumate DB (schemas templates/factory/drumate.sql), so - // a PERSONAL workspace always renders this panel with no rows — which is - // precisely why the Delete gate must not be read off the matrix. - _folderMembers: [], - _folderMembersLoaded: true, - _folderInviteRole: null, - ...over, - }; -} - -const servicesOf = (tree) => { - const out = []; - for (const n of walk(tree)) if (n && n.service) out.push(n.service); - return out; -}; - -const actionsFor = (privilege, over) => - servicesOf(renderModule(PANEL, ui(privilege, over))).filter((s) => - /^(folder-(rename|organize|duplicate|delete)|download)$/.test(s), - ); - -// ── the panel ─────────────────────────────────────────────────────────────── - -test("Edit is offered every write action EXCEPT Delete", () => { - const rows = actionsFor(EDIT); - assert.ok(rows.includes("folder-rename"), "Edit lost Rename"); - assert.ok(rows.includes("folder-organize"), "Edit lost Organize"); - assert.ok(rows.includes("folder-duplicate"), "Edit lost Duplicate"); - assert.ok(rows.includes("download"), "Edit lost Download"); - assert.ok( - !rows.includes("folder-delete"), - "THE BUG: an Edit member is still offered Delete", - ); -}); - -test("Admin and Owner keep Delete", () => { - for (const [name, priv] of [["admin", ADMIN], ["owner", OWNER]]) { - assert.ok( - actionsFor(priv).includes("folder-delete"), - `${name} must still be able to delete the workspace`, - ); - } -}); - -test("View and Chat are unchanged — they never had Delete", () => { - // Chat carries the download bit and nothing above it; View carries neither. - assert.deepEqual(actionsFor(CHAT), ["download"]); - assert.deepEqual(actionsFor(VIEW), []); -}); - -test("an unreadable privilege fails OPEN, as every other row does", () => { - // A window whose privilege never arrived (a folder opened from a meeting - // notification, say) must not lock its OWNER out. The rule is about the - // METHOD being missing, not about the bitmask reading 0 — a 0 is an answer. - const bare = { fig: { family: "window-folder" }, _folderMembers: [] }; - const rows = servicesOf(renderModule(PANEL, bare)); - assert.ok(rows.includes("folder-delete")); - - const throws = ui(OWNER, { - canAdmin: () => { - throw new Error("privilege unreadable"); - }, - }); - assert.ok(servicesOf(renderModule(PANEL, throws)).includes("folder-delete")); -}); - -test("the Delete row declares `admin`, and it is the only row that does", () => { - const panel = readFileSync(resolve(__dirname, "..", PANEL), "utf8"); - const needs = [...panel.matchAll(/service: "(folder-[a-z]+)"[\s\S]{0,200}?need: "(\w+)"/g)] - .map((m) => [m[1], m[2]]); - const admin = needs.filter(([, n]) => n === "admin").map(([s]) => s); - assert.deepEqual(admin, ["folder-delete"]); -}); - -// ── the window ────────────────────────────────────────────────────────────── - -// One class method lifted out of index.js. It closes at the first line that is -// exactly " }" — nothing inside it is indented that shallowly — so the slice -// is unambiguous. -function grab(name) { - const start = src.indexOf(` ${name}(`); - assert.ok(start > 0, `${name} not found in ${WINDOW}`); - const end = src.indexOf("\n }\n", start) + 4; - assert.ok(end > start, `${name} has no end`); - return src.slice(start, end); -} - -const may = new Function(`return { ${grab("_mayDeleteWorkspace")} };`)() - ._mayDeleteWorkspace; - -test("_mayDeleteWorkspace answers the admin bit", () => { - for (const [name, priv, want] of [ - ["view", VIEW, false], - ["chat", CHAT, false], - ["edit", EDIT, false], - ["admin", ADMIN, true], - ["owner", OWNER, true], - ]) { - assert.equal( - may.call({ canAdmin: () => priv & BIT.admin }), - want, - `${name} (${priv}) got the wrong answer`, - ); - } -}); - -test("_mayDeleteWorkspace fails open on a missing or throwing helper", () => { - assert.equal(may.call({}), true); - assert.equal( - may.call({ - canAdmin: () => { - throw new Error("nope"); - }, - }), - true, - ); -}); - -test("confirmFolderDelete refuses before it resolves anything", () => { - // The guard has to come FIRST: everything below it reads the workspace and - // hands it to Wm, and a check placed after that would be racing a request - // that is already on its way. - const body = grab("confirmFolderDelete"); - const guard = body.indexOf("_mayDeleteWorkspace"); - assert.ok(guard > 0, "confirmFolderDelete no longer checks _mayDeleteWorkspace"); - for (const call of ["confirmRemoveWorkspace", "confirmRemovePersonalWorkspace"]) { - const at = body.indexOf(call); - assert.ok(at > guard, `${call} is reachable before the admin check`); - } -}); diff --git a/tests/ws-rename-outside-click.test.js b/tests/ws-rename-outside-click.test.js deleted file mode 100644 index 6daefa46a..000000000 --- a/tests/ws-rename-outside-click.test.js +++ /dev/null @@ -1,359 +0,0 @@ -// Ending the workspace-rename edit: the Save button, and clicking outside -// (modules/desk/index.js — _saveWorkspaceRename, _dismissWorkspaceRename). -// -// Unchanged text closes silently, the way Escape does. CHANGED text asks first, -// because a stray click somewhere else in the desk is not a decision to rename -// a workspace — and it is not a decision to throw the typing away either. -// -// Methods are cut out of the SOURCE FILE and run against a fake `this`, the way -// tests/utility-panel-close.test.js and tests/rail-logo-home.test.js do, so this -// tests the shipped text rather than a copy of it. -const test = require("node:test"); -const assert = require("node:assert"); -const { readFileSync } = require("node:fs"); -const { resolve } = require("node:path"); - -const DESK = resolve(__dirname, "../src/drumee/modules/desk/index.js"); -const src = readFileSync(DESK, "utf8"); - -function grab(name) { - const m = new RegExp(`\\n (async )?${name}\\(`).exec(src); - assert.ok(m, `${name} not found`); - const start = m.index + 1; - return src.slice(start, src.indexOf("\n }\n", start) + 4); -} - -const ANIM_MS = Number( - /\nconst WS_RENAME_ANIM_MS = (\d+)/.exec(src)?.[1], -); -assert.ok(ANIM_MS > 0, "WS_RENAME_ANIM_MS not found in the source"); - -const METHODS = [ - "_bindWorkspaceRenameDismiss", - "_unbindWorkspaceRenameDismiss", - "_dismissWorkspaceRename", - "_saveWorkspaceRename", - "_finishWorkspaceRename", - "_endWorkspaceRename", -]; - -/** An element whose closest() answers for a fixed set of ancestor selectors. */ -function target(...inside) { - return { closest: (sel) => (inside.some((s) => sel.includes(s)) ? {} : null) }; -} - -/** - * A desk with the rename editor up. - * @param {Object} opt - * typed what is in the field now (defaults to the current name) - * current the workspace's name when the edit started - * posted what tile._commitRename answers: "resolve" | "reject" | "none" - * confirm what Wm.confirm answers: "save" | "discard" - */ -function desk(opt = {}) { - const current = opt.current || "Design"; - const typed = opt.typed === undefined ? current : opt.typed; - const calls = { commit: [], confirm: [], listeners: 0, crumb: 0, warned: [] }; - - const doc = { - addEventListener: (t, f, c) => { calls.listeners++; doc._f = f; doc._t = t; doc._c = c; }, - removeEventListener: (t, f, c) => { if (f === doc._f) calls.listeners--; }, - }; - - // The show/close animation defers the VISUAL teardown by WS_RENAME_ANIM_MS. - // State teardown stays synchronous, so only feed([]) and the crumb wait. - const timers = new Map(); - let seq = 0; - const setTimeout_ = (fn, ms) => { timers.set(++seq, { fn, ms }); return seq; }; - const clearTimeout_ = (id) => { timers.delete(id); }; - const flush = () => { const t = [...timers.values()]; timers.clear(); t.forEach((x) => x.fn()); }; - - const field = { value: typed }; - const box = { - el: { - dataset: {}, - querySelector: (s) => (/textarea|input/.test(s) ? field : null), - }, - feed: (k) => { box.fed = k; }, - isDestroyed: () => false, - children: { last: () => cmd }, - }; - const cmd = { - goodbye: () => { calls.goodbye = 1; }, - mget: () => typed, - }; - const chip = { el: { dataset: { renaming: "1" } } }; - - const tile = { - _commitRename: (v) => { - calls.commit.push(v); - if (opt.posted === "none") return null; - if (opt.posted === "reject") return Promise.reject(new Error("nope")); - return Promise.resolve(); - }, - }; - - const Wm = { - confirm: (o) => { - calls.confirm.push(o); - return opt.confirm === "discard" - ? Promise.reject(new Error("dismissed")) - : Promise.resolve(); - }, - _findWorkspaceWindow: () => ({ mget: () => 42 }), - updateBreadcrumb: () => { calls.crumb++; }, - }; - - const d = new Function( - "_", "_a", "LOCALE", "Wm", "document", "setTimeout", "clearTimeout", "WS_RENAME_ANIM_MS", - `return { ${METHODS.map(grab).join(",\n")} };`, - )( - { isFunction: (f) => typeof f === "function" }, - { value: "value", nid: "nid" }, - { SAVE_CHANGES: "Save Changes", SAVE: "Save", DISCARD: "Discard" }, - Wm, - doc, - setTimeout_, - clearTimeout_, - ANIM_MS, - ); - - d.fig = { family: "desk-module" }; - d.warn = (...a) => calls.warned.push(a); - d.__wsRename = { tile, current, hubId: 7, box, chip }; - d._crumbGroupPart = chip; - d._wsRenamePart = box; - return { d, calls, chip, box, flush, pending: () => timers.size }; -} - -test("binding is idempotent and unbinding removes exactly one listener", () => { - const { d, calls } = desk(); - d._bindWorkspaceRenameDismiss(); - d._bindWorkspaceRenameDismiss(); - assert.equal(calls.listeners, 1, "a second bind must not stack a listener"); - d._unbindWorkspaceRenameDismiss(); - assert.equal(calls.listeners, 0); - d._unbindWorkspaceRenameDismiss(); - assert.equal(calls.listeners, 0, "unbinding twice is harmless"); -}); - -test("a click INSIDE the editor is not a dismissal", async () => { - const { d, calls, chip } = desk({ typed: "Design renamed" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module-topbar__ws-rename")); - assert.equal(calls.confirm.length, 0); - assert.equal(calls.commit.length, 0); - assert.ok(d.__wsRename, "the edit is still live"); - assert.equal(chip.el.dataset.renaming, "1"); - assert.equal(calls.listeners, 1, "still listening"); -}); - -test("a click on our own confirm dialog is not a dismissal", async () => { - const { d, calls } = desk({ typed: "Design renamed" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".window-manager__wrapper-modal")); - assert.equal(calls.confirm.length, 0); - assert.ok(d.__wsRename); -}); - -test("unchanged text closes silently — no confirm, no write", async () => { - const { d, calls, chip, flush } = desk({ typed: "Design", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.confirm.length, 0, "nothing to ask about"); - assert.equal(calls.commit.length, 0, "nothing to write"); - assert.equal(d.__wsRename, null); - flush(); - assert.equal(chip.el.dataset.renaming, undefined, "the crumb is back"); - assert.equal(calls.listeners, 0, "listener released"); -}); - -test("an emptied field closes silently rather than renaming to nothing", async () => { - const { d, calls } = desk({ typed: " ", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.confirm.length, 0); - assert.equal(calls.commit.length, 0); - assert.equal(d.__wsRename, null); -}); - -test("changed text asks before doing anything", async () => { - const { d, calls } = desk({ typed: "Design 2026", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.confirm.length, 1, "the user is asked"); - const o = calls.confirm[0]; - assert.equal(o.confirm, "Save"); - assert.equal(o.cancel, "Discard"); - assert.ok(String(o.message).includes("Design 2026"), "the card shows the new name"); - // No backdrop: the question is about the name in the chip behind the card, - // and dimming it makes it harder to check. confirm() defaults to "scrim", so - // this has to be asked for explicitly and must not be dropped by accident. - assert.equal(o.overlay, "none"); -}); - -test("the listener is detached BEFORE the confirm opens", async () => { - // Otherwise the click that dismisses the dialog re-enters this path and - // stacks a second confirm on top of the first. - const { d, calls } = desk({ typed: "Design 2026" }); - d._bindWorkspaceRenameDismiss(); - const p = d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.listeners, 0, "released before the dialog is up"); - await p; -}); - -test("Save commits the typed name and refreshes the breadcrumb", async () => { - const { d, calls, chip, flush } = desk({ typed: "Design 2026", confirm: "save" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.deepEqual(calls.commit, ["Design 2026"]); - assert.equal(calls.crumb, 1, "the chip must not keep the old name"); - assert.equal(d.__wsRename, null); - flush(); - assert.equal(chip.el.dataset.renaming, undefined); -}); - -test("Discard closes without writing", async () => { - const { d, calls, chip, flush } = desk({ typed: "Design 2026", confirm: "discard" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.commit.length, 0, "no write"); - assert.equal(d.__wsRename, null); - flush(); - assert.equal(chip.el.dataset.renaming, undefined); - assert.equal(calls.listeners, 0); -}); - -test("a failed write still closes the editor and says so", async () => { - const { d, calls, chip, flush } = desk({ typed: "Design 2026", confirm: "save", posted: "reject" }); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.deepEqual(calls.commit, ["Design 2026"]); - assert.equal(calls.crumb, 0, "nothing was renamed, so nothing to re-resolve"); - assert.equal(d.__wsRename, null, "the editor does not hang around"); - flush(); - assert.equal(chip.el.dataset.renaming, undefined); - assert.equal(calls.warned.length, 1); -}); - -test("a commit with nothing to post still closes", async () => { - const { d, calls } = desk({ typed: "Design 2026", confirm: "save", posted: "none" }); - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.deepEqual(calls.commit, ["Design 2026"]); - assert.equal(d.__wsRename, null); -}); - -test("a dismissal with no edit in flight just releases the listener", async () => { - const { d, calls } = desk(); - d._bindWorkspaceRenameDismiss(); - d.__wsRename = null; - await d._dismissWorkspaceRename(target(".desk-module__body")); - assert.equal(calls.listeners, 0); - assert.equal(calls.confirm.length, 0); -}); - -// ── The Save button ───────────────────────────────────────────────────────── -// -// Pressing Save IS the decision, so unlike clicking away it never asks. It -// takes the same two decisions Enter takes. - -test("Save writes the typed name and refreshes the breadcrumb", async () => { - const { d, calls, chip, flush } = desk({ typed: "Design 2026", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._saveWorkspaceRename(); - assert.equal(calls.confirm.length, 0, "pressing Save is not ambiguous"); - assert.deepEqual(calls.commit, ["Design 2026"]); - assert.equal(calls.crumb, 1); - assert.equal(d.__wsRename, null); - flush(); - assert.equal(chip.el.dataset.renaming, undefined, "the crumb is back"); -}); - -test("Save on unchanged text closes without writing", async () => { - const { d, calls } = desk({ typed: "Design", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._saveWorkspaceRename(); - assert.equal(calls.commit.length, 0, "nothing changed, nothing to write"); - assert.equal(calls.confirm.length, 0); - assert.equal(d.__wsRename, null); -}); - -test("Save on an emptied field closes rather than renaming to nothing", async () => { - const { d, calls } = desk({ typed: " ", current: "Design" }); - d._bindWorkspaceRenameDismiss(); - await d._saveWorkspaceRename(); - assert.equal(calls.commit.length, 0); - assert.equal(d.__wsRename, null); -}); - -test("Save releases the click-outside listener", async () => { - const { d, calls } = desk({ typed: "Design 2026" }); - d._bindWorkspaceRenameDismiss(); - assert.equal(calls.listeners, 1); - await d._saveWorkspaceRename(); - assert.equal(calls.listeners, 0); -}); - -test("pressing Save is not an outside click", async () => { - // The button lives INSIDE .desk-module-topbar__ws-rename, so the document - // listener must read its mousedown as inside and leave the edit alone — the - // press that follows is the Save handler's business. Without this the - // dismissal would fire first and raise the Save/Discard prompt on top of the - // very button the user just pressed. - const { d, calls } = desk({ typed: "Design 2026" }); - d._bindWorkspaceRenameDismiss(); - await d._dismissWorkspaceRename(target(".desk-module-topbar__ws-rename")); - assert.equal(calls.confirm.length, 0); - assert.equal(calls.commit.length, 0); - assert.ok(d.__wsRename, "the edit survives to be saved"); - assert.equal(calls.listeners, 1); -}); - -test("Save with no edit in flight is a no-op", async () => { - const { d, calls } = desk(); - d.__wsRename = null; - await d._saveWorkspaceRename(); - assert.equal(calls.commit.length, 0); -}); - -// ── Show / close animation ───────────────────────────────────────────────── -// -// The box is animated with `data-anim`, the desk's own convention. Only the -// VISUAL teardown waits for it — the state teardown stays synchronous, so the -// six paths that end an edit keep the ordering they already had. - -test("ending stamps data-anim=out and holds the field until it has played", () => { - const { d, box, chip, pending } = desk({ typed: "Design" }); - d._endWorkspaceRename(); - assert.equal(box.el.dataset.anim, "out", "the out animation is running"); - assert.equal(box.fed, undefined, "the field is still on screen"); - assert.equal(chip.el.dataset.renaming, "1", "and the crumb is still hidden"); - assert.equal(pending(), 1); -}); - -test("the field goes and the crumb returns once the animation is over", () => { - const { d, box, chip, flush } = desk({ typed: "Design" }); - d._endWorkspaceRename(); - flush(); - assert.deepEqual(box.fed, [], "the slot is emptied"); - assert.equal(chip.el.dataset.renaming, undefined, "the crumb is back"); - assert.equal(box.el.dataset.anim, undefined, "and the flag is cleaned up"); -}); - -test("a second ending cancels the first — reopening cannot be emptied under", () => { - // Escape, then Rename again inside the animation window. Without the cancel - // the first timer fires against the NEW editor and blanks it. - const { d, box, pending, flush } = desk({ typed: "Design" }); - d._endWorkspaceRename(); - d._endWorkspaceRename(); - assert.equal(pending(), 1, "one pending teardown, not two"); - flush(); - assert.deepEqual(box.fed, []); -}); - -test("a destroyed box is torn down at once rather than animated", () => { - const { d, chip, pending } = desk({ typed: "Design" }); - d.__wsRename.box.isDestroyed = () => true; - d._endWorkspaceRename(); - assert.equal(pending(), 0, "nothing to animate, nothing to wait for"); - assert.equal(chip.el.dataset.renaming, undefined, "the crumb comes back now"); -});