From 974bd2b3aa46aa8cd9d66eeaae9fb81e472a3be9 Mon Sep 17 00:00:00 2001 From: banisterious Date: Tue, 12 May 2026 18:44:28 -0700 Subject: [PATCH] test: add vitest infrastructure and Phase 4 characterization suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 134 tests across six files lock the current behavior of the three count entry points and their support code, giving Phase 5's planned single-file split a load-bearing safety net. Files: - vitest.config.ts: node environment; resolve.extensions prefers .ts so imports resolve to main.ts source (not the main.js build artifact); alias redirects 'obsidian' to the test mock. - tests/mocks/obsidian.ts: minimal no-op stubs (App, Plugin, Modal, Setting, etc.) so main.ts's imports resolve in tests. - tests/fixtures/settings.ts: makeSettings(overrides) helper for spreading DEFAULT_SETTINGS with per-test overrides. - tests/counting/words.test.ts (37 tests): baseline, code/comment/link/ heading/words-and-phrases/extension/path exclusion modes, advanced regex, combination cases. - tests/counting/characters.test.ts (18 tests): all/no-spaces/letters-only modes plus the exclusion matrix. - tests/counting/sentences.test.ts (21 tests): baseline, abbreviation / decimal / URL / file-path / file-extension / ellipsis guards, and settings-driven exclusions. - tests/counting/text.test.ts (5 tests): countSelectedText aggregator. - tests/exclusions/overrides.test.ts (18 tests): the four override mechanisms (frontmatter array, string, "all" expansion, inline comment markers in both HTML and Obsidian styles) plus the disabledExclusions parameter. - tests/exclusions/processors.test.ts (35 tests): direct unit tests for the individual process* helpers. Three pre-existing quirks surfaced during test-writing and are locked in by 'LOCKED quirk:' tests so a later phase can decide whether to fix each: - Path-exclusion buffer is greedy (a detected path swallows trailing words to end of input). - Sentence-detection abbreviation guard never fires (the period is consumed by the split regex before the guard runs). - Sentence-detection file-extension guard discards entire sentences ending in name.ext rather than just the in-filename period. Details in docs/planning/audit-phase-4-tests.md § Findings. --- docs/planning/audit-phase-4-tests.md | 110 ++++++++++ tests/counting/characters.test.ts | 129 +++++++++++ tests/counting/sentences.test.ts | 125 +++++++++++ tests/counting/text.test.ts | 37 ++++ tests/counting/words.test.ts | 317 +++++++++++++++++++++++++++ tests/exclusions/overrides.test.ts | 200 +++++++++++++++++ tests/exclusions/processors.test.ts | 192 ++++++++++++++++ tests/fixtures/settings.ts | 10 + tests/mocks/obsidian.ts | 81 +++++++ vitest.config.ts | 24 ++ 10 files changed, 1225 insertions(+) create mode 100644 docs/planning/audit-phase-4-tests.md create mode 100644 tests/counting/characters.test.ts create mode 100644 tests/counting/sentences.test.ts create mode 100644 tests/counting/text.test.ts create mode 100644 tests/counting/words.test.ts create mode 100644 tests/exclusions/overrides.test.ts create mode 100644 tests/exclusions/processors.test.ts create mode 100644 tests/fixtures/settings.ts create mode 100644 tests/mocks/obsidian.ts create mode 100644 vitest.config.ts diff --git a/docs/planning/audit-phase-4-tests.md b/docs/planning/audit-phase-4-tests.md new file mode 100644 index 0000000..116d0ab --- /dev/null +++ b/docs/planning/audit-phase-4-tests.md @@ -0,0 +1,110 @@ +# Audit Phase 4 — Test infrastructure + +**Status:** Active +**Branch:** `audit/phase-4-tests` +**Release target:** 1.6.7 (CI pipeline now runs a Test step between Lint and Build; the release verifies CI is green with vitest installed) + +This phase lands vitest plus a characterization test suite over the three count entry points. The tests are the load-bearing safety net for Phase 5's single-file split: any regression Phase 5 introduces will surface in CI before merge. + +The audit plan's Phase 4 brief calls for "no production-code changes." We deviate on one point: the count functions and processing helpers in `main.ts` are top-level `function` declarations (not exported), and the tests need to import them. We add `export` keywords to the ~15 functions involved. The change is a no-op at runtime and at the bundler level; Phase 5 was going to do the same thing when it pulled these into `src/`. Recorded here so the deviation is explicit. + +--- + +## Scope + +### Tooling + +- `vitest` as devDependency (covers test runner, transformer via esbuild, assertion library). +- `vitest.config.ts` configured for `node` environment with the `obsidian` module aliased to a test mock. +- `npm test` and `npm run test:watch` scripts in `package.json`. +- A `Test` step in `.github/workflows/release.yml` between `Lint (eslint)` and `Build`. CI then refuses to ship a release if `npm test` fails. + +### `obsidian` module mock + +`tests/mocks/obsidian.ts` exports the minimal surface the counting code touches: + +- `App` — empty class. Tests pass a hand-built `App`-shaped object when calling `getDisabledExclusionsFromFrontmatter`. +- `MarkdownView`, `Modal`, `Plugin`, `PluginSettingTab`, `Setting`, `Notice`, `setIcon`, `Platform` — no-op stubs so `import` resolves; the count entry points don't touch them. + +The count functions are deliberately written to operate on plain strings plus a `settings` object plus an optional `disabledExclusions` array. None of them need the Obsidian runtime to be tested. + +### Test files + +- `tests/counting/words.test.ts` — `countSelectedWords` characterization. Each exclusion mode independently (code blocks, inline code, Obsidian comments, HTML comments, links, headings, words/phrases), a handful of combination cases, smart-quote contractions, decimal numbers, file-extension exclusion, path exclusion. +- `tests/counting/characters.test.ts` — `countSelectedCharacters` in all three modes (`all`, `no-spaces`, `letters-only`) and against the same exclusion matrix. +- `tests/counting/sentences.test.ts` — `countSelectedSentences` including abbreviations (`Mr.`, `Dr.`, `etc.`), decimal numbers, file extensions, URLs, multiple punctuation, ellipsis. +- `tests/counting/text.test.ts` — `countSelectedText` aggregator returning `{ words, characters, sentences }`. +- `tests/exclusions/overrides.test.ts` — the four override mechanisms: frontmatter array, frontmatter string, frontmatter `all`, inline `` and `%% cswc-disable %%` markers. +- `tests/exclusions/processors.test.ts` — direct tests for the individual processing functions: `stripFrontmatter`, `processCodeBlocks`, `processInlineCode`, `processObsidianComments`, `processHtmlComments`, `processLinks`, `processHeadings`, `processSelectiveHeadingSections`, `processWordsAndPhrases`, `processTextWithOverrides`. + +### Test count target + +50-100 tests, per the audit plan's brief. Enough to lock current behavior; not so many that maintenance becomes its own burden. + +--- + +## What we deliberately do NOT test + +- The modal, the settings tab, the status bar, the Canvas polling loop, the editor selection handlers. Those need Obsidian's runtime. UI verification stays manual. +- The history persistence shape. That's plugin-state plumbing; testing it would require instantiating the plugin against a mocked Obsidian, which the brief explicitly avoids. +- The log-export function. Diagnostic-only output; not behavior we need to freeze. + +If a test reveals a bug while writing characterization tests, **log it for a later phase**. The point of Phase 4 is to capture current behavior, including any quirks. Phase 5 (or beyond) fixes the bugs; Phase 4 just nails behavior to the wall. + +--- + +## Acceptance + +- `npm test` runs the full suite locally with zero failures against the current `main.ts`. +- The CI release workflow runs `npm test` between Lint and Build; tagging `1.6.7-rc1` produces a green draft release. +- The Obsidian module mock surface is documented inline in `tests/mocks/obsidian.ts`. +- Any quirks discovered during test-writing are captured at the bottom of this doc under "Findings." + +--- + +## Findings during test-writing + +Three behavioral quirks surfaced while writing characterization tests. All are locked-in by the test suite as current behavior; the tests use the prefix `LOCKED quirk:` so they're easy to find when a later phase decides to fix any of them. + +### 1. Path-exclusion is greedy + +`countSelectedWords` builds a buffer when a segment matches a path-start regex (Windows drive, Unix, UNC, env var). It re-checks `looksLikePath(buffer)` after appending each subsequent segment and only stops extending the buffer when the check fails. But the path regexes are `^pattern` — they only require the buffer to *start* with the pattern. Appending `" word"` to a path doesn't break the prefix match, so the buffer keeps growing and swallows every word to end of input. + +Example: `"Open C:\Users\foo and resume"` with `excludePaths: true, excludeWindowsPaths: true` returns 1 (only "Open" survives), not 3 ("Open", "and", "resume" as a naive reader would expect). + +**Fix candidate (later phase):** terminate path-consumption at the first segment that isn't itself a path continuation (e.g., contains no `/`, `\`, or path-character pattern), or anchor the regex with `$` so the buffer must match end-to-end as a path. + +Locked by tests in `tests/counting/words.test.ts > countSelectedWords — path exclusion`. + +### 2. Sentence-detection abbreviation guard never fires + +`countSelectedSentences` splits text on `/[.!?]+(?:\s*["'])?(?:\s+|$)/g`, then for each part checks against `/\b(?:Mr|Mrs|Dr|...|etc|...)\./i` to skip parts ending with a known abbreviation. The guard requires a literal period (`\.`) in the part, but the split regex has already consumed that period. The guard never matches on real input, so `"Mr."`, `"Dr."`, `"etc."` all produce false sentence splits today. + +Example: `"Mr. Smith and Dr. Jones met today."` returns 3, not 1. + +**Fix candidate (later phase):** test against the part *plus* the matched delimiter (would require switching from `.split` to a `.matchAll`-based approach), or rewrite the guard to detect parts that end with `\b(?:Mr|...)$` (matching the abbreviation without the period, since the split already removed it). + +Locked by tests in `tests/counting/sentences.test.ts > countSelectedSentences — false-positive guards`. + +### 3. File-extension guard discards the enclosing sentence + +The same loop has a file-extension guard: `if (/\w+\.\w+$/.test(part.trim()) && part.trim().length < 20) continue;`. The intent is to skip the period inside `file.txt` so it doesn't double-count. But because the test fires on a part that *ends* with `\w+\.\w+`, the entire sentence containing the filename gets thrown out, not just the period. + +Example: `"Read the file.txt. It contains data."` returns 1 (only "It contains data." survives), not 2. The "Read the file.txt." sentence is silently dropped. + +**Fix candidate (later phase):** restrict the guard to parts that *are* a filename (e.g., exactly `\w+\.\w+`), not parts that merely end with one. Or pre-replace filenames with a placeholder before splitting, like URLs and Windows paths already are. + +Locked by tests in `tests/counting/sentences.test.ts > countSelectedSentences — false-positive guards`. + +### 4. Redundant URL/path stripping in `countSelectedSentences` + +The audit plan's Phase 1 brief asks: "verify whether [the second URL-and-path stripping pass after the override-processing wrapper] is intentional or a vestige." Confirmed: it's redundant in the common path. `countSelectedSentences` calls `processLinks` inside `processTextWithOverrides` (which already strips URLs that have markdown link syntax), then after the wrapper runs an independent regex pass `/https?:\/\/[^\s]+/g` and `/[a-zA-Z]:[\\/][^\s]+/g`. The second pass catches bare URLs and paths that aren't wrapped in `[text](url)` markdown syntax, so it's not strictly redundant — it's a safety net for raw URLs and paths in prose. Worth keeping until Phase 5's `applyExclusions` extraction decides on a uniform approach. + +Not locked by a dedicated test, but tested implicitly through the "does not split on URLs" and "does not split on Windows file paths" cases. + +--- + +## Deviations from audit plan + +- **`export` keywords added to ~15 functions in `main.ts`.** Required for test imports; no runtime impact. Phase 5's module split was going to do this anyway. +- **Release target raised from "none" to 1.6.7.** The audit plan said no release for Phase 4 since it's developer-only. We're shipping because the CI workflow itself changes (the new `Test` step), and a tagged release proves the CI is green with the new step in place. The user-visible CHANGELOG line is "Internal: added vitest test infrastructure"; no user-facing behavior changes. diff --git a/tests/counting/characters.test.ts b/tests/counting/characters.test.ts new file mode 100644 index 0000000..c3e7f4b --- /dev/null +++ b/tests/counting/characters.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { countSelectedCharacters } from '../../main'; +import { makeSettings } from '../fixtures/settings'; + +describe('countSelectedCharacters — mode: all', () => { + it('returns 0 for empty string', () => { + expect(countSelectedCharacters('', 'all')).toBe(0); + }); + + it('counts every character including spaces and punctuation', () => { + expect(countSelectedCharacters('Hi, world!', 'all')).toBe(10); + }); + + it('counts newlines and tabs as characters', () => { + expect(countSelectedCharacters('a\nb\tc', 'all')).toBe(5); + }); + + it('counts unicode characters as single units', () => { + // Smart quotes are single code units in the BMP + expect(countSelectedCharacters('don’t', 'all')).toBe(5); + }); +}); + +describe('countSelectedCharacters — mode: no-spaces', () => { + it('excludes spaces, tabs, and newlines', () => { + expect(countSelectedCharacters('a b\tc\nd', 'no-spaces')).toBe(4); + }); + + it('keeps punctuation', () => { + expect(countSelectedCharacters('hi, world!', 'no-spaces')).toBe(9); + }); +}); + +describe('countSelectedCharacters — mode: letters-only', () => { + it('counts only A-Z and a-z characters', () => { + expect(countSelectedCharacters('Hi, world!', 'letters-only')).toBe(7); + }); + + it('excludes digits', () => { + expect(countSelectedCharacters('abc123', 'letters-only')).toBe(3); + }); + + it('excludes all whitespace, punctuation, and symbols', () => { + expect(countSelectedCharacters('a! b? c. d, e', 'letters-only')).toBe(5); + }); + + it('returns 0 when no letters are present', () => { + expect(countSelectedCharacters('123!@# 456', 'letters-only')).toBe(0); + }); +}); + +describe('countSelectedCharacters — exclusions', () => { + it('excludes code blocks when enabled', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + }); + const text = 'AB\n```\nXYZ\n```\nCD'; + // Removed: "```\nXYZ\n```" → remaining: "AB\n\nCD" → 6 chars + expect(countSelectedCharacters(text, 'all', settings)).toBe(6); + }); + + it('excludes inline code when enabled', () => { + const settings = makeSettings({ + excludeCode: true, + excludeInlineCode: true, + }); + // "use `x` here" → "use here" → 9 chars + expect(countSelectedCharacters('use `x` here', 'all', settings)).toBe(9); + }); + + it('excludes Obsidian comment content', () => { + const settings = makeSettings({ + excludeComments: true, + excludeObsidianComments: true, + excludeObsidianCommentContent: true, + }); + // "AB %%xy%% CD" → "AB CD" → 6 + expect(countSelectedCharacters('AB %%xy%% CD', 'all', settings)).toBe(6); + }); + + it('excludes HTML comment content', () => { + const settings = makeSettings({ + excludeComments: true, + excludeHtmlComments: true, + excludeHtmlCommentContent: true, + }); + // "AB CD" → "AB CD" → 6 + expect(countSelectedCharacters('AB CD', 'all', settings)).toBe(6); + }); + + it('excludes link non-visible portions', () => { + const settings = makeSettings({ + excludeNonVisibleLinkPortions: true, + }); + // "[[Note|alias]]" → "alias" → 5 + expect(countSelectedCharacters('[[Note|alias]]', 'all', settings)).toBe(5); + }); + + it('excludes entire heading lines', () => { + const settings = makeSettings({ + excludeHeadings: true, + excludeEntireHeadingLines: true, + }); + // "# Title\nbody" → "\nbody" → 5 chars (newline + body) + expect(countSelectedCharacters('# Title\nbody', 'all', settings)).toBe(5); + }); + + it('excludes words/phrases', () => { + const settings = makeSettings({ + excludeWordsAndPhrases: true, + excludedWords: 'foo', + }); + // "foo bar foo" → " bar " → 5 + expect(countSelectedCharacters('foo bar foo', 'all', settings)).toBe(5); + }); + + it('combines code and comment exclusions', () => { + const settings = makeSettings({ + excludeCode: true, + excludeInlineCode: true, + excludeComments: true, + excludeObsidianComments: true, + excludeObsidianCommentContent: true, + }); + // "X `c` %%h%% Y" → "X Y" → 5 + expect(countSelectedCharacters('X `c` %%h%% Y', 'all', settings)).toBe(5); + }); +}); diff --git a/tests/counting/sentences.test.ts b/tests/counting/sentences.test.ts new file mode 100644 index 0000000..830b18c --- /dev/null +++ b/tests/counting/sentences.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { countSelectedSentences } from '../../main'; +import { makeSettings } from '../fixtures/settings'; + +describe('countSelectedSentences — baseline', () => { + it('returns 0 for empty string', () => { + expect(countSelectedSentences('')).toBe(0); + }); + + it('counts a single sentence ending with period', () => { + expect(countSelectedSentences('Hello world.')).toBe(1); + }); + + it('counts two sentences with two periods', () => { + expect(countSelectedSentences('Hello world. Goodbye world.')).toBe(2); + }); + + it('counts question marks as sentence endings', () => { + expect(countSelectedSentences('Are you ok? I am fine.')).toBe(2); + }); + + it('counts exclamation marks as sentence endings', () => { + expect(countSelectedSentences('Watch out! That was close.')).toBe(2); + }); + + it('treats sentence ending followed by quote correctly', () => { + expect(countSelectedSentences('She said "hi." Then she left.')).toBe(2); + }); +}); + +describe('countSelectedSentences — false-positive guards', () => { + // FINDING: the abbreviation guard inside countSelectedSentences uses + // `/\b(?:Mr|Mrs|Dr|...|etc|...)\./i` to skip sentence parts that end + // with an abbreviation. But the regex requires a literal period (`\.`) + // in the part, and the sentence-split regex consumes that period + // *before* the guard runs. The guard never matches on real input, so + // "Mr.", "Dr.", "etc." etc. produce false sentence splits today. Locked + // here as current behavior; a future phase can repair the guard. + + it('LOCKED quirk: splits on "Mr." and "Dr." (abbreviation guard does not fire)', () => { + expect(countSelectedSentences('Mr. Smith and Dr. Jones met today.')).toBe(3); + }); + + it('LOCKED quirk: splits on "etc." (abbreviation guard does not fire)', () => { + expect(countSelectedSentences('We brought apples, oranges, etc. to share.')).toBe(2); + }); + + it('does not split on decimal numbers (no period in inter-digit position)', () => { + expect(countSelectedSentences('Pi is approximately 3.14 today.')).toBe(1); + }); + + it('does not split on URLs (URLs are stripped before sentence detection)', () => { + expect(countSelectedSentences('See https://example.com for details.')).toBe(1); + }); + + it('does not split on Windows file paths (paths are stripped before detection)', () => { + expect(countSelectedSentences('Open C:\\Users\\foo\\file.txt now.')).toBe(1); + }); + + it('LOCKED quirk: file-extension guard suppresses entire sentence ending in "name.ext"', () => { + // "Read the file.txt." → after split, part "Read the file.txt" ends + // with \w+\.\w+ (length < 20) and trips the file-extension guard, + // which throws away the whole sentence instead of just preserving + // the period inside the filename. Net result: 1 sentence, not 2. + expect(countSelectedSentences('Read the file.txt. It contains data.')).toBe(1); + }); + + it('counts ellipsis as separate splits (no special handling)', () => { + expect(countSelectedSentences('Wait... I forgot.')).toBe(2); + }); + + it('requires at least one letter to count as a sentence', () => { + expect(countSelectedSentences('!!!')).toBe(0); + }); + + it('numeric-only fragments do not count', () => { + expect(countSelectedSentences('123. 456.')).toBe(0); + }); +}); + +describe('countSelectedSentences — code and headings', () => { + it('strips fenced code blocks before counting', () => { + const text = 'First sentence.\n```\nconst x = 1.\n```\nSecond sentence.'; + // The inner "const x = 1." should not be counted as a sentence + expect(countSelectedSentences(text)).toBe(2); + }); + + it('strips inline code before counting', () => { + // Inline code with periods inside it shouldn't add sentence endings + expect(countSelectedSentences('Run `npm i.` and you are done.')).toBe(1); + }); + + it('strips ATX heading lines before counting', () => { + // Heading line "# Title." should not be a sentence + expect(countSelectedSentences('# Title.\nBody sentence.')).toBe(1); + }); +}); + +describe('countSelectedSentences — exclusions via settings', () => { + it('settings-based heading exclusion removes heading text entirely', () => { + const settings = makeSettings({ + excludeHeadings: true, + excludeEntireHeadingLines: true, + }); + expect(countSelectedSentences('# Title\nBody. Another.', settings)).toBe(2); + }); + + it('settings-based comment exclusion removes commented content', () => { + const settings = makeSettings({ + excludeComments: true, + excludeObsidianComments: true, + excludeObsidianCommentContent: true, + }); + // "Body. %%Note one. Note two.%% End." → "Body. End." → 2 + expect(countSelectedSentences('Body. %%Note one. Note two.%% End.', settings)).toBe(2); + }); + + it('settings-based word exclusion can remove sentence-letter content', () => { + const settings = makeSettings({ + excludeWordsAndPhrases: true, + excludedWords: 'foo, bar', + }); + expect(countSelectedSentences('foo bar. baz qux.', settings)).toBe(1); + }); +}); diff --git a/tests/counting/text.test.ts b/tests/counting/text.test.ts new file mode 100644 index 0000000..9557a13 --- /dev/null +++ b/tests/counting/text.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { countSelectedText } from '../../main'; +import { makeSettings } from '../fixtures/settings'; + +describe('countSelectedText — aggregator', () => { + it('returns zero counts for empty input', () => { + expect(countSelectedText('')).toEqual({ words: 0, characters: 0, sentences: 0 }); + }); + + it('returns word, character, and sentence counts together', () => { + const result = countSelectedText('Hello world. Goodbye.'); + expect(result).toEqual({ words: 3, characters: 21, sentences: 2 }); + }); + + it('respects characterCountMode from settings', () => { + const settings = makeSettings({ characterCountMode: 'no-spaces' }); + const result = countSelectedText('Hello world.', [], true, settings); + // "Hello world." → no spaces → 11 chars + expect(result.characters).toBe(11); + }); + + it('returns letters-only when configured', () => { + const settings = makeSettings({ characterCountMode: 'letters-only' }); + const result = countSelectedText('Hello, world! 123', [], true, settings); + // Letters only: "Hello" (5) + "world" (5) = 10 + expect(result.characters).toBe(10); + }); + + it('applies excludedExtensions to the word count only', () => { + // Files-with-extension exclusion is part of word counting, not + // character or sentence counting. + const result = countSelectedText('cat photo.jpg dog', ['.jpg']); + expect(result.words).toBe(2); + // Characters and sentences are still computed on the original text + expect(result.characters).toBe('cat photo.jpg dog'.length); + }); +}); diff --git a/tests/counting/words.test.ts b/tests/counting/words.test.ts new file mode 100644 index 0000000..6ad6d50 --- /dev/null +++ b/tests/counting/words.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest'; +import { countSelectedWords } from '../../main'; +import { makeSettings } from '../fixtures/settings'; + +describe('countSelectedWords — baseline', () => { + it('returns 0 for empty string', () => { + expect(countSelectedWords('')).toBe(0); + }); + + it('counts simple words', () => { + expect(countSelectedWords('hello world')).toBe(2); + }); + + it('counts hyphenated words as one', () => { + expect(countSelectedWords('state-of-the-art design')).toBe(2); + }); + + it('counts contractions with straight apostrophe as one', () => { + expect(countSelectedWords("don't worry it's fine")).toBe(4); + }); + + it('counts contractions with curly apostrophe as one', () => { + expect(countSelectedWords('don’t worry it’s fine')).toBe(4); + }); + + it('counts decimal numbers as one word', () => { + expect(countSelectedWords('Pi is 3.14 approximately')).toBe(4); + }); + + it('strips emojis by default', () => { + expect(countSelectedWords('hello 🚀 world')).toBe(2); + }); + + it('preserves emojis when stripEmojis is false', () => { + // Emojis are not letter characters so the regex won't match them + // either way — strip just removes them from the buffer. Either + // path produces the same word count for letter-only text. + expect(countSelectedWords('hello 🚀 world', [], false)).toBe(2); + }); + + it('strips double quotes around quoted text', () => { + expect(countSelectedWords('She said "hello there".')).toBe(4); + }); +}); + +describe('countSelectedWords — code exclusion', () => { + it('excludes fenced code blocks when enabled', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + }); + const text = 'before\n```\nconst x = 1;\n```\nafter'; + expect(countSelectedWords(text, [], true, settings)).toBe(2); + }); + + it('excludes tilde-fenced code blocks when enabled', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + }); + const text = 'before\n~~~\nconst x = 1;\n~~~\nafter'; + expect(countSelectedWords(text, [], true, settings)).toBe(2); + }); + + it('keeps code blocks when excludeCode is off (master toggle)', () => { + const settings = makeSettings({ + excludeCode: false, + excludeCodeBlocks: true, + }); + const text = 'before\n```\nconst x = 1;\n```\nafter'; + // "before", "const", "x", "1", "after" → 5 + expect(countSelectedWords(text, [], true, settings)).toBe(5); + }); + + it('excludes inline code when enabled', () => { + const settings = makeSettings({ + excludeCode: true, + excludeInlineCode: true, + }); + expect(countSelectedWords('use `console.log` here', [], true, settings)).toBe(2); + }); + + it('excludes both code blocks and inline code together', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + excludeInlineCode: true, + }); + const text = 'before `inline` middle\n```\ncode\n```\nafter'; + expect(countSelectedWords(text, [], true, settings)).toBe(3); + }); +}); + +describe('countSelectedWords — comment exclusion', () => { + it('excludes Obsidian comment content', () => { + const settings = makeSettings({ + excludeComments: true, + excludeObsidianComments: true, + excludeObsidianCommentContent: true, + }); + expect(countSelectedWords('visible %%hidden text%% more', [], true, settings)).toBe(2); + }); + + it('excludes Obsidian comment markers only (content kept)', () => { + const settings = makeSettings({ + excludeComments: true, + excludeObsidianComments: true, + excludeObsidianCommentContent: false, + }); + expect(countSelectedWords('visible %%hidden text%% more', [], true, settings)).toBe(4); + }); + + it('excludes HTML comment content', () => { + const settings = makeSettings({ + excludeComments: true, + excludeHtmlComments: true, + excludeHtmlCommentContent: true, + }); + expect(countSelectedWords('visible more', [], true, settings)).toBe(2); + }); + + it('excludes HTML comment markers only (content kept)', () => { + const settings = makeSettings({ + excludeComments: true, + excludeHtmlComments: true, + excludeHtmlCommentContent: false, + }); + expect(countSelectedWords('visible more', [], true, settings)).toBe(4); + }); +}); + +describe('countSelectedWords — link exclusion', () => { + it('excludes non-visible portion of internal aliased links', () => { + const settings = makeSettings({ + excludeNonVisibleLinkPortions: true, + }); + // "See [[Long Note Name|alias]] now" → "See alias now" → 3 + expect(countSelectedWords('See [[Long Note Name|alias]] now', [], true, settings)).toBe(3); + }); + + it('keeps internal link text without alias', () => { + const settings = makeSettings({ + excludeNonVisibleLinkPortions: true, + }); + // "See [[Note Name]] now" → "See Note Name now" → 4 + expect(countSelectedWords('See [[Note Name]] now', [], true, settings)).toBe(4); + }); + + it('excludes external link URL', () => { + const settings = makeSettings({ + excludeNonVisibleLinkPortions: true, + }); + // "Read [the docs](https://example.com) now" → "Read the docs now" → 4 + expect(countSelectedWords('Read [the docs](https://example.com) now', [], true, settings)).toBe(4); + }); +}); + +describe('countSelectedWords — heading exclusion', () => { + it('excludes entire heading lines when enabled', () => { + const settings = makeSettings({ + excludeHeadings: true, + excludeEntireHeadingLines: true, + }); + const text = '# Title here\n\nBody text follows'; + expect(countSelectedWords(text, [], true, settings)).toBe(3); + }); + + it('excludes heading markers only, keeping text', () => { + const settings = makeSettings({ + excludeHeadings: true, + excludeHeadingMarkersOnly: true, + }); + const text = '# Title here\n\nBody text follows'; + expect(countSelectedWords(text, [], true, settings)).toBe(5); + }); + + it('excludes only specifically-named heading sections', () => { + const settings = makeSettings({ + excludeHeadings: true, + excludeHeadingSections: ['## Skip Me'], + }); + const text = + '# Keep\n\nIntro paragraph.\n\n## Skip Me\n\nThis goes away.\n\n## Keep Too\n\nThis stays.'; + // kept: "Keep" + "Intro paragraph" + "Keep Too" + "This stays" → 7 + expect(countSelectedWords(text, [], true, settings)).toBe(7); + }); +}); + +describe('countSelectedWords — words and phrases exclusion', () => { + it('excludes a list of comma-separated words case-insensitively', () => { + const settings = makeSettings({ + excludeWordsAndPhrases: true, + excludedWords: 'the, a, an', + }); + expect(countSelectedWords('The cat sat on a mat under an arch', [], true, settings)).toBe(6); + }); + + it('excludes phrases case-insensitively', () => { + const settings = makeSettings({ + excludeWordsAndPhrases: true, + excludedPhrases: ['lorem ipsum'], + }); + expect(countSelectedWords('Header Lorem Ipsum footer', [], true, settings)).toBe(2); + }); +}); + +describe('countSelectedWords — file extension exclusion', () => { + it('excludes filenames with extensions in the excludedExtensions list', () => { + // "photo.jpg" → excluded; "notes.md" → excluded; "cat" → kept + expect(countSelectedWords('cat photo.jpg notes.md dog', ['.jpg', '.md'])).toBe(2); + }); + + it('keeps decimal numbers even when extensions look similar', () => { + expect(countSelectedWords('Pi is 3.14 today', ['.14'])).toBe(4); + }); +}); + +describe('countSelectedWords — path exclusion', () => { + // FINDING: the path-detection algorithm in main.ts is greedy. Once a + // segment matches a path-start regex (Windows drive, Unix, UNC, env + // var), every subsequent segment gets appended to the buffer and the + // buffer is re-checked with the same `^pattern` regex. Because the + // regex only requires the buffer to *start* with the path pattern, + // the check keeps succeeding and the path keeps growing — swallowing + // all words to end of input. These tests lock that current behavior. + // Phase 5 (or a later phase) decides whether to tighten the algorithm + // to terminate path-consumption at whitespace boundaries. + + it('Windows drive path swallows trailing words (greedy behavior)', () => { + const settings = makeSettings({ + excludePaths: true, + excludeWindowsPaths: true, + }); + // "Open C:\Users\foo and resume" → only "Open" survives because + // the algorithm extends the C:\ path through all later segments. + expect(countSelectedWords('Open C:\\Users\\foo and resume', [], true, settings)).toBe(1); + }); + + it('Unix path swallows trailing words (greedy behavior)', () => { + const settings = makeSettings({ + excludePaths: true, + excludeUnixPaths: true, + }); + expect(countSelectedWords('check /usr/local/bin for tools', [], true, settings)).toBe(1); + }); + + it('UNC path swallows trailing words (greedy behavior)', () => { + const settings = makeSettings({ + excludePaths: true, + excludeUNCPaths: true, + }); + expect(countSelectedWords('share \\\\server\\public is open', [], true, settings)).toBe(1); + }); + + it('environment-variable path swallows trailing words (greedy behavior)', () => { + const settings = makeSettings({ + excludePaths: true, + excludeEnvironmentPaths: true, + }); + expect(countSelectedWords('set $HOME for context', [], true, settings)).toBe(1); + }); + + it('isolated path at end of selection is excluded cleanly', () => { + const settings = makeSettings({ + excludePaths: true, + excludeUnixPaths: true, + }); + // No trailing words means the greedy extension doesn't kick in. + expect(countSelectedWords('check /usr/local/bin', [], true, settings)).toBe(1); + }); + + it('keeps path when its specific type toggle is off', () => { + const settings = makeSettings({ + excludePaths: true, + excludeUnixPaths: false, + excludeWindowsPaths: true, + }); + expect(countSelectedWords('check /usr/local/bin', [], true, settings)).toBe(2); + }); +}); + +describe('countSelectedWords — advanced regex', () => { + it('falls back to default regex when custom regex is invalid', () => { + const settings = makeSettings({ + enableAdvancedRegex: true, + customWordRegex: '[unclosed', // syntactically invalid + }); + expect(countSelectedWords('hello world', [], true, settings)).toBe(2); + }); + + it('uses custom regex when provided and valid', () => { + const settings = makeSettings({ + enableAdvancedRegex: true, + customWordRegex: '\\d+', // count digit runs only + }); + // "abc 123 def 45" → digit runs: "123", "45" → 2 + expect(countSelectedWords('abc 123 def 45', [], true, settings)).toBe(2); + }); +}); + +describe('countSelectedWords — combination cases', () => { + it('applies code + heading + words/phrases together', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + excludeHeadings: true, + excludeEntireHeadingLines: true, + excludeWordsAndPhrases: true, + excludedWords: 'foo', + }); + const text = '# Heading line\n\nbody foo body\n\n```\nignored code\n```\nend'; + // after heading removal: body foo body / ignored code / end + // after code-block removal: body foo body / / end + // after word exclusion: body body end → 3 + expect(countSelectedWords(text, [], true, settings)).toBe(3); + }); +}); diff --git a/tests/exclusions/overrides.test.ts b/tests/exclusions/overrides.test.ts new file mode 100644 index 0000000..7cbbcbe --- /dev/null +++ b/tests/exclusions/overrides.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest'; +import { + countSelectedWords, + getDisabledExclusionsFromFrontmatter, + processTextWithOverrides, + type WordCountPluginSettings, +} from '../../main'; +import { makeSettings } from '../fixtures/settings'; + +// Build a minimal App-shaped object for `getDisabledExclusionsFromFrontmatter`. +// The function only touches `workspace.getActiveFile()` and +// `metadataCache.getFileCache(file)`. Cast through `unknown` to the App +// parameter type so the call sites stay type-safe without resorting to `any`. +type AppParam = Parameters[0]; + +function makeApp(frontmatter: Record | null): AppParam { + const file = frontmatter === null ? null : { path: 'note.md' }; + return { + workspace: { + getActiveFile: () => file, + }, + metadataCache: { + getFileCache: () => (frontmatter === null ? null : { frontmatter }), + }, + } as unknown as AppParam; +} + +describe('getDisabledExclusionsFromFrontmatter', () => { + it('returns [] when no active file', () => { + expect(getDisabledExclusionsFromFrontmatter(makeApp(null))).toEqual([]); + }); + + it('returns [] when file has no frontmatter', () => { + const app = makeApp({}); + // Frontmatter present but no `cswc-disable` key → [] + expect(getDisabledExclusionsFromFrontmatter(app)).toEqual([]); + }); + + it('returns [] when cswc-disable is missing', () => { + expect(getDisabledExclusionsFromFrontmatter(makeApp({ title: 'note' }))).toEqual([]); + }); + + it('parses cswc-disable as a single string', () => { + const app = makeApp({ 'cswc-disable': 'exclude-headings' }); + expect(getDisabledExclusionsFromFrontmatter(app)).toEqual(['exclude-headings']); + }); + + it('parses cswc-disable as an array', () => { + const app = makeApp({ 'cswc-disable': ['exclude-headings', 'exclude-code-blocks'] }); + expect(getDisabledExclusionsFromFrontmatter(app)).toEqual([ + 'exclude-headings', + 'exclude-code-blocks', + ]); + }); + + it('expands cswc-disable: "all" to the full identifier list', () => { + const app = makeApp({ 'cswc-disable': 'all' }); + const result = getDisabledExclusionsFromFrontmatter(app); + expect(result).toContain('exclude-windows-paths'); + expect(result).toContain('exclude-unix-paths'); + expect(result).toContain('exclude-unc-paths'); + expect(result).toContain('exclude-environment-paths'); + expect(result).toContain('exclude-urls'); + expect(result).toContain('exclude-code-blocks'); + expect(result).toContain('exclude-inline-code'); + expect(result).toContain('exclude-comments'); + expect(result).toContain('exclude-headings'); + expect(result).toContain('exclude-specific-headings'); + expect(result).toContain('exclude-words-phrases'); + }); + + it('expands cswc-disable: ["all"] same as the string form', () => { + const app = makeApp({ 'cswc-disable': ['all'] }); + expect(getDisabledExclusionsFromFrontmatter(app).length).toBeGreaterThanOrEqual(11); + }); + + it('filters non-string array entries silently', () => { + const app = makeApp({ 'cswc-disable': ['exclude-headings', 42, null, 'exclude-comments'] }); + expect(getDisabledExclusionsFromFrontmatter(app)).toEqual([ + 'exclude-headings', + 'exclude-comments', + ]); + }); +}); + +describe('disabledExclusions parameter — runtime disable per call', () => { + const codeOnlySettings: WordCountPluginSettings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + }); + + it('without override: code blocks are excluded', () => { + const text = 'visible\n```\nignored code\n```\nmore'; + expect(countSelectedWords(text, [], true, codeOnlySettings, undefined, [])).toBe(2); + }); + + it('disabled exclusion bypasses the code-block toggle for this call', () => { + const text = 'visible\n```\nignored code\n```\nmore'; + // Now the code block content is kept: "visible ignored code more" → 4 + expect( + countSelectedWords(text, [], true, codeOnlySettings, undefined, ['exclude-code-blocks']), + ).toBe(4); + }); + + it('multiple disabled exclusions bypass each toggle independently', () => { + const settings = makeSettings({ + excludeCode: true, + excludeCodeBlocks: true, + excludeInlineCode: true, + }); + const text = 'visible `inline` more'; + // Default: inline code excluded → "visible more" → 2 + expect(countSelectedWords(text, [], true, settings)).toBe(2); + // With exclude-inline-code disabled, inline kept → "visible inline more" → 3 + expect( + countSelectedWords(text, [], true, settings, undefined, ['exclude-inline-code']), + ).toBe(3); + }); +}); + +describe('processTextWithOverrides — inline comment markers', () => { + it('processes the whole text when no markers are present', () => { + const seen: string[] = []; + processTextWithOverrides('plain text', (segment) => { + seen.push(segment); + return segment; + }); + expect(seen).toEqual(['plain text']); + }); + + it('skips processing inside ... ', () => { + const seen: string[] = []; + processTextWithOverrides( + 'before RAW after', + (segment) => { + seen.push(segment); + return segment; + }, + ); + // Processor gets the before-segment and the after-segment, but not RAW + expect(seen).toEqual(['before ', ' after']); + }); + + it('skips processing inside %% cswc-disable %% ... %% cswc-enable %% (Obsidian style)', () => { + const seen: string[] = []; + processTextWithOverrides( + 'one %% cswc-disable %% two %% cswc-enable %% three', + (segment) => { + seen.push(segment); + return segment; + }, + ); + expect(seen).toEqual(['one ', ' three']); + }); + + it('keeps disabled section verbatim in output', () => { + const result = processTextWithOverrides( + 'A B C', + (segment) => segment.toUpperCase(), + ); + // 'A ' → 'A ' (upper), ' B ' → kept verbatim, ' C' → ' C' (upper) + expect(result).toBe('A B C'); + }); + + it('disabled section without closing marker extends to end of text', () => { + const seen: string[] = []; + processTextWithOverrides( + 'before RAW to end', + (segment) => { + seen.push(segment); + return segment; + }, + ); + expect(seen).toEqual(['before ']); + }); + + it('countSelectedWords respects inline disable markers (HTML form)', () => { + const settings = makeSettings({ + excludeCode: true, + excludeInlineCode: true, + }); + // Without the override, `kept` would be excluded (it's inside backticks). + // With the override, the inline-code processor never runs inside the + // disabled region, so `kept` survives. + const text = 'a `kept` b'; + // Inside disabled region: " `kept` " (kept literally including backticks). + // Outside: "a " and " b". Joined → "a `kept` b". + // Words (regex matches \w runs): a, kept, b → 3. + expect(countSelectedWords(text, [], true, settings)).toBe(3); + }); + + it('countSelectedWords respects inline disable markers (Obsidian style)', () => { + const settings = makeSettings({ + excludeCode: true, + excludeInlineCode: true, + }); + const text = 'a %% cswc-disable %% `kept` %% cswc-enable %% b'; + expect(countSelectedWords(text, [], true, settings)).toBe(3); + }); +}); diff --git a/tests/exclusions/processors.test.ts b/tests/exclusions/processors.test.ts new file mode 100644 index 0000000..2edce3c --- /dev/null +++ b/tests/exclusions/processors.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import { + stripFrontmatter, + processCodeBlocks, + processInlineCode, + processObsidianComments, + processHtmlComments, + processLinks, + processHeadings, + processSelectiveHeadingSections, + processWordsAndPhrases, +} from '../../main'; + +describe('stripFrontmatter', () => { + it('returns text unchanged when it does not start with ---', () => { + expect(stripFrontmatter('plain body')).toBe('plain body'); + }); + + it('strips frontmatter delimited by --- and ---', () => { + const text = '---\ntitle: Test\n---\nBody here'; + expect(stripFrontmatter(text)).toBe('Body here'); + }); + + it('strips frontmatter delimited by --- and ...', () => { + const text = '---\ntitle: Test\n...\nBody here'; + expect(stripFrontmatter(text)).toBe('Body here'); + }); + + it('returns empty string when closing delimiter is missing', () => { + const text = '---\ntitle: Test\nBody (no close)'; + expect(stripFrontmatter(text)).toBe(''); + }); + + it('preserves multiline body', () => { + const text = '---\nkey: value\n---\nline 1\nline 2\nline 3'; + expect(stripFrontmatter(text)).toBe('line 1\nline 2\nline 3'); + }); +}); + +describe('processCodeBlocks', () => { + it('returns text unchanged when excludeCodeBlocks is false', () => { + const text = '```\ncode\n```\nbody'; + expect(processCodeBlocks(text, false)).toBe(text); + }); + + it('removes triple-backtick blocks', () => { + expect(processCodeBlocks('a\n```\ncode\n```\nb', true)).toBe('a\n\nb'); + }); + + it('removes triple-tilde blocks', () => { + expect(processCodeBlocks('a\n~~~\ncode\n~~~\nb', true)).toBe('a\n\nb'); + }); + + it('handles multiple blocks in sequence', () => { + const text = 'one\n```\nA\n```\ntwo\n```\nB\n```\nthree'; + expect(processCodeBlocks(text, true)).toBe('one\n\ntwo\n\nthree'); + }); +}); + +describe('processInlineCode', () => { + it('returns text unchanged when excludeInlineCode is false', () => { + expect(processInlineCode('use `x` here', false)).toBe('use `x` here'); + }); + + it('removes single-backtick spans', () => { + expect(processInlineCode('use `x` here', true)).toBe('use here'); + }); + + it('handles multiple inline spans', () => { + expect(processInlineCode('`a` and `b` and `c`', true)).toBe(' and and '); + }); +}); + +describe('processObsidianComments', () => { + it('returns text unchanged when excludeComments is false', () => { + expect(processObsidianComments('a %%b%% c', false, false)).toBe('a %%b%% c'); + }); + + it('removes only markers when excludeContent is false', () => { + expect(processObsidianComments('a %%b%% c', true, false)).toBe('a b c'); + }); + + it('removes markers and content when excludeContent is true', () => { + expect(processObsidianComments('a %%b%% c', true, true)).toBe('a c'); + }); + + it('handles multiline content when excludeContent is true', () => { + expect(processObsidianComments('a %%multi\nline\ncontent%% z', true, true)).toBe('a z'); + }); +}); + +describe('processHtmlComments', () => { + it('returns text unchanged when excludeComments is false', () => { + expect(processHtmlComments('a c', false, false)).toBe('a c'); + }); + + it('removes only markers when excludeContent is false', () => { + expect(processHtmlComments('a c', true, false)).toBe('a b c'); + }); + + it('removes markers and content when excludeContent is true', () => { + expect(processHtmlComments('a c', true, true)).toBe('a c'); + }); +}); + +describe('processLinks', () => { + it('returns text unchanged when excludeNonVisible is false', () => { + expect(processLinks('[[Note|alias]]', false)).toBe('[[Note|alias]]'); + }); + + it('replaces aliased internal links with their alias', () => { + expect(processLinks('See [[Long Note Name|short]] now', true)).toBe('See short now'); + }); + + it('replaces non-aliased internal links with the note name', () => { + expect(processLinks('See [[Note Name]] now', true)).toBe('See Note Name now'); + }); + + it('replaces external links with their link text', () => { + expect(processLinks('Read [the docs](https://example.com)', true)).toBe('Read the docs'); + }); +}); + +describe('processHeadings', () => { + it('returns text unchanged when excludeHeadings is false', () => { + expect(processHeadings('# Title\nbody', false, false, false, [])).toBe('# Title\nbody'); + }); + + it('removes entire heading lines when excludeEntireLines is true', () => { + expect(processHeadings('# Title\nbody', true, false, true, [])).toBe('\nbody'); + }); + + it('removes only heading markers when excludeMarkersOnly is true', () => { + expect(processHeadings('# Title\nbody', true, true, false, [])).toBe('Title\nbody'); + }); + + it('removes Setext (underlined) headings entirely', () => { + const text = 'Title\n=====\nbody'; + expect(processHeadings(text, true, false, true, [])).toBe('\nbody'); + }); +}); + +describe('processSelectiveHeadingSections', () => { + it('removes the named section and its content', () => { + const text = '# Keep\nintro\n\n## Skip Me\nbody\n\n## Keep\nend'; + const result = processSelectiveHeadingSections(text, ['## Skip Me']); + expect(result).toContain('Keep'); + expect(result).toContain('intro'); + expect(result).toContain('end'); + expect(result).not.toContain('Skip Me'); + expect(result).not.toContain('body'); + }); + + it('also removes subsections nested under the named section', () => { + const text = '## Skip\nfoo\n### Sub\nbar\n## Keep\nend'; + const result = processSelectiveHeadingSections(text, ['## Skip']); + expect(result).not.toContain('foo'); + expect(result).not.toContain('Sub'); + expect(result).not.toContain('bar'); + expect(result).toContain('Keep'); + expect(result).toContain('end'); + }); + + it('matching is case-insensitive on the full heading line', () => { + const text = '## SKIP me\nbody\n## next\nkeep'; + const result = processSelectiveHeadingSections(text, ['## skip me']); + expect(result).not.toContain('body'); + expect(result).toContain('keep'); + }); +}); + +describe('processWordsAndPhrases', () => { + it('returns text unchanged when excludeWordsAndPhrases is false', () => { + expect(processWordsAndPhrases('foo bar', false, 'foo', [])).toBe('foo bar'); + }); + + it('removes comma-separated words case-insensitively', () => { + expect(processWordsAndPhrases('Foo bar BAZ', true, 'foo, baz', [])).toBe(' bar '); + }); + + it('removes only whole words (not substrings)', () => { + expect(processWordsAndPhrases('food foo footer', true, 'foo', [])).toBe('food footer'); + }); + + it('removes phrases case-insensitively', () => { + expect(processWordsAndPhrases('See Lorem Ipsum here', true, '', ['lorem ipsum'])).toBe('See here'); + }); + + it('escapes regex special characters in words/phrases', () => { + expect(processWordsAndPhrases('a (b) c', true, '', ['(b)'])).toBe('a c'); + }); +}); diff --git a/tests/fixtures/settings.ts b/tests/fixtures/settings.ts new file mode 100644 index 0000000..bcec831 --- /dev/null +++ b/tests/fixtures/settings.ts @@ -0,0 +1,10 @@ +import { DEFAULT_SETTINGS, type WordCountPluginSettings } from '../../main'; + +// Returns a fresh copy of DEFAULT_SETTINGS plus any overrides. Tests +// should never mutate DEFAULT_SETTINGS directly; this helper is the +// safe way to express "default settings except for these fields." +export function makeSettings( + overrides: Partial = {}, +): WordCountPluginSettings { + return { ...DEFAULT_SETTINGS, ...overrides }; +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts new file mode 100644 index 0000000..d97e4ad --- /dev/null +++ b/tests/mocks/obsidian.ts @@ -0,0 +1,81 @@ +// Minimal `obsidian` module mock for vitest. +// +// `main.ts` imports a handful of classes and helpers from `'obsidian'`. +// The count-and-process functions tested in this suite don't actually +// touch them at runtime — they take their inputs as plain strings and +// settings objects. The imports are needed only so the TypeScript module +// graph resolves, so the mock can be no-op stubs. +// +// `getDisabledExclusionsFromFrontmatter` is the one function that +// genuinely reaches into Obsidian — it calls `app.workspace.getActiveFile` +// and `app.metadataCache.getFileCache`. Tests for that function build +// hand-rolled `App`-shaped objects rather than using the mock class. + +export class App {} +export class MarkdownView {} +export class Modal { + constructor(_app?: unknown) {} + open() {} + close() {} +} +export class Plugin { + app: unknown; + manifest: unknown; + constructor(app?: unknown, manifest?: unknown) { + this.app = app; + this.manifest = manifest; + } +} +export class PluginSettingTab { + constructor(_app?: unknown, _plugin?: unknown) {} +} +export class Setting { + constructor(_containerEl?: unknown) {} + setName(_name: string) { + return this; + } + setDesc(_desc: string) { + return this; + } + setHeading() { + return this; + } + addToggle(_cb: unknown) { + return this; + } + addText(_cb: unknown) { + return this; + } + addDropdown(_cb: unknown) { + return this; + } + addButton(_cb: unknown) { + return this; + } +} +export class Notice { + constructor(_message?: string, _timeout?: number) {} +} +export class TextComponent {} +export class ToggleComponent {} +export class DropdownComponent {} +export class ButtonComponent {} +export class EditorPosition {} +export class Editor {} +export class TFile {} + +export const Platform = { + isDesktop: true, + isMobile: false, + isMacOS: false, + isWin: false, + isLinux: true, + isMobileApp: false, + isDesktopApp: true, + isIosApp: false, + isAndroidApp: false, +}; + +export function setIcon(_el: unknown, _icon: string) { + // no-op +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..37e7dd6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + resolve: { + // Without this, `import { ... } from '../main'` would resolve to + // the build artifact `main.js` (because `package.json#main` points + // there). We want tests to run against `main.ts` so the count + // functions are transformed through vitest's esbuild pipeline and + // the `obsidian` alias below takes effect. + extensions: ['.ts', '.tsx', '.mjs', '.js', '.mts', '.cjs', '.cts', '.json'], + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + alias: { + // `main.ts` imports from `'obsidian'`. In tests, redirect that + // to a hand-rolled mock that exposes only the surface the + // counting code touches (which is effectively nothing — the + // classes are imported for type-level use only). + obsidian: resolve(__dirname, 'tests/mocks/obsidian.ts'), + }, + }, +});