diff --git a/src/model/writing-session-tracker.ts b/src/model/writing-session-tracker.ts index 8ff738f..44d0c8e 100644 --- a/src/model/writing-session-tracker.ts +++ b/src/model/writing-session-tracker.ts @@ -30,20 +30,26 @@ const WORD_COUNT_REGEX = (() => { .source; return new RegExp( [ - "(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-" + spaceDelimitedChars + "])+", + "(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-'" + spaceDelimitedChars + "])+", nonSpaceDelimitedWords, ].join("|"), "g" ); })(); -function countWords( +export function countWords( text: string, removeMarkdown = true, removeComments = true ): number { let textToCount = text; + if (removeComments) { + textToCount = textToCount + .replace(//gs, "") // HTML comments + .replace(/%%.*?%%/gs, ""); // Markdown comments + } + if (removeMarkdown) { textToCount = textToCount .replace(/`\$?=[^`]+`/g, "") // inline dataview @@ -52,12 +58,6 @@ function countWords( .replace(/\*|_|\[\[|\]\]|\||==|~~|---|#|> |`/g, ""); // Markdown Syntax } - if (removeComments) { - textToCount = textToCount - .replace(//gs, "") // HTML comments - .replace(/%%.*?%%/gs, ""); // Markdown comments - } - return (textToCount.match(WORD_COUNT_REGEX) || []).length; } diff --git a/test/__mocks__/obsidian.ts b/test/__mocks__/obsidian.ts new file mode 100644 index 0000000..9d6ed26 --- /dev/null +++ b/test/__mocks__/obsidian.ts @@ -0,0 +1,18 @@ +export const debounce = unknown>(fn: T, _wait: number, _immediate?: boolean): T => { + return fn; +}; + +export abstract class TAbstractFile { + path: string = ""; + vault: unknown = null; +} + +export class TFile extends TAbstractFile { + stat = { ctime: 0, mtime: 0, size: 0 }; + basename = ""; + extension = ""; + name = ""; + parent = null; +} + +export class Vault {} diff --git a/test/model/writing-session-tracker.test.ts b/test/model/writing-session-tracker.test.ts new file mode 100644 index 0000000..f9f6a54 --- /dev/null +++ b/test/model/writing-session-tracker.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { countWords } from "src/model/writing-session-tracker"; + +describe("countWords", () => { + it("counts basic space-delimited words", () => { + expect(countWords("Hello world")).toBe(2); + expect(countWords("one two three four five")).toBe(5); + expect(countWords("a")).toBe(1); + }); + + it("handles multiple spaces", () => { + expect(countWords("Hello world")).toBe(2); + expect(countWords(" leading")).toBe(1); + expect(countWords("trailing ")).toBe(1); + }); + + it("handles newlines", () => { + expect(countWords("Hello\nworld")).toBe(2); + expect(countWords("line1\nline2\nline3")).toBe(3); + }); + + it("handles empty string", () => { + expect(countWords("")).toBe(0); + }); + + it("counts numbers as words", () => { + expect(countWords("12345")).toBe(1); + expect(countWords("hello 123 world")).toBe(3); + }); + + it("counts comma-separated numbers as one word", () => { + expect(countWords("1,000")).toBe(1); + expect(countWords("1,000,000")).toBe(1); + }); + + it("counts decimal numbers as one word", () => { + expect(countWords("1,000.50")).toBe(1); + expect(countWords("3.14")).toBe(1); + }); + + it("strips bold markers", () => { + expect(countWords("**bold**")).toBe(1); + expect(countWords("**bold** and **more**")).toBe(3); + }); + + it("strips italic markers", () => { + expect(countWords("*italic*")).toBe(1); + expect(countWords("_italic_")).toBe(1); + }); + + it("strips wiki links", () => { + expect(countWords("[[link]]")).toBe(1); + expect(countWords("[[File|Alias]]")).toBe(1); + }); + + it("strips external links", () => { + expect(countWords("[text](url)")).toBe(1); + }); + + it("strips heading markers", () => { + expect(countWords("# Header")).toBe(1); + expect(countWords("## Subheader")).toBe(1); + }); + + it("removes YAML frontmatter", () => { + expect(countWords("---\ntitle: x\n---\n\nBody text")).toBe(2); + }); + + it("strips HTML comments", () => { + expect(countWords("Hello world")).toBe(2); + }); + + it("strips markdown comments", () => { + expect(countWords("Hello %% comment %% world")).toBe(2); + }); + + it("counts hyphenated words as one word", () => { + expect(countWords("well-known")).toBe(1); + expect(countWords("up-to-date")).toBe(1); + }); + + it("counts CJK characters individually", () => { + expect(countWords("日本語")).toBe(3); + expect(countWords("東京")).toBe(2); + }); + + it("handles markdown code inlines", () => { + // NB: backticks are stripped by the markdown regex, so `code` + // becomes `code` and counts as a word. Existing behavior. + expect(countWords("text `code` text")).toBe(3); + }); + + it("strips inline dataview", () => { + expect(countWords("text `=this.field` text")).toBe(2); + }); + + it("handles real-world paragraph text", () => { + const text = "The quick brown fox jumps over the lazy dog."; + expect(countWords(text)).toBe(9); + }); + + describe("flag combinations with mixed content", () => { + const mixed = "Hello **bold** %% md %% world"; + + it("strips comments and markdown when both flags active", () => { + expect(countWords(mixed, true, true), "comments removed first, then ** stripped — clean word count").toBe(3); + }); + + it("strips only markdown, preserves %% comments", () => { + expect(countWords(mixed, true, false), "md strips ** and > but %% is left intact so it acts as word-delimiter for md").toBe(6); + }); + + it("strips only comments, preserves markdown syntax", () => { + expect(countWords(mixed, false, true), "* is not in the word character class, so **bold** becomes bold").toBe(3); + }); + + it("preserves all syntax when processing disabled", () => { + expect(countWords(mixed, false, false), "raw text — all HTML, markdown, and %% tokens counted as-is").toBe(7); + }); + }); + + it("counts contractions with apostrophes as a single word", () => { + expect.soft(countWords("Can't"), '"Can\'t" should be 1 word').toBe(1); + expect.soft(countWords("don't"), '"don\'t" should be 1 word').toBe(1); + expect.soft(countWords("it's"), '"it\'s" should be 1 word').toBe(1); + expect.soft(countWords("won't"), '"won\'t" should be 1 word').toBe(1); + expect.soft(countWords("o'clock"), '"o\'clock" should be 1 word').toBe(1); + expect.soft(countWords("won't don't can't"), 'phrase with 3 contractions should be 3 words').toBe(3); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f4743e6..366e431 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import path from "path"; export default defineConfig({ + resolve: { + alias: { + obsidian: path.resolve(__dirname, "test/__mocks__/obsidian.ts"), + }, + }, test: { coverage: { reporter: ["text", "html"],