Ctrl K
ComunidadGuías…

tests/desktop.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The desktop app's pure spine.
//
// The Electron half of `electron/` cannot be driven here — there is no browser
// process under `node --test`, and installing Electron to run the test suite is
// exactly the cost `desktop/` being its own npm project exists to avoid. So the
// parts with a decision in them are written as pure modules and proved here,
// and what is left in the Electron-facing files is wiring:
//
//   electron/deeplink.ts  what a `astrolabe://` link and a double-clicked file are
//                         ALLOWED to name. Hostile input; the refusals are the
//                         product.
//   electron/prefs.ts     the port a vault gets, which decides whether the
//                         reader's theme, tabs and folds survive a launch.
//   electron/cookie.ts    the session lifetime, read off the wire instead of
//                         copied out of server/auth.ts.
//   electron/ipc.ts       the bridge's vocabulary (also counted by
//                         `npm run check-desktop`).
//   electron/presenceState.ts  which vault's activity Discord shows when
//                         several are open, and the messages that carry it.
//
// Root `npm run typecheck` follows these imports in, which is the second half
// of why they are pure: the desktop's spine is typechecked by the release gate
// every contributor already runs, even though `electron/` is not in the root
// tsconfig's `include`.

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, it } from "node:test";
import {
  containsFile,
  knownVault,
  noteRef,
  parseDeepLink,
  relativeNote,
  routeForNote,
  vaultForFile,
} from "../electron/deeplink.ts";
import { TO_MAIN, TO_RENDERER, COMMANDS } from "../electron/ipc.ts";
import { parseSessionCookie } from "../electron/cookie.ts";
import { regValue, windowsSystemVoice } from "../electron/systemVoice.ts";
import { childEnv, parseEnvFile } from "../electron/server.ts";
import {
  EMPTY_PREFS,
  MAX_RECENTS,
  PORT_MAX,
  MIN_WINDOW,
  PORT_MIN,
  fitToWorkArea,
  forgetVault,
  onSomeDisplay,
  parsePrefs,
  portCandidates,
  recentVaults,
  rememberBounds,
  rememberVault,
  rememberedPort,
  rememberZoom,
  saneZoom,
  seedFor,
  stepZoom,
  type Prefs,
} from "../electron/prefs.ts";
import { isPresenceToParent, PresenceBook, presenceStatusMessage } from "../electron/presenceState.ts";
import { isPresenceFromParent } from "../shared/presence/messages.ts";

// ── Deep links: what a stranger's link may name ────────────────────────────

describe("noteRef", () => {
  it("accepts an ordinary vault-relative note", () => {
    assert.equal(noteRef("Ideas/Cache locality.md"), "Ideas/Cache locality.md");
    assert.equal(noteRef("./Ideas/Note.md"), "Ideas/Note.md");
    assert.equal(noteRef("notes\\Idea.md"), "notes/Idea.md");
  });

  it("refuses every shape of escape", () => {
    // A `astrolabe://` URL can be opened by any page in any browser with no
    // prompt. Each of these is a file outside the vault.
    for (const evil of [
      "../secrets.md",
      "../../etc/passwd.md",
      "a/../../b.md",
      "a/b/../../../c.md",
      "/etc/passwd.md",
      "\\\\server\\share\\note.md",
      "C:/Users/me/note.md",
      "notes\\..\\..\\out.md",
      "note.md\u0000.png",
    ]) {
      assert.equal(noteRef(evil), null, `should refuse ${JSON.stringify(evil)}`);
    }
  });

  it("refuses anything that is not a note", () => {
    // Not a purity rule — the app can open an attachment. It means nothing OFF
    // this machine gets to choose the file the app reads and indexes.
    assert.equal(noteRef("photo.png"), null);
    assert.equal(noteRef("archive.zip"), null);
    assert.equal(noteRef(".ssh/id_rsa"), null);
    assert.equal(noteRef(""), null);
    assert.equal(noteRef("."), null);
  });

  it("keeps the three note extensions the rest of the product agrees on", () => {
    assert.equal(noteRef("a.md"), "a.md");
    assert.equal(noteRef("a.tex"), "a.tex");
    assert.equal(noteRef("a.latex"), "a.latex");
  });
});

describe("parseDeepLink", () => {
  it("reads the two spellings a link in the wild actually has", () => {
    assert.deepEqual(parseDeepLink("astrolabe://note?path=Ideas/A.md"), {
      vault: null,
      note: "Ideas/A.md",
    });
    assert.deepEqual(parseDeepLink("astrolabe:///note?path=Ideas/A.md"), {
      vault: null,
      note: "Ideas/A.md",
    });
  });

  it("carries a vault and a note together", () => {
    const link = parseDeepLink("astrolabe://open?vault=/home/me/vault&note=A.md");
    assert.equal(link?.note, "A.md");
    assert.equal(link?.vault, path.normalize("/home/me/vault"));
  });

  it("refuses anything that is not an astrolabe link", () => {
    assert.equal(parseDeepLink("https://example.com/"), null);
    assert.equal(parseDeepLink("file:///etc/passwd"), null);
    assert.equal(parseDeepLink("not a url"), null);
    assert.equal(parseDeepLink("astrolabe://delete?path=A.md"), null);
    assert.equal(parseDeepLink("astrolabe://open"), null); // names nothing
  });

  it("drops a bad note but keeps a good vault", () => {
    const link = parseDeepLink("astrolabe://open?vault=/home/me/vault&note=../../x.md");
    assert.equal(link?.note, null);
    assert.equal(link?.vault, path.normalize("/home/me/vault"));
  });

  it("refuses a relative vault", () => {
    // An absolute path is the only kind that means one directory.
    assert.equal(parseDeepLink("astrolabe://open?vault=../../..")?.vault ?? null, null);
  });
});

describe("knownVault", () => {
  const known = [path.resolve("/home/me/notes"), path.resolve("/home/me/work")];

  it("honors a vault the reader has already opened", () => {
    assert.equal(knownVault(path.resolve("/home/me/notes"), known), known[0]);
    assert.equal(knownVault(path.resolve("/home/me/notes") + path.sep, known), known[0]);
  });

  it("refuses a directory nothing has opened", () => {
    // THE rule. Without it, `astrolabe://open?vault=/` is a link that makes the
    // app index and serve the reader's entire disk.
    assert.equal(knownVault("/", known), null);
    assert.equal(knownVault(path.resolve("/home/me"), known), null);
    assert.equal(knownVault(path.resolve("/home/me/notes/sub"), known), null);
  });
});

describe("file associations", () => {
  const vault = path.resolve("/home/me/notes");

  it("does not mistake a sibling for a child", () => {
    assert.equal(containsFile(vault, path.resolve("/home/me/notes-backup/A.md")), false);
    assert.equal(containsFile(vault, path.resolve("/home/me/notes/A.md")), true);
    assert.equal(containsFile(vault, vault), false);
  });

  it("resolves a file to its vault-relative note", () => {
    assert.equal(relativeNote(vault, path.resolve("/home/me/notes/Ideas/A.md")), "Ideas/A.md");
    assert.equal(relativeNote(vault, path.resolve("/home/me/other/A.md")), null);
  });

  it("picks the DEEPEST vault holding the file", () => {
    // A reader who opened both a vault and a sub-folder of it as vaults meant
    // the sub-folder when they double-clicked a file inside it.
    const vaults = [path.resolve("/home/me"), path.resolve("/home/me/notes")];
    assert.equal(vaultForFile(path.resolve("/home/me/notes/A.md"), vaults), vaults[1]);
    assert.equal(vaultForFile(path.resolve("/home/me/A.md"), vaults), vaults[0]);
    assert.equal(vaultForFile(path.resolve("/tmp/A.md"), vaults), null);
  });
});

describe("routeForNote", () => {
  it("spells a note exactly as the app's own address bar does", () => {
    // Not a stylistic agreement. A deep link that lands on `/Welcome.md` when
    // the router spells it `/Welcome` puts the reader on a URL the router
    // rewrites out from under them — and, in the tree-miss case, on a
    // different note.
    //
    // The expected values are `client/router.ts::notePathToUrl`'s, written out
    // rather than imported: that module reaches the store, the tree, the toast
    // host and a stylesheet, none of which loads under `node --test`. The two
    // implementations share the half that decides the answer
    // (`shared/noteFormat.ts::stripNoteExt`); this pins the other half.
    assert.equal(routeForNote("Welcome.md"), "/Welcome");
    assert.equal(routeForNote("Ideas/Cache locality.md"), "/Ideas/Cache%20locality");
    assert.equal(routeForNote("Paper.tex"), "/Paper");
    assert.equal(routeForNote("Notes/A & B.md"), "/Notes/A%20%26%20B");
    assert.equal(routeForNote("مقالة.md"), "/%D9%85%D9%82%D8%A7%D9%84%D8%A9");
  });
});

// ── Ports: the reader's theme, tabs and folds, in one integer ──────────────

function prefsWith(...vaults: { path: string; port: number }[]): Prefs {
  return {
    ...EMPTY_PREFS,
    vaults: vaults.map((v) => ({ path: v.path, port: v.port, bounds: null, lastOpened: 1 })),
  };
}

describe("portCandidates", () => {
  it("offers the remembered port FIRST", () => {
    // The whole point: `localStorage` is keyed by origin and the origin is the
    // port, so the port a vault had is the port its stored layout lives on.
    const prefs = prefsWith({ path: "/v/a", port: 6842 });
    assert.equal(portCandidates("/v/a", prefs)[0], 6842);
    assert.equal(rememberedPort("/v/a", prefs), 6842);
  });

  it("offers no remembered port for a vault it has never seen", () => {
    assert.equal(rememberedPort("/v/new", EMPTY_PREFS), 0);
  });

  it("covers the whole desktop band exactly once", () => {
    const list = portCandidates("/v/a", EMPTY_PREFS);
    assert.equal(list.length, PORT_MAX - PORT_MIN + 1);
    assert.equal(new Set(list).size, list.length);
    for (const port of list) assert.ok(port >= PORT_MIN && port <= PORT_MAX);
  });

  it("skips ports other vaults already own, and never its own twice", () => {
    const prefs = prefsWith(
      { path: "/v/a", port: 6842 },
      { path: "/v/b", port: 6843 },
      { path: "/v/c", port: 6844 },
    );
    const list = portCandidates("/v/a", prefs);
    assert.equal(list[0], 6842);
    assert.equal(list.filter((p) => p === 6842).length, 1);
    assert.ok(!list.includes(6843));
    assert.ok(!list.includes(6844));
  });

  it("seeds from the vault path, so the same vault lands on the same port", () => {
    // This is what makes a reinstall — or the same vault on a second machine —
    // keep its stored layout without the preferences file having survived.
    const first = portCandidates("/home/me/notes", EMPTY_PREFS)[0];
    const again = portCandidates("/home/me/notes", EMPTY_PREFS)[0];
    assert.equal(first, again);
    assert.notEqual(first, portCandidates("/home/me/work", EMPTY_PREFS)[0]);
    assert.equal(seedFor("/home/me/notes"), seedFor("/home/me/notes"));
  });

  it("never proposes 6801 — the web deployment's own port", () => {
    // Running `npm start` in a terminal beside the desktop app is the normal
    // case, not a conflict to arbitrate.
    for (const vault of ["/a", "/b", "/some/deep/vault", "/خزانة"]) {
      assert.ok(!portCandidates(vault, EMPTY_PREFS).includes(6801));
    }
  });
});

describe("parsePrefs", () => {
  it("survives anything at all", () => {
    for (const junk of [null, 42, "text", [], { vaults: "no" }, { vaults: [1, 2, null] }]) {
      const prefs = parsePrefs(junk);
      assert.deepEqual(prefs.vaults, []);
      assert.equal(prefs.spellcheck, true);
    }
  });

  it("drops a bad field rather than the whole document", () => {
    const prefs = parsePrefs({
      vaults: [
        { path: "/v/a", port: 6842, lastOpened: 5 },
        { port: 6843 }, // no path — meaningless
        { path: "/v/c", port: 99999 }, // outside the desktop band
        { path: "/v/a", port: 6844 }, // duplicate path
      ],
    });
    assert.deepEqual(prefs.vaults.map((v) => v.path), ["/v/a", "/v/c"]);
    assert.equal(prefs.vaults.find((v) => v.path === "/v/c")?.port, 0);
  });

  it("refuses window bounds that cannot be a window", () => {
    const sane = { x: 100, y: 80, width: 1280, height: 860, maximized: false };
    assert.deepEqual(parsePrefs({ vaults: [{ path: "/v", port: 6820, bounds: sane }] }).vaults[0].bounds, sane);
    for (const bad of [
      { x: 0, y: 0, width: 10, height: 10 },
      { x: 0, y: 0, width: 1e9, height: 800 },
      { x: NaN, y: 0, width: 800, height: 600 },
      { x: 0, y: 0, width: 800 },
      "nope",
    ]) {
      assert.equal(parsePrefs({ vaults: [{ path: "/v", port: 6820, bounds: bad }] }).vaults[0].bounds, null);
    }
  });

  it("keeps a NEGATIVE origin — a display to the left of the primary one", () => {
    const left = { x: -1400, y: 40, width: 1280, height: 860, maximized: false };
    assert.deepEqual(parsePrefs({ vaults: [{ path: "/v", port: 6820, bounds: left }] }).vaults[0].bounds, left);
  });

  // The gap the Windows report fell into: the window's own minimum was 400
  // tall, the preferences file's was 480 on both axes, and every Snap quadrant
  // on a 150% 1080p laptop lands between them.
  it("keeps every rectangle the window itself is allowed to be", () => {
    const smallest = { x: 0, y: 0, width: MIN_WINDOW.width, height: MIN_WINDOW.height, maximized: false };
    assert.deepEqual(parsePrefs({ vaults: [{ path: "/v", port: 6820, bounds: smallest }] }).vaults[0].bounds, smallest);
    // A Snap quadrant on a 1366×768 laptop at 150%: 455×256 DIP is below the
    // floor on both axes and is still refused.
    const tooSmall = { x: 0, y: 0, width: 455, height: 256, maximized: false };
    assert.equal(parsePrefs({ vaults: [{ path: "/v", port: 6820, bounds: tooSmall }] }).vaults[0].bounds, null);
  });
});

describe("fitToWorkArea", () => {
  const laptop = { x: 0, y: 0, width: 911, height: 512 };

  it("pulls a window that hangs off the desk back onto it", () => {
    // Saved on a 1920×1080 desktop, re-opened on a scaled 1366 laptop: before
    // this, the caption buttons, the trailing edge and the bottom corner were
    // all outside the work area.
    const fitted = fitToWorkArea({ x: 1200, y: 700, width: 1280, height: 860 }, laptop);
    assert.deepEqual(fitted, { x: 0, y: 0, width: 911, height: 512 });
  });

  it("leaves a rectangle that already fits exactly alone", () => {
    const rect = { x: 40, y: 30, width: 700, height: 400 };
    assert.deepEqual(fitToWorkArea(rect, laptop), rect);
  });

  it("respects a work area that does not start at the origin", () => {
    const desk = { x: 0, y: 32, width: 1440, height: 868 };
    assert.deepEqual(fitToWorkArea({ x: 0, y: 0, width: 1280, height: 860 }, desk), { x: 0, y: 32, width: 1280, height: 860 });
  });

  it("never returns a window smaller than the app's own floor while the desk holds it", () => {
    const fitted = fitToWorkArea({ x: 0, y: 0, width: 100, height: 100 }, laptop);
    assert.equal(fitted.width, MIN_WINDOW.width);
    assert.equal(fitted.height, MIN_WINDOW.height);
  });
});

