tests/aliases.test.ts
Rama main · réplica de lectura del repositorio original.
// Frontmatter `aliases:` — the OTHER names a note answers to.
//
// The README invites the reader to point Astrolabe at an existing Obsidian vault.
// In one of those, notes carry `aliases: [ML, machine-learning]` and are linked
// as `[[ML]]`: before this table existed, every one of those links rendered
// dashed and offered to create a duplicate note — in the first hour, to exactly
// the reader the product is pitched at.
//
// Four surfaces have to agree or the feature is worse than its absence (a note
// reachable by a name in one place and invisible by it in three others), so
// this file covers all four from one vault: resolution, search, the completion
// list's data source, and backlinks. The write half — keeping a renamed note's
// old title as an alias — is at the bottom, over strings alone.
import assert from "node:assert/strict";
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import {
aliasEntries,
backlinks,
indexFile,
initIndexer,
resolveLink as resolveLinkRaw,
search as searchRaw,
} from "../server/indexer.ts";
import { addNoteAlias, parseAliases, readNoteFrontmatter } from "../server/noteFrontmatter.ts";
import { resolveLink as clientResolve, setAliasTable } from "../client/editor/links.ts";
import { initSite } from "../server/site.ts";
import { buildTree, initVault } from "../server/vault.ts";
import type { TreeNode } from "../shared/types.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";
// Every case below pins the language scope to `null` ("no scoping"): the
// filter has its own tests, and none of these is about it.
const resolveLink = (name: string, publishedOnly = false): string | null =>
resolveLinkRaw(name, publishedOnly, null);
const search = (q: string, publishedOnly = false): ReturnType<typeof searchRaw> =>
searchRaw(q, publishedOnly, null);
const data = makeDir();
const root = makeVault({
// The three spellings a real vault contains, one per note.
"Notes/Machine Learning.md": note({ aliases: "[ML, machine-learning]" }, "# Machine Learning\n"),
"Notes/Deep Learning.md": note({ aliases: "DL, deep learning" }, "# Deep Learning\n"),
"Notes/Solo.md": note({ aliases: "Alone" }, "# Solo\n"),
// A block list, plus Obsidian's older singular key.
"Notes/Blocks.md": "---\naliases:\n - Bee\n - Cee\n---\n# Blocks\n",
"Notes/Singular.md": note({ alias: "Older" }, "# Singular\n"),
// Precedence: a note whose FILENAME is what another note claims as an alias.
"DL.md": "# The real DL\n",
"Notes/Claims DL.md": note({ aliases: "[DL]" }, "# Claims DL\n"),
// The tie: two notes claiming one alias. `zzzz/Beta.md` has FEWER segments
// and a LONGER string than `x/y/Alpha.md` — the same shape links.test.ts
// pins for duplicate basenames, so the two rules can be compared directly.
"x/y/Alpha.md": note({ aliases: "[AI]" }, "# Alpha\n"),
"zzzz/Beta.md": note({ aliases: "[AI]" }, "# Beta\n"),
// A `.tex` note keeps its frontmatter in a comment block that still compiles.
"papers/Fourier.tex": "%---\n% aliases: [Heat, heat equation]\n%---%\n\\documentclass{article}\n\\begin{document}\nOn heat.\n\\end{document}\n",
// Visibility: an alias must never make a private note reachable, and a
// private note's FILENAME must not shadow a published note's alias for a
// visitor who cannot see either the file or its name.
"Private.md": note({ aliases: "[Secret]" }, "# Private\n"),
"Pub.md": note({ publish: "true", aliases: "[Pubby, Ghost]" }, "# Pub\n"),
"Ghost.md": "# Ghost\n",
// The linker: one note pointing at another THROUGH an alias.
"Notes/Reading.md": "# Reading\n\nToday I read about [[ML]] and took notes.\n",
});
before(async () => {
initSite({ ASTROLABE_DATA: data });
initVault(root);
await initIndexer();
});
after(() => {
removeVault(root);
removeVault(data);
});
// ------------------------------------------------------------------ parsing
describe("parseAliases — the three shapes YAML hands over", () => {
it("reads a flow list", () => {
assert.deepEqual(parseAliases({ aliases: ["ML", "machine-learning"] }), ["ML", "machine-learning"]);
});
it("splits a comma-separated scalar", () => {
assert.deepEqual(parseAliases({ aliases: "DL, deep learning" }), ["DL", "deep learning"]);
});
it("takes a bare scalar as one name", () => {
assert.deepEqual(parseAliases({ aliases: "Alone" }), ["Alone"]);
});
it("never splits a LIST ITEM on its commas", () => {
// `aliases: ["Smith, John"]` is already one item to YAML. Splitting it too
// would leave no way to spell an alias that contains a comma.
assert.deepEqual(parseAliases({ aliases: ["Smith, John"] }), ["Smith, John"]);
});
it("reads the singular `alias:` too, and a numeric one", () => {
assert.deepEqual(parseAliases({ alias: "Older" }), ["Older"]);
assert.deepEqual(parseAliases({ aliases: [2024] }), ["2024"]);
});
it("drops what is not a name, and collapses duplicates case-insensitively", () => {
assert.deepEqual(parseAliases({ aliases: [{ nested: 1 }, true, " ", "ML", "ml"] }), ["ML"]);
assert.deepEqual(parseAliases({}), []);
});
});
// --------------------------------------------------------------- resolution
describe("resolveLink through the alias table", () => {
const cases: [string, string | null][] = [
["ML", "Notes/Machine Learning.md"],
["machine-learning", "Notes/Machine Learning.md"],
["ml", "Notes/Machine Learning.md"], // case-insensitive, like every name
["deep learning", "Notes/Deep Learning.md"],
["Alone", "Notes/Solo.md"],
["Bee", "Notes/Blocks.md"],
["Cee", "Notes/Blocks.md"],
["Older", "Notes/Singular.md"],
["Heat", "papers/Fourier.tex"],
["heat equation", "papers/Fourier.tex"],
["nobody's alias", null],
];
for (const [name, expected] of cases) {
it(`[[${name}]] → ${expected ?? "null"}`, () => {
assert.equal(resolveLink(name), expected);
});
}
it("a real FILENAME beats someone else's alias", () => {
// Both exist: `DL.md` at the root, and `Notes/Claims DL.md` declaring
// `aliases: [DL]`. The file's own name wins, whatever the paths look like.
assert.equal(resolveLink("DL"), "DL.md");
assert.equal(resolveLink("Claims DL"), "Notes/Claims DL.md");
});
it("two notes claiming one alias tie by the basename rule: fewest segments, then shortest, then alpha", () => {
assert.equal(resolveLink("AI"), "zzzz/Beta.md");
});
});
describe("aliases and the visitor", () => {
it("an alias never makes a private note reachable", () => {
assert.equal(resolveLink("Secret"), "Private.md");
assert.equal(resolveLink("Secret", true), null);
});
it("a published note's alias resolves for a visitor", () => {
assert.equal(resolveLink("Pubby", true), "Pub.md");
});
it("a private FILENAME does not shadow a published alias for a visitor", () => {
// To the admin, `Ghost.md` is a file and its name wins. Inside the
// visitor's smaller collection no note is named Ghost at all, so the
// published note that claims the alias is the honest answer — and both
// branches are computed from notes the caller may already discover, so
// neither leaks the other's existence.
assert.equal(resolveLink("Ghost"), "Ghost.md");
assert.equal(resolveLink("Ghost", true), "Pub.md");
});
});
// -------------------------------------------------------- the other surfaces
describe("search finds a note by its alias, and says so", () => {
it("indexes the alias", () => {
const hits = search("ML");
const hit = hits.find((h) => h.path === "Notes/Machine Learning.md");
assert.ok(hit, `expected the ML note among ${JSON.stringify(hits.map((h) => h.path))}`);
assert.equal(hit.alias, "ML");
});
it("says nothing about aliases when the TITLE is what matched", () => {
const hit = search("Solo").find((h) => h.path === "Notes/Solo.md");
assert.ok(hit);
assert.equal(hit.alias, undefined);
});
it("a visitor's search never answers with a private note's alias", () => {
assert.equal(search("Secret", true).some((h) => h.path === "Private.md"), false);
});
});
describe("the completion list's data source", () => {
it("carries every alias with the note it names", () => {
const entries = aliasEntries(false, null);
const ml = entries.filter((e) => e.path === "Notes/Machine Learning.md").map((e) => e.alias);
assert.deepEqual(ml.sort(), ["ML", "machine-learning"]);
// Sorted by alias for a stable popup — and WITHIN one alias, claimants in
// RESOLUTION order (fewest segments, shortest, then alphabetical: the
// pickShortest rule), because the completion keeps exactly one row per
// alias and it must be the row `[[alias]]` actually lands on. Plain path
// order could keep a row that names the loser.
const depth = (p: string): number => p.split("/").length;
assert.deepEqual(
[...entries].sort(
(a, b) =>
a.alias.localeCompare(b.alias) ||
depth(a.path) - depth(b.path) ||
a.path.length - b.path.length ||
a.path.localeCompare(b.path),
),
entries,
);
});
it("offers a visitor only what a visitor may discover", () => {
const entries = aliasEntries(true, null);
assert.deepEqual(entries.map((e) => e.alias).sort(), ["Ghost", "Pubby"]);
});
});
describe("a link made through an alias is a backlink like any other", () => {
it("counts, with the same context extraction", () => {
const hits = backlinks("Notes/Machine Learning.md", false, null);
const hit = hits.find((h) => h.path === "Notes/Reading.md");
assert.ok(hit, "the note linking [[ML]] should be a backlink");
assert.match(hit.context, /\[\[ML\]\]/);
assert.match(hit.context, /Today I read about/);
});
});
// The parity block, and the reason this file has one: the EDITOR decides
// whether a link is drawn dashed and where a click goes, the indexer decides
// where the backlink and the graph edge go. An alias the server resolved and
// the client did not is a note sitting right there under a dashed link that
// offers to create a duplicate of it — the original bug, moved one process
// over. The client's table is filled from GET /api/aliases by the same refresh
// that loads the tree.
describe("the client resolver names the same note", () => {
let tree: TreeNode;
before(async () => {
tree = await buildTree();
setAliasTable(aliasEntries(false, null));
});
it("resolves an alias, in each of the three spellings", () => {
assert.equal(clientResolve("ML", tree), "Notes/Machine Learning.md");
assert.equal(clientResolve("deep learning", tree), "Notes/Deep Learning.md");
assert.equal(clientResolve("Bee", tree), "Notes/Blocks.md");
assert.equal(clientResolve("Heat", tree), "papers/Fourier.tex");
});
it("keeps the filename ahead of someone else's alias", () => {
assert.equal(clientResolve("DL", tree), resolveLink("DL"));
assert.equal(clientResolve("DL", tree), "DL.md");
});
it("breaks an alias tie the way the server does", () => {
assert.equal(clientResolve("AI", tree), resolveLink("AI"));
});
it("still answers null for a name nothing claims", () => {
assert.equal(clientResolve("nobody's alias", tree), null);
});
});
// ------------------------------------------------------- incremental upkeep
describe("the alias table follows the vault", () => {
it("a reindexed note loses the aliases it dropped and answers to the new ones", async () => {
const rel = "Notes/Solo.md";
await writeFile(path.join(root, rel), note({ aliases: "[Only, Single]" }, "# Solo\n"), "utf8");
await indexFile(rel);
assert.equal(resolveLink("Alone"), null, "the dropped alias must not survive the reindex");
assert.equal(resolveLink("Only"), rel);
assert.equal(resolveLink("Single"), rel);
});
it("a DELETED note leaves the table", async () => {
// A stale alias that resolves to a file which is no longer there is worse
// than no aliases at all: the link looks live and opens nothing.
const rel = "Notes/Solo.md";
await rm(path.join(root, rel));
await indexFile(rel); // what the watcher's incremental path does
assert.equal(resolveLink("Only"), null);
assert.equal(resolveLink("Single"), null);
assert.equal(aliasEntries(false, null).some((e) => e.path === rel), false);
});
});
// --------------------------------------------------- keeping an old title
describe("addNoteAlias — the write behind “keep the old title as an alias”", () => {
it("adds the key to a note that has frontmatter but no aliases", () => {
const src = "---\ntitle: A\npublish: true\n---\nbody\n";
assert.equal(
addNoteAlias("N.md", src, "Old Title"),
'---\ntitle: A\npublish: true\naliases: ["Old Title"]\n---\nbody\n',
);
});
it("creates a block for a note that has no frontmatter at all", () => {
assert.equal(addNoteAlias("N.md", "# N\n", "Old"), '---\naliases: ["Old"]\n---\n# N\n');
});
it("merges into an inline list, new name first, every other byte untouched", () => {
const src = "---\naliases: [ML, machine-learning]\ntags: [x]\n---\nbody\n";
assert.equal(
addNoteAlias("N.md", src, "Old"),
'---\naliases: ["Old", "ML", "machine-learning"]\ntags: [x]\n---\nbody\n',
);
});
it("adds an ITEM to a block list rather than flattening it", () => {
// Flattening would leave the `- ML` lines orphaned under a key that now
// holds a value: not a note with an odd alias list, a note whose YAML no
// longer parses — and the first thing lost when that happens is
// `publish: true`, i.e. the note silently leaves the public site.
const src = "---\naliases:\n - ML\n - machine-learning\npublish: true\n---\nbody\n";
assert.equal(
addNoteAlias("N.md", src, "Old"),
'---\naliases:\n - "Old"\n - ML\n - machine-learning\npublish: true\n---\nbody\n',
);
});
it("is idempotent, whatever the casing", () => {
const src = '---\naliases: ["Old Title"]\n---\nbody\n';
assert.equal(addNoteAlias("N.md", src, "old title"), src);
assert.equal(addNoteAlias("N.md", src, " "), src);
});
it("writes a `.tex` note's alias into its comment block, which still compiles", () => {
const src = "%---\n% publish: true\n%---%\n\\documentclass{article}\n";
const out = addNoteAlias("P.tex", src, "Old Paper");
assert.equal(out, '%---\n% publish: true\n% aliases: ["Old Paper"]\n%---%\n\\documentclass{article}\n');
// Every line of the block is still a LaTeX comment.
for (const line of out.split("\n").slice(0, 4)) assert.match(line, /^%/);
});
it("adds an item to a `.tex` block list wearing its own comment prefix", () => {
const src = "%---\n% aliases:\n% - Heat\n%---%\n\\documentclass{article}\n";
assert.equal(
addNoteAlias("P.tex", src, "Old"),
'%---\n% aliases:\n% - "Old"\n% - Heat\n%---%\n\\documentclass{article}\n',
);
});
it("the note it wrote is one the indexer can read back", async () => {
const rel = "Notes/Renamed.md";
await writeFile(path.join(root, rel), "---\npublish: true\n---\n# Renamed\n", "utf8");
await indexFile(rel);
const src = "---\npublish: true\n---\n# Renamed\n";
await writeFile(path.join(root, rel), addNoteAlias(rel, src, "Its Old Name"), "utf8");
await indexFile(rel);
assert.equal(resolveLink("Its Old Name"), rel);
assert.equal(resolveLink("Its Old Name", true), rel, "the publish flag survived the write");
});
});
describe("addNoteAlias: the block shapes the old regex missed", () => {
// Each of these is valid YAML the previous implementation mis-filed into the
// flow path, which rewrote the key line and orphaned the items — frontmatter
// that stops parsing, and with it `publish: true`. The assertions check the
// ITEMS survive beside the new one, which is exactly what orphaning breaks.
it("a trailing comment on the key line", () => {
const src = "---\ntitle: A\naliases: # names\n - ML\npublish: true\n---\nbody\n";
const out = addNoteAlias("a.md", src, "DL");
assert.ok(out.includes(" - \"DL\"\n") || out.includes(" - DL\n"), out);
assert.ok(out.includes(" - ML"), "the existing item was orphaned or lost");
assert.ok(out.includes("publish: true"), "publish line lost");
});
it("a blank line between the key and its items", () => {
const src = "---\naliases:\n\n - ML\npublish: true\n---\nbody\n";
const out = addNoteAlias("a.md", src, "DL");
assert.ok(out.includes("- ML"), "the existing item was orphaned");
// However it was inserted, the result must still parse as one list of two.
assert.equal(parseAliases(readNoteFrontmatter("a.md", out)).length, 2, out);
});
it("a comment line between the key and its items", () => {
const src = "---\naliases:\n # old names\n - ML\n---\nbody\n";
const out = addNoteAlias("a.md", src, "DL");
assert.equal(parseAliases(readNoteFrontmatter("a.md", out)).length, 2, out);
});
});