test: add vitest infrastructure and Phase 4 characterization suite

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.
This commit is contained in:
banisterious 2026-05-12 18:44:28 -07:00
parent 81f1aa6a51
commit 974bd2b3aa
10 changed files with 1225 additions and 0 deletions

View file

@ -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 `<!-- cswc-disable -->` 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.

View file

@ -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('dont', '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 <!--xy--> CD" → "AB CD" → 6
expect(countSelectedCharacters('AB <!--xy--> 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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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('dont worry its 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 <!-- hidden text --> 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 <!-- hidden text --> 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);
});
});

View file

@ -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<typeof getDisabledExclusionsFromFrontmatter>[0];
function makeApp(frontmatter: Record<string, unknown> | 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 <!-- cswc-disable --> ... <!-- cswc-enable -->', () => {
const seen: string[] = [];
processTextWithOverrides(
'before <!-- cswc-disable --> RAW <!-- cswc-enable --> 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 <!-- cswc-disable --> B <!-- cswc-enable --> 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 <!-- cswc-disable --> 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 <!-- cswc-disable --> `kept` <!-- cswc-enable --> 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);
});
});

View file

@ -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 <!--b--> c', false, false)).toBe('a <!--b--> c');
});
it('removes only markers when excludeContent is false', () => {
expect(processHtmlComments('a <!--b--> c', true, false)).toBe('a b c');
});
it('removes markers and content when excludeContent is true', () => {
expect(processHtmlComments('a <!--b--> 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');
});
});

10
tests/fixtures/settings.ts vendored Normal file
View file

@ -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> = {},
): WordCountPluginSettings {
return { ...DEFAULT_SETTINGS, ...overrides };
}

81
tests/mocks/obsidian.ts Normal file
View file

@ -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
}

24
vitest.config.ts Normal file
View file

@ -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'),
},
},
});