describe("the recent list", () => {
  it("moves an opened vault to the head and keeps its geometry", () => {
    const bounds = { x: 10, y: 10, width: 900, height: 700, maximized: false };
    let prefs = rememberVault(EMPTY_PREFS, "/v/a", 6842, 1);
    prefs = rememberBounds(prefs, "/v/a", bounds);
    prefs = rememberVault(prefs, "/v/b", 6843, 2);
    prefs = rememberVault(prefs, "/v/a", 6842, 3);
    assert.equal(prefs.vaults[0].path, "/v/a");
    assert.deepEqual(prefs.vaults[0].bounds, bounds);
    assert.equal(recentVaults(prefs)[0].path, "/v/a");
  });

  it("caps the list", () => {
    let prefs = EMPTY_PREFS;
    for (let i = 0; i < MAX_RECENTS + 5; i++) prefs = rememberVault(prefs, `/v/${i}`, 6820 + i, i);
    assert.equal(prefs.vaults.length, MAX_RECENTS);
  });

  // `maximized` is not a rectangle, so a rectangle we distrust says nothing
  // about it. Dropping the pair together is how a maximised window came back
  // at a size it had left two resizes ago.
  it("keeps the maximised flag even when the normal rectangle is refused", () => {
    const good = { x: 10, y: 10, width: 900, height: 700, maximized: false };
    let prefs = rememberBounds(rememberVault(EMPTY_PREFS, "/v/a", 6842, 1), "/v/a", good);
    prefs = rememberBounds(prefs, "/v/a", { x: 0, y: 0, width: 200, height: 100, maximized: true });
    assert.equal(prefs.vaults[0].bounds?.maximized, true);
    assert.equal(prefs.vaults[0].bounds?.width, 900, "the last rectangle we trusted is the one kept");
  });

  it("does not invent a vault from a stale window's geometry", () => {
    const before = rememberVault(EMPTY_PREFS, "/v/a", 6842, 1);
    const after = rememberBounds(before, "/v/gone", { x: 0, y: 0, width: 900, height: 700, maximized: false });
    assert.deepEqual(after.vaults.map((v) => v.path), ["/v/a"]);
  });

  it("forgets a vault, port and geometry together", () => {
    const prefs = forgetVault(rememberVault(EMPTY_PREFS, "/v/a", 6842, 1), "/v/a");
    assert.deepEqual(prefs.vaults, []);
  });
});

describe("onSomeDisplay", () => {
  const primary = { bounds: { x: 0, y: 0, width: 1920, height: 1080 } };
  const secondary = { bounds: { x: -1920, y: 0, width: 1920, height: 1080 } };

  it("restores a window that is still visible", () => {
    assert.equal(onSomeDisplay({ x: 100, y: 100, width: 1280, height: 860, maximized: false }, [primary]), true);
    assert.equal(onSomeDisplay({ x: -1800, y: 60, width: 1280, height: 860, maximized: false }, [primary, secondary]), true);
  });

  it("refuses a window on a monitor that has been unplugged", () => {
    // The failure this exists for: the app "does not start", while in fact it
    // is running perfectly, off the side of the desk.
    assert.equal(onSomeDisplay({ x: -1800, y: 60, width: 1280, height: 860, maximized: false }, [primary]), false);
    assert.equal(onSomeDisplay({ x: 4000, y: 0, width: 1280, height: 860, maximized: false }, [primary]), false);
  });

  it("accepts a window hanging off an edge, if there is enough to grab", () => {
    assert.equal(onSomeDisplay({ x: 1830, y: 40, width: 1280, height: 860, maximized: false }, [primary]), true);
    assert.equal(onSomeDisplay({ x: 1900, y: 40, width: 1280, height: 860, maximized: false }, [primary]), false);
  });
});

// ── The session, as the server states it ───────────────────────────────────

describe("parseSessionCookie", () => {
  it("reads the name, the value and the lifetime the SERVER chose", () => {
    const cookie = parseSessionCookie(
      "astrolabe_session=v2.1.1770000000000.abc; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax",
    );
    assert.equal(cookie?.name, "astrolabe_session");
    assert.equal(cookie?.value, "v2.1.1770000000000.abc");
    // 7 days, and nothing in electron/ says "7" anywhere.
    assert.equal(cookie?.maxAge, 604800);
  });

  it("does not care what the cookie is called", () => {
    assert.equal(parseSessionCookie("whatever=x; Max-Age=60")?.name, "whatever");
  });

  it("reports no lifetime rather than a wrong one", () => {
    assert.equal(parseSessionCookie("a=b; Path=/")?.maxAge, 0);
    assert.equal(parseSessionCookie("a=b; Max-Age=nonsense")?.maxAge, 0);
    assert.equal(parseSessionCookie("a=b; Max-Age=-1")?.maxAge, 0);
  });

  it("refuses a header that is not a cookie", () => {
    assert.equal(parseSessionCookie(""), null);
    assert.equal(parseSessionCookie("=novalue"), null);
    assert.equal(parseSessionCookie("noequals"), null);
  });
});

