mirror of
https://github.com/banisterious/obsidian-custom-selected-word-count.git
synced 2026-07-22 05:49:07 +00:00
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:
parent
81f1aa6a51
commit
974bd2b3aa
10 changed files with 1225 additions and 0 deletions
110
docs/planning/audit-phase-4-tests.md
Normal file
110
docs/planning/audit-phase-4-tests.md
Normal 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.
|
||||
129
tests/counting/characters.test.ts
Normal file
129
tests/counting/characters.test.ts
Normal 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('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 <!--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);
|
||||
});
|
||||
});
|
||||
125
tests/counting/sentences.test.ts
Normal file
125
tests/counting/sentences.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
tests/counting/text.test.ts
Normal file
37
tests/counting/text.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
317
tests/counting/words.test.ts
Normal file
317
tests/counting/words.test.ts
Normal 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('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 <!-- 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);
|
||||
});
|
||||
});
|
||||
200
tests/exclusions/overrides.test.ts
Normal file
200
tests/exclusions/overrides.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
tests/exclusions/processors.test.ts
Normal file
192
tests/exclusions/processors.test.ts
Normal 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
10
tests/fixtures/settings.ts
vendored
Normal 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
81
tests/mocks/obsidian.ts
Normal 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
24
vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue