Kaynağa Gözat

Chore: Add baseline unit tests (#2417)

* Fix: Better Windows path support

* Move to 'chai' for test running

* Fix: Let's start with what we know

* Chore: Add 'root' level file path test, remove less useful tests

* Chore: Add 'root' level file path test, remove less useful tests

---------

Co-authored-by: Dennis Bartlett <[email protected]>
Mark Percival 9 ay önce
ebeveyn
işleme
21e95ab67e

+ 6 - 0
.changeset/mean-dragons-hug.md

@@ -0,0 +1,6 @@
+---
+"claude-dev": patch
+---
+
+Chore: Add tests to context-mentions, run tests with mocha and ts-node - `npm run test:unit`
+

+ 3 - 0
.github/workflows/test.yml

@@ -73,6 +73,9 @@ jobs:
             - name: Build Extension
               run: npm run compile
 
+            - name: Unit Tests
+              run: npm run test:unit
+
             # Run extension tests with coverage
             - name: Extension Tests with Coverage
               id: extension_coverage

+ 6 - 0
.mocharc.json

@@ -0,0 +1,6 @@
+{
+	"extension": ["ts"],
+	"spec": "src/**/__tests__/*.ts",
+	"require": ["ts-node/register", "source-map-support/register"],
+	"recursive": true
+}

+ 164 - 6
package-lock.json

@@ -82,6 +82,7 @@
 				"prettier": "^3.3.3",
 				"should": "^13.2.3",
 				"sinon": "^19.0.2",
+				"ts-node": "^10.9.2",
 				"typescript": "^5.4.5"
 			},
 			"engines": {
@@ -4286,6 +4287,30 @@
 				"url": "https://github.com/prettier/prettier?sponsor=1"
 			}
 		},
+		"node_modules/@cspotcode/source-map-support": {
+			"version": "0.8.1",
+			"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+			"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/trace-mapping": "0.3.9"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+			"version": "0.3.9",
+			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+			"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/resolve-uri": "^3.0.3",
+				"@jridgewell/sourcemap-codec": "^1.4.10"
+			}
+		},
 		"node_modules/@esbuild/aix-ppc64": {
 			"version": "0.25.0",
 			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
@@ -8569,6 +8594,34 @@
 			"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
 			"license": "MIT"
 		},
+		"node_modules/@tsconfig/node10": {
+			"version": "1.0.11",
+			"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+			"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@tsconfig/node12": {
+			"version": "1.0.11",
+			"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+			"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@tsconfig/node14": {
+			"version": "1.0.3",
+			"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+			"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@tsconfig/node16": {
+			"version": "1.0.4",
+			"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+			"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/@types/chai": {
 			"version": "5.0.1",
 			"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz",
@@ -9112,6 +9165,19 @@
 				"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
 			}
 		},
+		"node_modules/acorn-walk": {
+			"version": "8.3.4",
+			"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+			"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"acorn": "^8.11.0"
+			},
+			"engines": {
+				"node": ">=0.4.0"
+			}
+		},
 		"node_modules/agent-base": {
 			"version": "7.1.1",
 			"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
@@ -9215,6 +9281,13 @@
 			"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
 			"license": "ISC"
 		},
+		"node_modules/arg": {
+			"version": "4.1.3",
+			"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+			"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/argparse": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -10173,6 +10246,13 @@
 				"node": ">= 0.10"
 			}
 		},