// ── The bridge's vocabulary ────────────────────────────────────────────────

describe("the IPC register", () => {
  it("names every channel exactly once, in one namespace", () => {
    const all = [...Object.values(TO_MAIN), ...Object.values(TO_RENDERER)];
    assert.equal(new Set(all).size, all.length, "a channel name is used twice");
    for (const channel of all) assert.match(channel, /^astrolabe:[a-z-]+$/);
  });

  it("has no command the menu could send that is not spelled here", () => {
    assert.equal(new Set(COMMANDS).size, COMMANDS.length);
    for (const command of COMMANDS) assert.match(command, /^[a-z-]+$/);
  });
});

describe("the data-dir override", () => {
  it("parses only an absolute path, and survives a round trip", () => {
    const prefs = parsePrefs({
      vaults: [
        { path: "/v/a", port: 6821, lastOpened: 5, data: "/srv/astrolabe-data" },
        { path: "/v/b", port: 6822, lastOpened: 4, data: "relative/nope" },
      ],
    });
    assert.equal(prefs.vaults[0].data, "/srv/astrolabe-data");
    assert.equal(prefs.vaults[1].data, undefined);
  });

  it("SURVIVES rememberVault, which rebuilds the row on every open", () => {
    // Without the carry, the override existed until the first launch: the
    // rewrite dropped it, the vault silently reverted to an empty per-app
    // home, and the desktop "lost" the reader's settings.
    let prefs = parsePrefs({
      vaults: [{ path: "/v/a", port: 6821, lastOpened: 5, data: "/srv/astrolabe-data" }],
    });
    prefs = rememberVault(prefs, "/v/a", 6823, 99);
    assert.equal(prefs.vaults[0].data, "/srv/astrolabe-data");
    assert.equal(prefs.vaults[0].port, 6823);
  });
});

describe("the voice Windows chose", () => {
  it("reads a REG_SZ out of reg query, whatever the value column is called", () => {
    const token = [
      "",
      "HKEY_CURRENT_USER\\Software\\Microsoft\\Speech_OneCore\\Voices",
      "    DefaultTokenId    REG_SZ    HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech_OneCore\\Voices\\Tokens\\MSTTS_V110_frFR_PaulM",
      "",
    ].join("\r\n");
    assert.equal(regValue(token), "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech_OneCore\\Voices\\Tokens\\MSTTS_V110_frFR_PaulM");
    const name = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech_OneCore\\Voices\\Tokens\\MSTTS_V110_frFR_PaulM\r\n    (par défaut)    REG_SZ    Microsoft Paul - French (France)\r\n";
    assert.equal(regValue(name), "Microsoft Paul - French (France)");
    assert.equal(regValue("ERROR: The system was unable to find the specified registry key or value."), null);
  });

  it("is asked only on Windows", async () => {
    assert.equal(await windowsSystemVoice("linux"), null);
    assert.equal(await windowsSystemVoice("darwin"), null);
  });
});

