mirror of
https://github.com/vicky469/aside.git
synced 2026-07-22 07:01:57 +00:00
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import * as assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import type { Comment } from "../src/commentManager";
|
|
import { buildIndexFileFilterGraph } from "../src/core/derived/indexFileFilterGraph";
|
|
import {
|
|
buildIndexFileFilterOptions,
|
|
deriveIndexSidebarScopedFilePaths,
|
|
filterCommentsByFilePaths,
|
|
getIndexFileFilterLabel,
|
|
getIndexFileFilterSuggestions,
|
|
isIndexFileFilterPathSelected,
|
|
normalizeIndexFileFilterPaths,
|
|
resolveAutoIndexFileFilterRootPath,
|
|
shouldLimitIndexSidebarList,
|
|
} from "../src/ui/views/indexFileFilter";
|
|
import { getActiveFileFilterPresentation, updateRenderedActiveFileFilters } from "../src/ui/views/sidebarActiveFileFilterDom";
|
|
import { showIndexSidebarListLoadingState } from "../src/ui/views/sidebarIndexLoadingState";
|
|
|
|
class FakeElement {
|
|
public readonly children: FakeElement[] = [];
|
|
public readonly attributes = new Map<string, string>();
|
|
public className = "";
|
|
public textContent = "";
|
|
public readonly ownerDocument = {
|
|
createElement: (tagName: string) => new FakeElement(tagName),
|
|
};
|
|
|
|
constructor(public readonly tagName: string) {}
|
|
|
|
public appendChild(child: FakeElement): FakeElement {
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
public matches(selector: string): boolean {
|
|
return selector
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.some((part) => part.startsWith(".") && this.hasClass(part.slice(1)));
|
|
}
|
|
|
|
public querySelector(selector: string): FakeElement | null {
|
|
if (this.matches(selector)) {
|
|
return this;
|
|
}
|
|
|
|
for (const child of this.children) {
|
|
const match = child.querySelector(selector);
|
|
if (match) {
|
|
return match;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public replaceChildren(...children: FakeElement[]): void {
|
|
this.children.length = 0;
|
|
this.textContent = "";
|
|
this.children.push(...children);
|
|
}
|
|
|
|
public setAttribute(name: string, value: string): void {
|
|
this.attributes.set(name, value);
|
|
}
|
|
|
|
private hasClass(className: string): boolean {
|
|
return this.className.split(/\s+/).includes(className);
|
|
}
|
|
}
|
|
|
|
function assertTypedRippleLoadingElement(loadingEl: FakeElement): void {
|
|
assert.equal(loadingEl.className, "aside-index-list-loading");
|
|
assert.equal(loadingEl.attributes.get("aria-label"), "Loading comments");
|
|
assert.equal(loadingEl.children.length, 5);
|
|
assert.deepEqual(
|
|
loadingEl.children.map((child) => [child.className, child.textContent, child.attributes.get("aria-hidden")]),
|
|
[
|
|
["aside-index-list-loading-dot", ".", "true"],
|
|
["aside-index-list-loading-dot", ".", "true"],
|
|
["aside-index-list-loading-dot", ".", "true"],
|
|
["aside-index-list-loading-dot", ".", "true"],
|
|
["aside-index-list-loading-dot", ".", "true"],
|
|
],
|
|
);
|
|
}
|
|
|
|
function createComment(overrides: Partial<Comment> = {}): Comment {
|
|
return {
|
|
id: overrides.id ?? "comment-1",
|
|
filePath: overrides.filePath ?? "docs/architecture.md",
|
|
startLine: overrides.startLine ?? 5,
|
|
startChar: overrides.startChar ?? 1,
|
|
endLine: overrides.endLine ?? 5,
|
|
endChar: overrides.endChar ?? 8,
|
|
selectedText: overrides.selectedText ?? "comment",
|
|
selectedTextHash: overrides.selectedTextHash ?? "hash:comment",
|
|
comment: overrides.comment ?? "Comment body",
|
|
timestamp: overrides.timestamp ?? 100,
|
|
anchorKind: overrides.anchorKind ?? "selection",
|
|
orphaned: overrides.orphaned ?? false,
|
|
};
|
|
}
|
|
|
|
test("normalizeIndexFileFilterPaths deduplicates, trims, and sorts file paths", () => {
|
|
assert.deepEqual(
|
|
normalizeIndexFileFilterPaths([" docs/b.md ", "docs/a.md", "docs/b.md", ""]),
|
|
["docs/a.md", "docs/b.md"],
|
|
);
|
|
});
|
|
|
|
test("resolveAutoIndexFileFilterRootPath preserves explicit roots and otherwise selects the first index file", () => {
|
|
assert.equal(
|
|
resolveAutoIndexFileFilterRootPath({
|
|
currentRootPath: " docs/current.md ",
|
|
firstIndexFilePath: "docs/first.md",
|
|
autoSelectSuppressed: false,
|
|
}),
|
|
"docs/current.md",
|
|
);
|
|
assert.equal(
|
|
resolveAutoIndexFileFilterRootPath({
|
|
currentRootPath: null,
|
|
firstIndexFilePath: " docs/first.md ",
|
|
autoSelectSuppressed: false,
|
|
}),
|
|
"docs/first.md",
|
|
);
|
|
assert.equal(
|
|
resolveAutoIndexFileFilterRootPath({
|
|
currentRootPath: null,
|
|
firstIndexFilePath: "docs/first.md",
|
|
autoSelectSuppressed: true,
|
|
}),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
resolveAutoIndexFileFilterRootPath({
|
|
currentRootPath: null,
|
|
firstIndexFilePath: "",
|
|
autoSelectSuppressed: false,
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("isIndexFileFilterPathSelected matches normalized selected file paths", () => {
|
|
assert.equal(isIndexFileFilterPathSelected("docs/a.md", " docs/a.md "), true);
|
|
assert.equal(isIndexFileFilterPathSelected("docs/a.md", "docs/b.md"), false);
|
|
assert.equal(isIndexFileFilterPathSelected("docs/a.md", null), false);
|
|
});
|
|
|
|
test("buildIndexFileFilterOptions counts comments per file and sorts by file name", () => {
|
|
const options = buildIndexFileFilterOptions([
|
|
createComment({ filePath: "notes/zeta.md" }),
|
|
createComment({ filePath: "notes/alpha.md" }),
|
|
createComment({ filePath: "other/alpha.md" }),
|
|
createComment({ filePath: "notes/zeta.md" }),
|
|
]);
|
|
|
|
assert.deepEqual(options, [
|
|
{ filePath: "notes/alpha.md", commentCount: 1 },
|
|
{ filePath: "other/alpha.md", commentCount: 1 },
|
|
{ filePath: "notes/zeta.md", commentCount: 2 },
|
|
]);
|
|
});
|
|
|
|
test("filterCommentsByFilePaths returns only matching comment files", () => {
|
|
const comments = [
|
|
createComment({ id: "a", filePath: "docs/a.md" }),
|
|
createComment({ id: "b", filePath: "docs/b.md" }),
|
|
];
|
|
|
|
assert.deepEqual(
|
|
filterCommentsByFilePaths(comments, ["docs/b.md"]).map((comment) => comment.id),
|
|
["b"],
|
|
);
|
|
assert.deepEqual(
|
|
filterCommentsByFilePaths(comments, []).map((comment) => comment.id),
|
|
["a", "b"],
|
|
);
|
|
});
|
|
|
|
test("deriveIndexSidebarScopedFilePaths returns the selected root connected component", () => {
|
|
const graph = buildIndexFileFilterGraph([
|
|
createComment({ id: "a", filePath: "docs/a.md", comment: "[[B]]" }),
|
|
createComment({ id: "b", filePath: "docs/b.md", comment: "" }),
|
|
createComment({ id: "c", filePath: "docs/c.md", comment: "" }),
|
|
], {
|
|
resolveWikiLinkPath: (linkPath) => ({
|
|
B: "docs/b.md",
|
|
}[linkPath] ?? null),
|
|
});
|
|
|
|
assert.deepEqual(
|
|
deriveIndexSidebarScopedFilePaths(graph, "docs/a.md"),
|
|
["docs/a.md", "docs/b.md"],
|
|
);
|
|
});
|
|
|
|
test("deriveIndexSidebarScopedFilePaths returns an empty scope for missing roots", () => {
|
|
const graph = buildIndexFileFilterGraph([
|
|
createComment({ id: "a", filePath: "docs/a.md", comment: "[[B]]" }),
|
|
createComment({ id: "b", filePath: "docs/b.md", comment: "" }),
|
|
], {
|
|
resolveWikiLinkPath: (linkPath) => ({
|
|
B: "docs/b.md",
|
|
}[linkPath] ?? null),
|
|
});
|
|
|
|
assert.deepEqual(
|
|
deriveIndexSidebarScopedFilePaths(graph, "docs/missing.md"),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("shouldLimitIndexSidebarList applies the cap only when no root scope or search is active", () => {
|
|
assert.equal(shouldLimitIndexSidebarList(null), true);
|
|
assert.equal(shouldLimitIndexSidebarList("docs/a.md"), false);
|
|
assert.equal(shouldLimitIndexSidebarList(null, "design"), false);
|
|
});
|
|
|
|
test("getIndexFileFilterSuggestions keeps selected files visible and orders them first", () => {
|
|
const options = [
|
|
{ filePath: "notes/alpha.md", commentCount: 1 },
|
|
{ filePath: "archive/alpha.md", commentCount: 3 },
|
|
{ filePath: "notes/beta.md", commentCount: 2 },
|
|
];
|
|
|
|
assert.deepEqual(
|
|
getIndexFileFilterSuggestions(options, "alp", ["notes/alpha.md"]).map((option) => option.filePath),
|
|
["notes/alpha.md", "archive/alpha.md"],
|
|
);
|
|
assert.deepEqual(
|
|
getIndexFileFilterSuggestions(options, "", ["notes/beta.md"]).map((option) => option.filePath),
|
|
["notes/beta.md", "archive/alpha.md", "notes/alpha.md"],
|
|
);
|
|
});
|
|
|
|
test("getIndexFileFilterLabel falls back to full path when selected basenames collide", () => {
|
|
assert.equal(
|
|
getIndexFileFilterLabel("notes/alpha.md", ["notes/alpha.md", "archive/alpha.md"]),
|
|
"notes/alpha.md",
|
|
);
|
|
assert.equal(
|
|
getIndexFileFilterLabel("notes/beta.md", ["notes/beta.md", "archive/alpha.md"]),
|
|
"beta",
|
|
);
|
|
});
|
|
|
|
test("updateRenderedActiveFileFilters replaces the visible filter chip immediately", () => {
|
|
const labelEl = { textContent: "old" };
|
|
const summaryEl = { textContent: "old summary" };
|
|
const clearButton = {
|
|
attributes: new Map<string, string>(),
|
|
setAttribute(name: string, value: string) {
|
|
this.attributes.set(name, value);
|
|
},
|
|
};
|
|
const container = {
|
|
querySelector(selector: string) {
|
|
if (selector === ".aside-active-file-filter-label") {
|
|
return labelEl;
|
|
}
|
|
if (selector === ".aside-active-file-filter-summary") {
|
|
return summaryEl;
|
|
}
|
|
if (selector === ".aside-active-file-filter-clear") {
|
|
return clearButton;
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const updated = updateRenderedActiveFileFilters(container as unknown as HTMLElement, {
|
|
rootFilePath: "test.md",
|
|
filteredIndexFilePaths: ["test.md"],
|
|
});
|
|
|
|
assert.equal(updated, true);
|
|
assert.equal(labelEl.textContent, "test");
|
|
assert.equal(summaryEl.textContent, "1 file");
|
|
assert.equal(clearButton.attributes.get("aria-label"), "Clear file filter for test.md");
|
|
});
|
|
|
|
test("getActiveFileFilterPresentation can suppress linked-file summary", () => {
|
|
const presentation = getActiveFileFilterPresentation({
|
|
rootFilePath: "docs/a.md",
|
|
filteredIndexFilePaths: [
|
|
"docs/a.md",
|
|
"docs/b.md",
|
|
"docs/c.md",
|
|
"docs/d.md",
|
|
"docs/e.md",
|
|
],
|
|
showSummary: false,
|
|
});
|
|
|
|
assert.equal(presentation.label, "a");
|
|
assert.equal(presentation.summary, null);
|
|
assert.equal(presentation.clearAriaLabel, "Clear file filter for docs/a.md");
|
|
});
|
|
|
|
test("showIndexSidebarListLoadingState clears stale comments and inserts typed ripple loader", () => {
|
|
const root = new FakeElement("div");
|
|
const list = new FakeElement("div");
|
|
list.className = "aside-comments-list";
|
|
const staleComment = new FakeElement("article");
|
|
staleComment.className = "aside-comment-item";
|
|
staleComment.textContent = "stale comment";
|
|
list.appendChild(staleComment);
|
|
root.appendChild(list);
|
|
|
|
const rendered = showIndexSidebarListLoadingState(root as unknown as HTMLElement);
|
|
|
|
assert.equal(rendered, true);
|
|
assert.equal(list.children.length, 1);
|
|
assertTypedRippleLoadingElement(list.children[0]);
|
|
});
|
|
|
|
test("showIndexSidebarListLoadingState replaces non-list index content with a loading list", () => {
|
|
const root = new FakeElement("div");
|
|
const container = new FakeElement("div");
|
|
container.className = "aside-comments-container";
|
|
const toolbar = new FakeElement("div");
|
|
toolbar.className = "aside-sidebar-toolbar";
|
|
toolbar.textContent = "toolbar";
|
|
const activeFilter = new FakeElement("div");
|
|
activeFilter.className = "aside-active-file-filters";
|
|
activeFilter.textContent = "filter";
|
|
const thoughtTrail = new FakeElement("div");
|
|
thoughtTrail.className = "aside-thought-trail";
|
|
thoughtTrail.textContent = "old trail";
|
|
container.appendChild(toolbar);
|
|
container.appendChild(activeFilter);
|
|
container.appendChild(thoughtTrail);
|
|
root.appendChild(container);
|
|
|
|
const rendered = showIndexSidebarListLoadingState(root as unknown as HTMLElement);
|
|
|
|
assert.equal(rendered, true);
|
|
assert.deepEqual(container.children.map((child) => child.className), [
|
|
"aside-sidebar-toolbar",
|
|
"aside-active-file-filters",
|
|
"aside-comments-list",
|
|
]);
|
|
assertTypedRippleLoadingElement(container.children[2].children[0]);
|
|
});
|