@synth-coder/memhub 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.eslintrc.cjs +45 -45
  2. package/.factory/commands/opsx-apply.md +150 -150
  3. package/.factory/commands/opsx-archive.md +155 -155
  4. package/.factory/commands/opsx-explore.md +171 -171
  5. package/.factory/commands/opsx-propose.md +104 -104
  6. package/.factory/skills/openspec-apply-change/SKILL.md +156 -156
  7. package/.factory/skills/openspec-archive-change/SKILL.md +114 -114
  8. package/.factory/skills/openspec-explore/SKILL.md +288 -288
  9. package/.factory/skills/openspec-propose/SKILL.md +110 -110
  10. package/.github/workflows/ci.yml +110 -74
  11. package/.github/workflows/release.yml +67 -0
  12. package/.iflow/commands/opsx-apply.md +152 -152
  13. package/.iflow/commands/opsx-archive.md +157 -157
  14. package/.iflow/commands/opsx-explore.md +173 -173
  15. package/.iflow/commands/opsx-propose.md +106 -106
  16. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -156
  17. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -114
  18. package/.iflow/skills/openspec-explore/SKILL.md +288 -288
  19. package/.iflow/skills/openspec-propose/SKILL.md +110 -110
  20. package/.prettierrc +11 -11
  21. package/AGENTS.md +167 -169
  22. package/README.md +276 -195
  23. package/README.zh-CN.md +245 -193
  24. package/dist/src/cli/agents/claude-code.d.ts +5 -0
  25. package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
  26. package/dist/src/cli/agents/claude-code.js +14 -0
  27. package/dist/src/cli/agents/claude-code.js.map +1 -0
  28. package/dist/src/cli/agents/cline.d.ts +5 -0
  29. package/dist/src/cli/agents/cline.d.ts.map +1 -0
  30. package/dist/src/cli/agents/cline.js +14 -0
  31. package/dist/src/cli/agents/cline.js.map +1 -0
  32. package/dist/src/cli/agents/codex.d.ts +5 -0
  33. package/dist/src/cli/agents/codex.d.ts.map +1 -0
  34. package/dist/src/cli/agents/codex.js +14 -0
  35. package/dist/src/cli/agents/codex.js.map +1 -0
  36. package/dist/src/cli/agents/cursor.d.ts +5 -0
  37. package/dist/src/cli/agents/cursor.d.ts.map +1 -0
  38. package/dist/src/cli/agents/cursor.js +14 -0
  39. package/dist/src/cli/agents/cursor.js.map +1 -0
  40. package/dist/src/cli/agents/factory-droid.d.ts +5 -0
  41. package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
  42. package/dist/src/cli/agents/factory-droid.js +14 -0
  43. package/dist/src/cli/agents/factory-droid.js.map +1 -0
  44. package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
  45. package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
  46. package/dist/src/cli/agents/gemini-cli.js +14 -0
  47. package/dist/src/cli/agents/gemini-cli.js.map +1 -0
  48. package/dist/src/cli/agents/index.d.ts +14 -0
  49. package/dist/src/cli/agents/index.d.ts.map +1 -0
  50. package/dist/src/cli/agents/index.js +30 -0
  51. package/dist/src/cli/agents/index.js.map +1 -0
  52. package/dist/src/cli/agents/windsurf.d.ts +5 -0
  53. package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
  54. package/dist/src/cli/agents/windsurf.js +14 -0
  55. package/dist/src/cli/agents/windsurf.js.map +1 -0
  56. package/dist/src/cli/index.d.ts +8 -0
  57. package/dist/src/cli/index.d.ts.map +1 -0
  58. package/dist/src/cli/index.js +168 -0
  59. package/dist/src/cli/index.js.map +1 -0
  60. package/dist/src/cli/init.d.ts +34 -0
  61. package/dist/src/cli/init.d.ts.map +1 -0
  62. package/dist/src/cli/init.js +160 -0
  63. package/dist/src/cli/init.js.map +1 -0
  64. package/dist/src/cli/instructions.d.ts +29 -0
  65. package/dist/src/cli/instructions.d.ts.map +1 -0
  66. package/dist/src/cli/instructions.js +141 -0
  67. package/dist/src/cli/instructions.js.map +1 -0
  68. package/dist/src/cli/types.d.ts +22 -0
  69. package/dist/src/cli/types.d.ts.map +1 -0
  70. package/dist/src/cli/types.js +86 -0
  71. package/dist/src/cli/types.js.map +1 -0
  72. package/dist/src/contracts/mcp.js +34 -34
  73. package/dist/src/contracts/schemas.js.map +1 -1
  74. package/dist/src/server/mcp-server.d.ts.map +1 -1
  75. package/dist/src/server/mcp-server.js +7 -14
  76. package/dist/src/server/mcp-server.js.map +1 -1
  77. package/dist/src/services/embedding-service.d.ts.map +1 -1
  78. package/dist/src/services/embedding-service.js +1 -1
  79. package/dist/src/services/embedding-service.js.map +1 -1
  80. package/dist/src/services/memory-service.d.ts.map +1 -1
  81. package/dist/src/services/memory-service.js.map +1 -1
  82. package/dist/src/storage/markdown-storage.d.ts.map +1 -1
  83. package/dist/src/storage/markdown-storage.js +1 -1
  84. package/dist/src/storage/markdown-storage.js.map +1 -1
  85. package/dist/src/storage/vector-index.d.ts.map +1 -1
  86. package/dist/src/storage/vector-index.js +4 -5
  87. package/dist/src/storage/vector-index.js.map +1 -1
  88. package/docs/README.md +21 -0
  89. package/docs/mcp-tools.md +136 -0
  90. package/docs/user-guide.md +182 -0
  91. package/package.json +61 -59
  92. package/src/cli/agents/claude-code.ts +14 -0
  93. package/src/cli/agents/cline.ts +14 -0
  94. package/src/cli/agents/codex.ts +14 -0
  95. package/src/cli/agents/cursor.ts +14 -0
  96. package/src/cli/agents/factory-droid.ts +14 -0
  97. package/src/cli/agents/gemini-cli.ts +14 -0
  98. package/src/cli/agents/index.ts +36 -0
  99. package/src/cli/agents/windsurf.ts +14 -0
  100. package/src/cli/index.ts +192 -0
  101. package/src/cli/init.ts +218 -0
  102. package/src/cli/instructions.ts +156 -0
  103. package/src/cli/types.ts +112 -0
  104. package/src/contracts/index.ts +12 -12
  105. package/src/contracts/mcp.ts +223 -223
  106. package/src/contracts/schemas.ts +307 -307
  107. package/src/contracts/types.ts +410 -410
  108. package/src/index.ts +8 -8
  109. package/src/server/index.ts +5 -5
  110. package/src/server/mcp-server.ts +169 -186
  111. package/src/services/embedding-service.ts +114 -114
  112. package/src/services/index.ts +5 -5
  113. package/src/services/memory-service.ts +656 -663
  114. package/src/storage/frontmatter-parser.ts +243 -243
  115. package/src/storage/index.ts +6 -6
  116. package/src/storage/markdown-storage.ts +228 -236
  117. package/src/storage/vector-index.ts +159 -160
  118. package/src/utils/index.ts +5 -5
  119. package/src/utils/slugify.ts +63 -63
  120. package/test/cli/init.test.ts +380 -0
  121. package/test/contracts/schemas.test.ts +313 -313
  122. package/test/contracts/types.test.ts +21 -21
  123. package/test/frontmatter-parser-more.test.ts +94 -94
  124. package/test/server/mcp-server.test.ts +211 -210
  125. package/test/services/memory-service-edge.test.ts +248 -248
  126. package/test/services/memory-service.test.ts +291 -279
  127. package/test/storage/frontmatter-parser.test.ts +223 -223
  128. package/test/storage/markdown-storage.test.ts +226 -217
  129. package/test/storage/storage-edge.test.ts +238 -238
  130. package/test/storage/vector-index.test.ts +149 -153
  131. package/test/utils/slugify-edge.test.ts +94 -94
  132. package/test/utils/slugify.test.ts +72 -68
  133. package/tsconfig.json +25 -25
  134. package/tsconfig.test.json +8 -8
  135. package/vitest.config.ts +29 -29
  136. package/docs/architecture-diagrams.md +0 -368
  137. package/docs/architecture.md +0 -381
  138. package/docs/contracts.md +0 -190
  139. package/docs/prompt-template.md +0 -33
  140. package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
  141. package/docs/proposals/proposal-close-gates.md +0 -58
  142. package/docs/tool-calling-policy.md +0 -101
  143. package/docs/vector-search.md +0 -306
