insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
268 lines (212 loc) • 10.1 kB
text/typescript
import type { CachedMetadata, Editor, HeadingCache, Pos, SectionCache } from "obsidian";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { injectGlobals } from "../src/globals/globalFuncs";
import {
// getLocalSettingsBulletTypeSuggestions,
getLocalSettingsOmitSuggestions
} from "../src/settings/localSettingsCompletionOptions";
import { Validator } from "../src/validator";
import { createEditor, createPluginMock } from "./mocks/pluginClassMocks";
injectGlobals();
function createHeading(heading: string, level: number, line: number): HeadingCache {
const position: Pos = {
start: { line, col: 0, offset: 0 },
end: { line, col: heading.length, offset: heading.length }
};
return { heading, level, position } as HeadingCache;
}
function createCodeSection(closingFenceLine: number): SectionCache {
return {
type: "code",
position: { start: { line: 0, col: 0, offset: 0 }, end: { line: closingFenceLine, col: 3, offset: 0 } }
} as SectionCache;
}
type ValidatorFixture = { metadata: CachedMetadata; editor: Editor; filePath: string; };
function createFixture(
localYamlLines: string[],
headings?: HeadingCache[],
filePath = "folder/file-a.md"
): ValidatorFixture {
const lines: string[] = [
"```insta-toc",
"---",
...localYamlLines,
"---",
"",
"# Table of Contents",
"",
"- Existing",
"```",
"",
"# Heading 1",
"## Heading 2"
];
const closingFenceLine = lines.indexOf("```", 1);
const section = createCodeSection(closingFenceLine);
const metadata: CachedMetadata = { sections: [ section ], headings };
return { metadata, editor: createEditor(lines), filePath };
}
function getHeadingExcludePattern(validator: Validator): RegExp | undefined {
const getter = Reflect.get(Validator.prototype as object, "getHeadingExcludePattern") as (
this: Validator
) => RegExp | undefined;
return getter.call(validator);
}
describe("Validator local settings behavior", () => {
beforeEach(() => {
vi.stubGlobal("window", {
app: {
plugins: {
getPlugin: vi.fn().mockReturnValue({
settings: { tocTitle: "Table of Contents", bulletType: "dash" }
})
}
}
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("re-validates when local settings change but headings do not", () => {
const stableHeadings: HeadingCache[] = [ createHeading("Heading 1", 1, 10), createHeading("Heading 2", 2, 11) ];
const initial = createFixture([ "title:", " name: Table of Contents" ], stableHeadings);
const { plugin, setEditor } = createPluginMock(undefined, initial.editor);
const validator = new Validator(plugin.editorService, plugin.settings, initial.metadata, initial.filePath);
expect(validator.isValid()).toBe(true);
expect(validator.fileHeadings.map((h) => h.heading)).toEqual([ "Heading 1", "Heading 2" ]);
const updatedLocalConfigOnly = createFixture(
[ "levels:", " min: 2", " max: 6", "omit:", " - Heading 2" ],
stableHeadings
);
setEditor(updatedLocalConfigOnly.editor);
validator.update(
plugin.editorService,
plugin.settings,
updatedLocalConfigOnly.metadata,
updatedLocalConfigOnly.filePath
);
expect(validator.isValid()).toBe(true);
expect(validator.localTocSettings.levels.min).toBe(2);
expect(validator.fileHeadings).toEqual([]);
});
test("returns false when neither headings nor local config changed", () => {
const headings: HeadingCache[] = [ createHeading("Heading 1", 1, 10), createHeading("Heading 2", 2, 11) ];
const fixture = createFixture([ "title:", " name: Table of Contents" ], headings);
const { plugin } = createPluginMock(undefined, fixture.editor);
const validator = new Validator(plugin.editorService, plugin.settings, fixture.metadata, fixture.filePath);
expect(validator.isValid()).toBe(true);
validator.update(plugin.editorService, plugin.settings, fixture.metadata, fixture.filePath);
expect(validator.isValid()).toBe(false);
});
test("re-validates when forceRefresh is true even without content changes", () => {
const fixture = createFixture([ "title:", " name: Table of Contents" ], [
createHeading("Heading 1", 1, 10),
createHeading("Heading 2", 2, 11)
]);
const { plugin } = createPluginMock(undefined, fixture.editor);
const validator = new Validator(plugin.editorService, plugin.settings, fixture.metadata, fixture.filePath);
expect(validator.isValid()).toBe(true);
expect(validator.isValid(true)).toBe(true);
});
test("handles missing heading cache safely", () => {
const noHeadingsFixture = createFixture([ "title:", " name: Table of Contents" ], undefined);
const { plugin } = createPluginMock(undefined, noHeadingsFixture.editor);
const validator = new Validator(
plugin.editorService,
plugin.settings,
noHeadingsFixture.metadata,
noHeadingsFixture.filePath
);
expect(validator.isValid()).toBe(true);
expect(validator.fileHeadings).toEqual([]);
});
test("resets local settings to global defaults when switching files", () => {
const headings: HeadingCache[] = [ createHeading("Heading 1", 1, 10), createHeading("Heading 2", 2, 11) ];
const firstFile = createFixture(
[ "levels:", " min: 2", " max: 6", "omit:", " - Heading 2" ],
headings,
"folder/file-a.md"
);
const { plugin, setEditor } = createPluginMock(undefined, firstFile.editor);
const validator = new Validator(plugin.editorService, plugin.settings, firstFile.metadata, firstFile.filePath);
expect(validator.isValid()).toBe(true);
expect(validator.localTocSettings.levels.min).toBe(2);
expect(validator.fileHeadings).toEqual([]);
const secondFile = createFixture([], headings, "folder/file-b.md");
setEditor(secondFile.editor);
validator.update(plugin.editorService, plugin.settings, secondFile.metadata, secondFile.filePath);
expect(validator.isValid()).toBe(true);
expect(validator.localTocSettings.levels.min).toBeNull();
expect(validator.localTocSettings.omit).toBeNull();
expect(validator.fileHeadings.map((heading) => heading.heading)).toEqual([ "Heading 1", "Heading 2" ]);
});
test("restores previous local settings when applying invalid yaml", () => {
const fixture = createFixture([ "levels:", " min: 2", " max: 6" ], [
createHeading("Heading 1", 1, 10),
createHeading("Heading 2", 2, 11)
]);
const { plugin } = createPluginMock(undefined, fixture.editor);
const validator = new Validator(plugin.editorService, plugin.settings, fixture.metadata, fixture.filePath);
expect(validator.isValid()).toBe(true);
expect(validator.localTocSettings.levels.min).toBe(2);
const didApply = validator.applyLocalSettingsYaml("levels:\n min: 9");
expect(didApply).toBe(false);
expect(validator.localTocSettings.levels.min).toBe(2);
expect(validator.localTocSettings.levels.max).toBe(6);
});
test("reuses the compiled heading exclusion regex until exclusion inputs change", () => {
// Arrange
const fixture = createFixture([], [ createHeading("Heading 1", 1, 10), createHeading("Heading 2", 2, 11) ]);
const { plugin } = createPluginMock({ excludedChars: [ "*" ] }, fixture.editor);
const validator = new Validator(plugin.editorService, plugin.settings, fixture.metadata, fixture.filePath);
expect(validator.isValid()).toBe(true);
// Act
const firstPattern = getHeadingExcludePattern(validator);
const secondPattern = getHeadingExcludePattern(validator);
validator.applyLocalSettingsYaml("exclude: _");
const localChangePattern = getHeadingExcludePattern(validator);
validator.update(
plugin.editorService,
{ ...plugin.settings, excludedChars: [ "*", "#" ] },
fixture.metadata,
fixture.filePath
);
const globalChangePattern = getHeadingExcludePattern(validator);
// Assert
expect(firstPattern).toBeInstanceOf(RegExp);
expect(secondPattern).toBe(firstPattern);
expect(localChangePattern).toBeInstanceOf(RegExp);
expect(localChangePattern).not.toBe(firstPattern);
expect(globalChangePattern).toBeInstanceOf(RegExp);
expect(globalChangePattern).not.toBe(localChangePattern);
});
// test("provides list type suggestions for local settings completions", () => {
// expect(getLocalSettingsBulletTypeSuggestions()).toEqual([
// "none",
// "armenian",
// "georgian",
// "lower-greek",
// "lower-latin",
// "upper-latin",
// "disc",
// "circle",
// "square",
// "decimal",
// "decimal-leading-zero",
// "lower-alpha",
// "upper-alpha",
// "lower-roman",
// "upper-roman"
// ]);
// });
test("builds omit suggestions from heading cache entries", () => {
const headings: HeadingCache[] = [
createHeading(" Heading 1 ", 1, 10),
createHeading("Heading 1", 2, 11),
createHeading("", 3, 12),
createHeading("Heading 2 <!-- omit -->", 4, 13),
createHeading("Heading 3", 5, 14)
];
expect(getLocalSettingsOmitSuggestions(headings)).toEqual([ "Heading 1", "Heading 3" ]);
});
});