describe("the app zoom", () => {
  it("survives a relaunch: remembered, written, read back, and carried through the next open", () => {
    // The reader's report: 120%, close, launch — 100% again. rememberZoom
    // wrote it; the next launch's rememberVault rebuilt the row without it.
    let prefs = rememberVault(EMPTY_PREFS, "/v/a", 6842, 1);
    prefs = rememberZoom(prefs, "/v/a", stepZoom(1, 1));
    assert.equal(prefs.vaults[0].zoom, 1.2);
    // desktop.json on disk, and the launch that reads it back.
    let next = parsePrefs(JSON.parse(JSON.stringify(prefs)));
    assert.equal(next.vaults[0].zoom, 1.2);
    next = rememberVault(next, "/v/a", 6842, 2);
    next = rememberBounds(next, "/v/a", { x: 10, y: 10, width: 1000, height: 700, maximized: false });
    assert.equal(next.vaults[0].zoom, 1.2, "the open and the geometry keep the zoom");
    // A vault never zoomed has no field at all, and opens at 1.
    assert.equal(rememberVault(EMPTY_PREFS, "/v/b", 6843, 1).vaults[0].zoom, undefined);
  });

  it("is a sane factor whatever the file says, and a reset is exactly 1", () => {
    assert.equal(parsePrefs({ vaults: [{ path: "/v/a", port: 6821, lastOpened: 1, zoom: 40 }] }).vaults[0].zoom, 3);
    assert.equal(parsePrefs({ vaults: [{ path: "/v/a", port: 6821, lastOpened: 1, zoom: "big" }] }).vaults[0].zoom, 1);
    assert.equal(saneZoom(0.1), 0.5);
    assert.equal(stepZoom(1.44, 0), 1);
    assert.ok(Math.abs(stepZoom(1.2, -1) - 1) < 1e-9);
  });

  it("is not invented for a vault that was never opened", () => {
    const prefs = rememberZoom(EMPTY_PREFS, "/v/ghost", 1.2);
    assert.equal(prefs.vaults.length, 0);
  });
});

describe("an env-linked vault is the deployment, in a window", () => {
  it("parses the .env dialect node --env-file reads, totally", () => {
    const env = parseEnvFile(
      [
        "# a comment",
        "PORT=8080",
        "SITE_NAME='Night Garden'",
        'ADMIN_PASSWORD_HASH="$argon2id$v=19$m=65536,t=3"',
        "  PUBLIC_LAYOUT = blog  ",
        "not a line",
        "lower=case is not a key",
        "",
      ].join("\n"),
    );
    assert.equal(env.SITE_NAME, "Night Garden");
    assert.equal(env.ADMIN_PASSWORD_HASH, "$argon2id$v=19$m=65536,t=3");
    assert.equal(env.PUBLIC_LAYOUT, "blog");
    assert.equal(env.lower, undefined);
  });

  it("the deployment's identity wins; the desktop keeps only placement", () => {
    const cred = { password: "minted", hash: "minted-hash", secret: "minted-secret" };
    const env = childEnv("/v", "/data", 6821, cred, {
      PORT: "8080",
      HOST: "0.0.0.0",
      ADMIN_PASSWORD_HASH: "owner-hash",
      SESSION_SECRET: "owner-secret",
      PUBLIC_LAYOUT: "blog",
      SITE_LANG: "ar",
    });
    // Identity is the deployment's: the owner's password works, the public
    // site is public, the site speaks its own language.
    assert.equal(env.ADMIN_PASSWORD_HASH, "owner-hash");
    assert.equal(env.SESSION_SECRET, "owner-secret");
    assert.equal(env.PUBLIC_LAYOUT, "blog");
    assert.equal(env.PUBLIC, undefined);
    // Placement is the desktop's: a deployment's PORT following it into a
    // window would fight the deployment for its own socket.
    assert.equal(env.PORT, "6821");
    assert.equal(env.HOST, "127.0.0.1");
  });

  it("without a linked deployment, the minted credential still rules", () => {
    const cred = { password: "minted", hash: "minted-hash", secret: "minted-secret" };
    const env = childEnv("/v", "/data", 6821, cred, null);
    assert.equal(env.ADMIN_PASSWORD_HASH, "minted-hash");
    assert.equal(env.PUBLIC, "false");
  });

  it("the desktop is its own operator: Read aloud's external speaker is allowed, unless a deployment's .env says off", () => {
    const cred = { password: "minted", hash: "minted-hash", secret: "minted-secret" };
    assert.equal(childEnv("/v", "/data", 6821, cred, null).SPEAK_EXTERNAL, "on");
    assert.equal(childEnv("/v", "/data", 6821, cred, { SITE_LANG: "ar" }).SPEAK_EXTERNAL, "on");
    assert.equal(childEnv("/v", "/data", 6821, cred, { SPEAK_EXTERNAL: "off" }).SPEAK_EXTERNAL, "off");
  });
});