@@ -1,153 +1,149 @@
1
- /**
2
- * VectorIndex Tests
3
- */
4
-
5
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
- import { mkdtempSync, rmSync } from 'fs';
7
- import { tmpdir } from 'os';
8
- import { join } from 'path';
9
- import { VectorIndex } from '../../src/storage/vector-index.js';
10
- import type { Memory } from '../../src/contracts/types.js';
11
-
12
- /** Build a random 384-dim float vector (avoids loading the real model) */
13
- function randomVec(dim = 384): number[] {
14
- const vec = Array.from({ length: dim }, () => Math.random() * 2 - 1);
15
- // L2-normalise so cosine distance is meaningful
16
- const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
17
- return vec.map(v => v / norm);
18
- }
19
-
20
- function makeMemory(overrides: Partial<Memory> = {}): Memory {
21
- return {
22
- id: 'test-id-' + Math.random().toString(36).slice(2),
23
- createdAt: new Date().toISOString(),
24
- updatedAt: new Date().toISOString(),
25
- tags: [],
26
- category: 'general',
27
- importance: 3,
28
- title: 'Test Memory',
29
- content: 'Test content',
30
- ...overrides,
31
- };
32
- }
33
-
34
- describe('VectorIndex', () => {
35
- let tempDir: string;
36
- let index: VectorIndex;
37
-
38
- beforeEach(() => {
39
- tempDir = mkdtempSync(join(tmpdir(), 'memhub-vec-test-'));
40
- index = new VectorIndex(tempDir);
41
- });
42
-
43
- afterEach(() => {
44
- rmSync(tempDir, { recursive: true, force: true });
45
- });
46
-
47
- it('should initialize without errors', async () => {
48
- await expect(index.initialize()).resolves.not.toThrow();
49
- });
50
-
51
- it('should report 0 rows after initialization', async () => {
52
- await index.initialize();
53
- expect(await index.count()).toBe(0);
54
- });
55
-
56
- it('should upsert a memory and increase count', async () => {
57
- const memory = makeMemory();
58
- const vec = randomVec();
59
- await index.upsert(memory, vec);
60
- expect(await index.count()).toBe(1);
61
- });
62
-
63
- it('should overwrite existing row on upsert (same id)', async () => {
64
- const memory = makeMemory({ id: 'fixed-id' });
65
- await index.upsert(memory, randomVec());
66
- await index.upsert({ ...memory, title: 'Updated' }, randomVec());
67
- expect(await index.count()).toBe(1);
68
- });
69
-
70
- it('should delete a row by id', async () => {
71
- const memory = makeMemory();
72
- await index.upsert(memory, randomVec());
73
- await index.delete(memory.id);
74
- expect(await index.count()).toBe(0);
75
- });
76
-
77
- it('should not throw when deleting non-existent id', async () => {
78
- await expect(index.delete('non-existent')).resolves.not.toThrow();
79
- });
80
-
81
- it('should search and return results', async () => {
82
- const vec = randomVec();
83
- const memory = makeMemory({ id: 'searchable-id' });
84
- await index.upsert(memory, vec);
85
-
86
- // Searching with the same vector should return that memory as top result
87
- const results = await index.search(vec, 5);
88
- expect(results.length).toBeGreaterThan(0);
89
- expect(results[0].id).toBe('searchable-id');
90
- });
91
-
92
- it('should return empty results when index is empty', async () => {
93
- const results = await index.search(randomVec(), 5);
94
- expect(results).toHaveLength(0);
95
- });
96
-
97
- it('should respect the limit parameter', async () => {
98
- for (let i = 0; i < 5; i++) {
99
- await index.upsert(makeMemory(), randomVec());
100
- }
101
- const results = await index.search(randomVec(), 3);
102
- expect(results.length).toBeLessThanOrEqual(3);
103
- });
104
-
105
- it('should return _distance field in results', async () => {
106
- const vec = randomVec();
107
- await index.upsert(makeMemory(), vec);
108
- const results = await index.search(vec, 1);
109
- expect(results[0]._distance).toBeDefined();
110
- expect(typeof results[0]._distance).toBe('number');
111
- });
112
-
113
- it('should handle id with single quotes (SQL injection prevention)', async () => {
114
- const maliciousId = "test-' OR '1'='1";
115
- const memory = makeMemory({ id: maliciousId });
116
- await index.upsert(memory, randomVec());
117
- expect(await index.count()).toBe(1);
118
-
119
- // Should be able to delete by the same id
120
- await index.delete(maliciousId);
121
- expect(await index.count()).toBe(0);
122
- });
123
-
124
- it('should handle id with special characters', async () => {
125
- const specialId = "id-with-'quotes'-and-more";
126
- const memory = makeMemory({ id: specialId });
127
- await index.upsert(memory, randomVec());
128
- expect(await index.count()).toBe(1);
129
-
130
- await index.delete(specialId);
131
- expect(await index.count()).toBe(0);
132
- });
133
-
134
- // Error scenario tests
135
- it('should allow concurrent initialization calls', async () => {
136
- // Multiple concurrent init calls should not cause issues
137
- const promises = [
138
- index.initialize(),
139
- index.initialize(),
140
- index.initialize(),
141
- ];
142
- await expect(Promise.all(promises)).resolves.not.toThrow();
143
- });
144
-
145
- it('should persist data across instances', async () => {
146
- const memory = makeMemory({ id: 'persistent-id' });
147
- await index.upsert(memory, randomVec());
148
-
149
- // Create a new instance pointing to the same directory
150
- const newIndex = new VectorIndex(tempDir);
151
- expect(await newIndex.count()).toBe(1);
152
- });
153
- });
1
+ /**
2
+ * VectorIndex Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { mkdtempSync, rmSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { join } from 'path';
9
+ import { VectorIndex } from '../../src/storage/vector-index.js';
10
+ import type { Memory } from '../../src/contracts/types.js';
11
+
12
+ /** Build a random 384-dim float vector (avoids loading the real model) */
13
+ function randomVec(dim = 384): number[] {
14
+ const vec = Array.from({ length: dim }, () => Math.random() * 2 - 1);
15
+ // L2-normalise so cosine distance is meaningful
16
+ const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
17
+ return vec.map(v => v / norm);
18
+ }
19
+
20
+ function makeMemory(overrides: Partial<Memory> = {}): Memory {
21
+ return {
22
+ id: 'test-id-' + Math.random().toString(36).slice(2),
23
+ createdAt: new Date().toISOString(),
24
+ updatedAt: new Date().toISOString(),
25
+ tags: [],
26
+ category: 'general',
27
+ importance: 3,
28
+ title: 'Test Memory',
29
+ content: 'Test content',
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe('VectorIndex', () => {
35
+ let tempDir: string;
36
+ let index: VectorIndex;
37
+
38
+ beforeEach(() => {
39
+ tempDir = mkdtempSync(join(tmpdir(), 'memhub-vec-test-'));
40
+ index = new VectorIndex(tempDir);
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ });
46
+
47
+ it('should initialize without errors', async () => {
48
+ await expect(index.initialize()).resolves.not.toThrow();
49
+ });
50
+
51
+ it('should report 0 rows after initialization', async () => {
52
+ await index.initialize();
53
+ expect(await index.count()).toBe(0);
54
+ });
55
+
56
+ it('should upsert a memory and increase count', async () => {
57
+ const memory = makeMemory();
58
+ const vec = randomVec();
59
+ await index.upsert(memory, vec);
60
+ expect(await index.count()).toBe(1);
61
+ });
62
+
63
+ it('should overwrite existing row on upsert (same id)', async () => {
64
+ const memory = makeMemory({ id: 'fixed-id' });
65
+ await index.upsert(memory, randomVec());
66
+ await index.upsert({ ...memory, title: 'Updated' }, randomVec());
67
+ expect(await index.count()).toBe(1);
68
+ });
69
+
70
+ it('should delete a row by id', async () => {
71
+ const memory = makeMemory();
72
+ await index.upsert(memory, randomVec());
73
+ await index.delete(memory.id);
74
+ expect(await index.count()).toBe(0);
75
+ });
76
+
77
+ it('should not throw when deleting non-existent id', async () => {
78
+ await expect(index.delete('non-existent')).resolves.not.toThrow();
79
+ });
80
+
81
+ it('should search and return results', async () => {
82
+ const vec = randomVec();
83
+ const memory = makeMemory({ id: 'searchable-id' });
84
+ await index.upsert(memory, vec);
85
+
86
+ // Searching with the same vector should return that memory as top result
87
+ const results = await index.search(vec, 5);
88
+ expect(results.length).toBeGreaterThan(0);
89
+ expect(results[0].id).toBe('searchable-id');
90
+ });
91
+
92
+ it('should return empty results when index is empty', async () => {
93
+ const results = await index.search(randomVec(), 5);
94
+ expect(results).toHaveLength(0);
95
+ });
96
+
97
+ it('should respect the limit parameter', async () => {
98
+ for (let i = 0; i < 5; i++) {
99
+ await index.upsert(makeMemory(), randomVec());
100
+ }
101
+ const results = await index.search(randomVec(), 3);
102
+ expect(results.length).toBeLessThanOrEqual(3);
103
+ });
104
+
105
+ it('should return _distance field in results', async () => {
106
+ const vec = randomVec();
107
+ await index.upsert(makeMemory(), vec);
108
+ const results = await index.search(vec, 1);
109
+ expect(results[0]._distance).toBeDefined();
110
+ expect(typeof results[0]._distance).toBe('number');
111
+ });
112
+
113
+ it('should handle id with single quotes (SQL injection prevention)', async () => {
114
+ const maliciousId = "test-' OR '1'='1";
115
+ const memory = makeMemory({ id: maliciousId });
116
+ await index.upsert(memory, randomVec());
117
+ expect(await index.count()).toBe(1);
118
+
119
+ // Should be able to delete by the same id
120
+ await index.delete(maliciousId);
121
+ expect(await index.count()).toBe(0);
122
+ });
123
+
124
+ it('should handle id with special characters', async () => {
125
+ const specialId = "id-with-'quotes'-and-more";
126
+ const memory = makeMemory({ id: specialId });
127
+ await index.upsert(memory, randomVec());
128
+ expect(await index.count()).toBe(1);
129
+
130
+ await index.delete(specialId);
131
+ expect(await index.count()).toBe(0);
132
+ });
133
+
134
+ // Error scenario tests
135
+ it('should allow concurrent initialization calls', async () => {
136
+ // Multiple concurrent init calls should not cause issues
137
+ const promises = [index.initialize(), index.initialize(), index.initialize()];
138
+ await expect(Promise.all(promises)).resolves.not.toThrow();
139
+ });
140
+
141
+ it('should persist data across instances', async () => {
142
+ const memory = makeMemory({ id: 'persistent-id' });
143
+ await index.upsert(memory, randomVec());
144
+
145
+ // Create a new instance pointing to the same directory
146
+ const newIndex = new VectorIndex(tempDir);
147
+ expect(await newIndex.count()).toBe(1);
148
+ });
149
+ });
@@ -1,94 +1,94 @@
1
- /**
2
- * Slugify Edge Case Tests
3
- * Additional tests for better coverage
4
- */
5
-
6
- import { describe, it, expect } from 'vitest';
7
- import { slugify, generateUniqueSlug } from '../../src/utils/slugify.js';
8
-
9
- describe('slugify edge cases', () => {
10
- it('should handle whitespace-only string', () => {
11
- expect(slugify(' ')).toBe('untitled');
12
- });
13
-
14
- it('should handle multiple consecutive spaces', () => {
15
- expect(slugify('hello world')).toBe('hello-world');
16
- });
17
-
18
- it('should handle string with only special characters', () => {
19
- expect(slugify('!@#$%^&*()')).toBe('untitled');
20
- });
21
-
22
- it('should handle mixed alphanumeric with special chars', () => {
23
- expect(slugify('hello123!@#world456')).toBe('hello123world456');
24
- });
25
-
26
- it('should handle string starting with numbers', () => {
27
- expect(slugify('123hello')).toBe('123hello');
28
- });
29
-
30
- it('should handle very short string', () => {
31
- expect(slugify('a')).toBe('a');
32
- });
33
-
34
- it('should handle string at exactly max length', () => {
35
- const input = 'a'.repeat(100);
36
- expect(slugify(input)).toBe(input);
37
- });
38
-
39
- it('should handle string just over max length', () => {
40
- const input = 'a'.repeat(101);
41
- expect(slugify(input).length).toBeLessThanOrEqual(100);
42
- });
43
-
44
- it('should handle string with hyphens at boundaries', () => {
45
- expect(slugify('-hello-world-')).toBe('hello-world');
46
- });
47
-
48
- it('should handle string with multiple consecutive hyphens after cleaning', () => {
49
- expect(slugify('hello---world')).toBe('hello-world');
50
- });
51
-
52
- it('should handle null-like empty string', () => {
53
- expect(slugify('')).toBe('untitled');
54
- });
55
-
56
- it('should preserve numbers in slug', () => {
57
- expect(slugify('version 2.0 release')).toBe('version-20-release');
58
- });
59
- });
60
-
61
- describe('generateUniqueSlug edge cases', () => {
62
- it('should handle empty existing slugs array', () => {
63
- const result = generateUniqueSlug('test', []);
64
- expect(result).toBe('test');
65
- });
66
-
67
- it('should handle when base slug plus counter would exceed limit', () => {
68
- const longBase = 'a'.repeat(98);
69
- const existing = [longBase];
70
- const result = generateUniqueSlug(longBase, existing);
71
- expect(result.length).toBeLessThanOrEqual(100);
72
- expect(result).toMatch(/^a+-\d+$/);
73
- });
74
-
75
- it('should handle multiple conflicts', () => {
76
- const existing = ['test', 'test-1', 'test-2', 'test-3'];
77
- const result = generateUniqueSlug('test', existing);
78
- expect(result).toBe('test-4');
79
- });
80
-
81
- it('should handle slug that looks like a numbered conflict', () => {
82
- const existing = ['test-1'];
83
- // 'test' is not in existing, so it should return 'test'
84
- const result = generateUniqueSlug('test', existing);
85
- expect(result).toBe('test');
86
- });
87
-
88
- it('should handle case sensitivity', () => {
89
- const existing = ['Test'];
90
- const result = generateUniqueSlug('test', existing);
91
- // Should not conflict because 'test' !== 'Test'
92
- expect(result).toBe('test');
93
- });
94
- });
1
+ /**
2
+ * Slugify Edge Case Tests
3
+ * Additional tests for better coverage
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { slugify, generateUniqueSlug } from '../../src/utils/slugify.js';
8
+
9
+ describe('slugify edge cases', () => {
10
+ it('should handle whitespace-only string', () => {
11
+ expect(slugify(' ')).toBe('untitled');
12
+ });
13
+
14
+ it('should handle multiple consecutive spaces', () => {
15
+ expect(slugify('hello world')).toBe('hello-world');
16
+ });
17
+
18
+ it('should handle string with only special characters', () => {
19
+ expect(slugify('!@#$%^&*()')).toBe('untitled');
20
+ });
21
+
22
+ it('should handle mixed alphanumeric with special chars', () => {
23
+ expect(slugify('hello123!@#world456')).toBe('hello123world456');
24
+ });
25
+
26
+ it('should handle string starting with numbers', () => {
27
+ expect(slugify('123hello')).toBe('123hello');
28
+ });
29
+
30
+ it('should handle very short string', () => {
31
+ expect(slugify('a')).toBe('a');
32
+ });
33
+
34
+ it('should handle string at exactly max length', () => {
35
+ const input = 'a'.repeat(100);
36
+ expect(slugify(input)).toBe(input);
37
+ });
38
+
39
+ it('should handle string just over max length', () => {
40
+ const input = 'a'.repeat(101);
41
+ expect(slugify(input).length).toBeLessThanOrEqual(100);
42
+ });
43
+
44
+ it('should handle string with hyphens at boundaries', () => {
45
+ expect(slugify('-hello-world-')).toBe('hello-world');
46
+ });
47
+
48
+ it('should handle string with multiple consecutive hyphens after cleaning', () => {
49
+ expect(slugify('hello---world')).toBe('hello-world');
50
+ });
51
+
52
+ it('should handle null-like empty string', () => {
53
+ expect(slugify('')).toBe('untitled');
54
+ });
55
+
56
+ it('should preserve numbers in slug', () => {
57
+ expect(slugify('version 2.0 release')).toBe('version-20-release');
58
+ });
59
+ });
60
+
61
+ describe('generateUniqueSlug edge cases', () => {
62
+ it('should handle empty existing slugs array', () => {
63
+ const result = generateUniqueSlug('test', []);
64
+ expect(result).toBe('test');
65
+ });
66
+
67
+ it('should handle when base slug plus counter would exceed limit', () => {
68
+ const longBase = 'a'.repeat(98);
69
+ const existing = [longBase];
70
+ const result = generateUniqueSlug(longBase, existing);
71
+ expect(result.length).toBeLessThanOrEqual(100);
72
+ expect(result).toMatch(/^a+-\d+$/);
73
+ });
74
+
75
+ it('should handle multiple conflicts', () => {
76
+ const existing = ['test', 'test-1', 'test-2', 'test-3'];
77
+ const result = generateUniqueSlug('test', existing);
78
+ expect(result).toBe('test-4');
79
+ });
80
+
81
+ it('should handle slug that looks like a numbered conflict', () => {
82
+ const existing = ['test-1'];
83
+ // 'test' is not in existing, so it should return 'test'
84
+ const result = generateUniqueSlug('test', existing);
85
+ expect(result).toBe('test');
86
+ });
87
+
88
+ it('should handle case sensitivity', () => {
89
+ const existing = ['Test'];
90
+ const result = generateUniqueSlug('test', existing);
91
+ // Should not conflict because 'test' !== 'Test'
92
+ expect(result).toBe('test');
93
+ });
94
+ });
@@ -1,68 +1,72 @@
1
- /**
2
- * Slugify Utility Tests
3
- * Tests for the slug generation utility
4
- */
5
-
6
- import { describe, it, expect } from 'vitest';
7
- import { slugify, generateUniqueSlug } from '../../src/utils/slugify.js';
8
-
9
- describe('slugify', () => {
10
- it('should convert to lowercase', () => {
11
- expect(slugify('HELLO')).toBe('hello');
12
- });
13
-
14
- it('should replace spaces with hyphens', () => {
15
- expect(slugify('hello world')).toBe('hello-world');
16
- });
17
-
18
- it('should remove special characters', () => {
19
- expect(slugify('hello!@#world')).toBe('helloworld');
20
- });
21
-
22
- it('should collapse multiple hyphens', () => {
23
- expect(slugify('hello---world')).toBe('hello-world');
24
- });
25
-
26
- it('should trim leading/trailing hyphens', () => {
27
- expect(slugify('-hello-world-')).toBe('hello-world');
28
- });
29
-
30
- it('should handle empty string', () => {
31
- expect(slugify('')).toBe('untitled');
32
- });
33
-
34
- it('should handle strings that become empty after cleaning', () => {
35
- expect(slugify('!@#$%')).toBe('untitled');
36
- });
37
-
38
- it('should truncate to max length', () => {
39
- const long = 'a'.repeat(200);
40
- const result = slugify(long);
41
- expect(result.length).toBeLessThanOrEqual(100);
42
- });
43
-
44
- it('should handle Chinese characters', () => {
45
- expect(slugify('你好世界')).toMatch(/^[a-z0-9-]*$/);
46
- });
47
-
48
- it('should handle mixed content', () => {
49
- expect(slugify('Hello 世界! World')).toMatch(/^[a-z0-9-]+$/);
50
- });
51
- });
52
-
53
- describe('generateUniqueSlug', () => {
54
- it('should return same slug when no conflicts', () => {
55
- const result = generateUniqueSlug('hello world', []);
56
- expect(result).toBe('hello-world');
57
- });
58
-
59
- it('should append counter when slug exists', () => {
60
- const result = generateUniqueSlug('hello world', ['hello-world']);
61
- expect(result).toBe('hello-world-1');
62
- });
63
-
64
- it('should increment counter until unique', () => {
65
- const result = generateUniqueSlug('hello world', ['hello-world', 'hello-world-1', 'hello-world-2']);
66
- expect(result).toBe('hello-world-3');
67
- });
68
- });
1
+ /**
2
+ * Slugify Utility Tests
3
+ * Tests for the slug generation utility
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { slugify, generateUniqueSlug } from '../../src/utils/slugify.js';
8
+
9
+ describe('slugify', () => {
10
+ it('should convert to lowercase', () => {
11
+ expect(slugify('HELLO')).toBe('hello');
12
+ });
13
+
14
+ it('should replace spaces with hyphens', () => {
15
+ expect(slugify('hello world')).toBe('hello-world');
16
+ });
17
+
18
+ it('should remove special characters', () => {
19
+ expect(slugify('hello!@#world')).toBe('helloworld');
20
+ });
21
+
22
+ it('should collapse multiple hyphens', () => {
23
+ expect(slugify('hello---world')).toBe('hello-world');
24
+ });
25
+
26
+ it('should trim leading/trailing hyphens', () => {
27
+ expect(slugify('-hello-world-')).toBe('hello-world');
28
+ });
29
+
30
+ it('should handle empty string', () => {
31
+ expect(slugify('')).toBe('untitled');
32
+ });
33
+
34
+ it('should handle strings that become empty after cleaning', () => {
35
+ expect(slugify('!@#$%')).toBe('untitled');
36
+ });
37
+
38
+ it('should truncate to max length', () => {
39
+ const long = 'a'.repeat(200);
40
+ const result = slugify(long);
41
+ expect(result.length).toBeLessThanOrEqual(100);
42
+ });
43
+
44
+ it('should handle Chinese characters', () => {
45
+ expect(slugify('你好世界')).toMatch(/^[a-z0-9-]*$/);
46
+ });
47
+
48
+ it('should handle mixed content', () => {
49
+ expect(slugify('Hello 世界! World')).toMatch(/^[a-z0-9-]+$/);
50
+ });
51
+ });
52
+
53
+ describe('generateUniqueSlug', () => {
54
+ it('should return same slug when no conflicts', () => {
55
+ const result = generateUniqueSlug('hello world', []);
56
+ expect(result).toBe('hello-world');
57
+ });
58
+
59
+ it('should append counter when slug exists', () => {
60
+ const result = generateUniqueSlug('hello world', ['hello-world']);
61
+ expect(result).toBe('hello-world-1');
62
+ });
63
+
64
+ it('should increment counter until unique', () => {
65
+ const result = generateUniqueSlug('hello world', [
66
+ 'hello-world',
67
+ 'hello-world-1',
68
+ 'hello-world-2',
69
+ ]);
70
+ expect(result).toBe('hello-world-3');
71
+ });
72
+ });