+		"node_modules/create-require": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+			"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/cross-spawn": {
 			"version": "7.0.6",
 			"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10863,9 +10943,9 @@
 			}
 		},
 		"node_modules/escalade": {
-			"version": "3.1.2",
-			"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
-			"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+			"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
 			"license": "MIT",
 			"engines": {
 				"node": ">=6"
@@ -13446,6 +13526,13 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
+		"node_modules/make-error": {
+			"version": "1.3.6",
+			"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+			"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+			"dev": true,
+			"license": "ISC"
+		},
 		"node_modules/mammoth": {
 			"version": "1.8.0",
 			"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.8.0.tgz",
@@ -15608,9 +15695,9 @@
 			"license": "MIT"
 		},
 		"node_modules/semver": {
-			"version": "7.6.3",
-			"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
-			"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+			"version": "7.7.1",
+			"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+			"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
 			"license": "ISC",
 			"bin": {
 				"semver": "bin/semver.js"
@@ -16608,6 +16695,60 @@
 				"typescript": ">=4.2.0"
 			}
 		},
+		"node_modules/ts-node": {
+			"version": "10.9.2",
+			"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+			"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@cspotcode/source-map-support": "^0.8.0",
+				"@tsconfig/node10": "^1.0.7",
+				"@tsconfig/node12": "^1.0.7",
+				"@tsconfig/node14": "^1.0.0",
+				"@tsconfig/node16": "^1.0.2",
+				"acorn": "^8.4.1",
+				"acorn-walk": "^8.1.1",
+				"arg": "^4.1.0",
+				"create-require": "^1.1.0",
+				"diff": "^4.0.1",
+				"make-error": "^1.1.1",
+				"v8-compile-cache-lib": "^3.0.1",
+				"yn": "3.1.1"
+			},
+			"bin": {
+				"ts-node": "dist/bin.js",
+				"ts-node-cwd": "dist/bin-cwd.js",
+				"ts-node-esm": "dist/bin-esm.js",
+				"ts-node-script": "dist/bin-script.js",
+				"ts-node-transpile-only": "dist/bin-transpile.js",
+				"ts-script": "dist/bin-script-deprecated.js"
+			},
+			"peerDependencies": {
+				"@swc/core": ">=1.2.50",
+				"@swc/wasm": ">=1.2.50",
+				"@types/node": "*",
+				"typescript": ">=2.7"
+			},
+			"peerDependenciesMeta": {
+				"@swc/core": {
+					"optional": true
+				},
+				"@swc/wasm": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/ts-node/node_modules/diff": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+			"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+			"dev": true,
+			"license": "BSD-3-Clause",
+			"engines": {
+				"node": ">=0.3.1"
+			}
+		},
 		"node_modules/tslib": {
 			"version": "1.14.1",
 			"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -16932,6 +17073,13 @@
 				"uuid": "dist/bin/uuid"
 			}
 		},
+		"node_modules/v8-compile-cache-lib": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+			"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/v8-to-istanbul": {
 			"version": "9.3.0",
 			"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -17486,6 +17634,16 @@
 				"fd-slicer": "~1.1.0"
 			}
 		},