// ── The spellchecker's languages ───────────────────────────────────────────
//
// electron/spellcheck.ts imports Electron's types, which the root typecheck
// cannot resolve (desktop/ has its own node_modules; `npm run check-desktop`
// typechecks it there), so the whitelist is asserted from the SOURCE: every
// language `shared/script.ts::spellcheckLang` can stamp on a line must be
// enabled, or the attribute is inert on the desktop.

describe("spellcheck languages", () => {
  it("enables every language a line can be stamped with", () => {
    const src = readFileSync(new URL("../electron/spellcheck.ts", import.meta.url), "utf8");
    const m = /export const LINE_LANGUAGES = \[([^\]]+)\]/.exec(src);
    assert.ok(m, "LINE_LANGUAGES is declared");
    const listed = [...(m?.[1] ?? "").matchAll(/"([a-z]+)"/g)].map((x) => x[1]);
    // he, fa, ar from the script test; fr from shared/french.ts's line test;
    // ja from the furigana work (no dictionary anywhere — dropped at runtime).
    assert.deepEqual(listed, ["he", "fa", "ar", "fr", "ja"]);
  });
});



// ── Discord presence: one card for every open vault ───────────────────────

describe("PresenceBook", () => {
  const A = { type: 0 as const, details: "Writing" };
  const B = { type: 0 as const, details: "Drawing" };
  const msg = (clientId: string | null, activity: typeof A | null) => ({ type: "astrolabe:presence" as const, clientId, activity });

  it("is null with nothing to show", () => {
    const book = new PresenceBook();
    assert.equal(book.pick(), null);
    book.set("/v/one", msg(null, null), 1);
    assert.equal(book.pick(), null);
  });

  it("the most recent non-null activity wins across vaults", () => {
    const book = new PresenceBook();
    book.set("/v/one", msg("111111111111111111", A), 1);
    book.set("/v/two", msg("222222222222222222", B), 2);
    assert.deepEqual(book.pick(), { clientId: "222222222222222222", activity: B });
    book.set("/v/one", msg("111111111111111111", A), 3);
    assert.deepEqual(book.pick(), { clientId: "111111111111111111", activity: A });
  });

  it("a vault that turns null steps aside instead of clearing another's card", () => {
    const book = new PresenceBook();
    book.set("/v/one", msg("111111111111111111", A), 1);
    book.set("/v/two", msg(null, null), 2);
    assert.deepEqual(book.pick(), { clientId: "111111111111111111", activity: A });
    book.set("/v/one", msg("111111111111111111", null), 3);
    assert.equal(book.pick(), null);
  });

  it("a dropped vault is forgotten", () => {
    const book = new PresenceBook();
    book.set("/v/one", msg("111111111111111111", A), 1);
    book.set("/v/two", msg("222222222222222222", B), 2);
    book.drop("/v/two");
    assert.deepEqual(book.pick(), { clientId: "111111111111111111", activity: A });
  });
});

describe("presence messages", () => {
  it("the child's message is recognized, and nothing else on the channel is", () => {
    assert.equal(isPresenceToParent({ type: "astrolabe:presence", clientId: "1", activity: null }), true);
    assert.equal(isPresenceToParent({ type: "astrolabe:presence", clientId: null, activity: { type: 0 } }), true);
    assert.equal(isPresenceToParent({ type: "astrolabe:listening", port: 6801 }), false);
    assert.equal(isPresenceToParent({ type: "astrolabe:presence", clientId: 1, activity: null }), false);
    assert.equal(isPresenceToParent({ type: "astrolabe:presence", clientId: "1", activity: [] }), false);
    assert.equal(isPresenceToParent(null), false);
  });

  it("the parent's status message round-trips through the child's guard", () => {
    for (const c of ["off", "searching", "connected", "invalid-id"] as const) {
      assert.deepEqual(presenceStatusMessage(c), { type: "astrolabe:presence-status", connection: c });
      assert.equal(isPresenceFromParent(presenceStatusMessage(c)), true);
    }
    assert.equal(isPresenceFromParent({ type: "astrolabe:presence-status", connection: "lost" }), false);
    assert.equal(isPresenceFromParent({ type: "astrolabe:presence", connection: "off" }), false);
    assert.equal(isPresenceFromParent("x"), false);
  });
});