+		"node_modules/yn": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+			"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
 		"node_modules/yocto-queue": {
 			"version": "0.1.0",
 			"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

+ 2 - 0
package.json

@@ -303,6 +303,7 @@
 		"format:fix": "prettier . --write",
 		"test": "vscode-test",
 		"test:ci": "node scripts/test-ci.js",
+		"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
 		"test:coverage": "vscode-test --coverage",
 		"install:all": "npm install && cd webview-ui && npm install",
 		"dev:webview": "cd webview-ui && npm run dev",
@@ -339,6 +340,7 @@
 		"prettier": "^3.3.3",
 		"should": "^13.2.3",
 		"sinon": "^19.0.2",
+		"ts-node": "^10.9.2",
 		"typescript": "^5.4.5"
 	},
 	"dependencies": {

+ 155 - 0
src/core/context-management/__tests__/ContextManager.test.ts

@@ -0,0 +1,155 @@
+import { ContextManager } from "../ContextManager"
+import { Anthropic } from "@anthropic-ai/sdk"
+import { expect } from "chai"
+
+describe("ContextManager", () => {
+	function createMessages(count: number): Anthropic.Messages.MessageParam[] {
+		const messages: Anthropic.Messages.MessageParam[] = []
+
+		messages.push({
+			role: "user",
+			content: "Initial task message",
+		})
+
+		let role: "user" | "assistant" = "assistant"
+		for (let i = 1; i < count; i++) {
+			messages.push({
+				role,
+				content: `Message ${i}`,
+			})
+			role = role === "user" ? "assistant" : "user"
+		}
+
+		return messages
+	}
+
+	describe("getNextTruncationRange", () => {
+		let contextManager: ContextManager
+
+		beforeEach(() => {
+			contextManager = new ContextManager()
+		})
+
+		it("first truncation with half keep", () => {
+			const messages = createMessages(11)
+			const result = contextManager.getNextTruncationRange(messages, undefined, "half")
+
+			expect(result).to.deep.equal([1, 4])
+		})
+
+		it("first truncation with quarter keep", () => {
+			const messages = createMessages(11)
+			const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
+
+			expect(result).to.deep.equal([1, 6])
+		})
+
+		it("sequential truncation with half keep", () => {
+			const messages = createMessages(21)
+			const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
+			expect(firstRange).to.deep.equal([1, 10])
+
+			// Pass the previous range for sequential truncation
+			const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
+			expect(secondRange).to.deep.equal([1, 14])
+		})
+
+		it("sequential truncation with quarter keep", () => {
+			const messages = createMessages(41)
+			const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
+
+			const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
+
+			expect(secondRange[0]).to.equal(1)
+			expect(secondRange[1]).to.be.greaterThan(firstRange[1])
+		})
+
+		it("ensures the last message in range is a user message", () => {
+			const messages = createMessages(14)
+			const result = contextManager.getNextTruncationRange(messages, undefined, "half")
+
+			// Check if the message at the end of range is a user message
+			const lastRemovedMessage = messages[result[1]]
+			expect(lastRemovedMessage.role).to.equal("user")
+
+			// Check if the next message after the range is an assistant message
+			const nextMessage = messages[result[1] + 1]
+			expect(nextMessage.role).to.equal("assistant")
+		})
+
+		it("handles small message arrays", () => {
+			const messages = createMessages(3)
+			const result = contextManager.getNextTruncationRange(messages, undefined, "half")
+
+			expect(result).to.deep.equal([1, 0])
+		})
+
+		it("preserves the message structure when truncating", () => {
+			const messages = createMessages(20)
+			const result = contextManager.getNextTruncationRange(messages, undefined, "half")
+
+			// Get messages after removing the range
+			const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
+
+			// Check first message and alternating pattern
+			expect(effectiveMessages[0].role).to.equal("user")
+			for (let i = 1; i < effectiveMessages.length; i++) {
+				const expectedRole = i % 2 === 1 ? "assistant" : "user"
+				expect(effectiveMessages[i].role).to.equal(expectedRole)
+			}
+		})
+	})
+
+	describe("getTruncatedMessages", () => {
+		let contextManager: ContextManager
+
+		beforeEach(() => {
+			contextManager = new ContextManager()
+		})
+
+		it("returns original messages when no range is provided", () => {
+			const messages = createMessages(3)
+
+			const result = contextManager.getTruncatedMessages(messages, undefined)
+			expect(result).to.deep.equal(messages)
+		})
+
+		it("correctly removes messages in the specified range", () => {
+			const messages = createMessages(5)
+
+			const range: [number, number] = [1, 3]
+			const result = contextManager.getTruncatedMessages(messages, range)
+
+			expect(result).to.have.lengthOf(2)
+			expect(result[0]).to.deep.equal(messages[0])
+			expect(result[1]).to.deep.equal(messages[4])
+		})
+
+		it("works with a range that starts at the first message after task", () => {
+			const messages = createMessages(4)
+
+			const range: [number, number] = [1, 2]
+			const result = contextManager.getTruncatedMessages(messages, range)
+
+			expect(result).to.have.lengthOf(2)
+			expect(result[0]).to.deep.equal(messages[0])
+			expect(result[1]).to.deep.equal(messages[3])
+		})
+
+		it("correctly handles removing a range while preserving alternation pattern", () => {
+			const messages = createMessages(5)
+
+			const range: [number, number] = [1, 2]
+			const result = contextManager.getTruncatedMessages(messages, range)
+
+			expect(result).to.have.lengthOf(3)
+			expect(result[0]).to.deep.equal(messages[0])
+			expect(result[1]).to.deep.equal(messages[3])
+			expect(result[2]).to.deep.equal(messages[4])
+
+			expect(result[0].role).to.equal("user")
+			expect(result[1].role).to.equal("assistant")
+			expect(result[2].role).to.equal("user")
+		})
+	})
+})

+ 191 - 0
src/shared/__tests__/context-mentions.test.ts

@@ -0,0 +1,191 @@
+import { expect } from "chai"
+
+import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
+
+interface TestResult {
+	actual: string | null
+	expected: string | null
+}
+
+function testMention(input: string, expected: string | null): TestResult {
+	const match = mentionRegex.exec(input)
+	return {
+		actual: match ? match[0] : null,
+		expected,
+	}
+}
+
+function assertMatch(result: TestResult) {
+	expect(result.actual).eq(result.expected)
+	return true
+}
+
+describe("Mention Regex", () => {
+	describe("Windows Path Support", () => {
+		it("matches simple Windows paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/C:\\folder\\file.txt", "@/C:\\folder\\file.txt"],
+				["@/C:\\file.txt", "@/C:\\file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+
+	describe("Edge Cases", () => {
+		it("handles edge cases correctly", () => {
+			const cases: Array<[string, string]> = [
+				["@/C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@/C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
+				["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+
+	describe("Existing Functionality", () => {
+		it("matches Unix paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/usr/local/bin/file", "@/usr/local/bin/file"],
+				["@/path/to/file.txt", "@/path/to/file.txt"],
+				["@//etc/host", "@//etc/host"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+
+		it("matches URLs", () => {
+			const cases: Array<[string, string]> = [
+				["@http://example.com", "@http://example.com"],
+				["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
+				["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+
+		it("matches git hashes", () => {
+			const cases: Array<[string, string]> = [
+				["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+
+		it("matches special keywords", () => {
+			const cases: Array<[string, string]> = [
+				["@problems", "@problems"],
+				["@git-changes", "@git-changes"],
+				["@terminal", "@terminal"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+
+	describe("Invalid Patterns", () => {
+		it("rejects invalid patterns", () => {
+			const cases: Array<[string, null]> = [
+				["C:\\folder\\file.txt", null],
+				["@", null],
+				["@ C:\\file.txt", null],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+
+		it("matches only until invalid characters", () => {
+			const result = testMention("@/C:\\folder\\file.txt invalid suffix", "@/C:\\folder\\file.txt")
+			assertMatch(result)
+		})
+	})
+
+	describe("In Context", () => {
+		it("matches mentions within text", () => {
+			const cases: Array<[string, string]> = [
+				["Check the file at @/C:\\folder\\file.txt for details.", "@/C:\\folder\\file.txt"],
+				["Review @problems and @git-changes.", "@problems"],
+				["Multiple: @/file1.txt and @/C:\\file2.txt and @terminal", "@/file1.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+
+	describe("Multiple Mentions", () => {
+		it("finds all mentions in a string using global regex", () => {
+			const text = "Check @/path/file1.txt and @/C:\\folder\\file2.txt and report any @problems to @git-changes"
+			const matches = text.match(mentionRegexGlobal)
+			expect(matches).deep.eq(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
+		})
+	})
+
+	describe("Special Characters in Paths", () => {
+		it("handles special characters in file paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
+				["@/C:\\folder+plus\\file(parens)[]brackets.txt", "@/C:\\folder+plus\\file(parens)[]brackets.txt"],
+				["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
+				["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+
+	describe("Mixed Path Types in Single String", () => {
+		it("correctly identifies the first path in a string with multiple path types", () => {
+			const text = "Check both @/unix/path and @/C:\\windows\\path for details."
+			const result = mentionRegex.exec(text) || []
+			expect(result[0]).eq("@/unix/path")
+
+			// Test starting from after the first match
+			const secondSearchStart = text.indexOf("@/C:")
+			const secondResult = mentionRegex.exec(text.substring(secondSearchStart)) || []
+			expect(secondResult[0]).eq("@/C:\\windows\\path")
+		})
+	})
+
+	describe("Non-Latin Character Support", () => {
+		it("handles international characters in paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
+				["@/C:\\用户\\документы\\файл.txt", "@/C:\\用户\\документы\\файл.txt"],
+				["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
+				["@/C:\\folder\\file_äöü.txt", "@/C:\\folder\\file_äöü.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				assertMatch(result)
+			})
+		})
+	})
+})

+ 1 - 1
tsconfig.test.json

@@ -14,5 +14,5 @@
 		"rootDir": "src"
 	},
 	"include": ["src/**/*.test.ts"],
-	"exclude": ["src/test/**/*.js"]
+	"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
 }

+ 8 - 0
tsconfig.unit-test.json

@@ -0,0 +1,8 @@
+{
+	"extends": "./tsconfig.json",
+	"compilerOptions": {
+		"module": "commonjs"
+	},
+	"include": ["test/**/*.ts"],
+	"exclude": ["node_modules"]
+}