@aigne/doc-smith 0.8.12-beta → 0.8.12-beta.2
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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/agents/generate/check-diagram.mjs +40 -0
- package/agents/generate/draw-diagram.yaml +23 -0
- package/agents/generate/generate-structure.yaml +5 -1
- package/agents/generate/merge-d2-diagram.yaml +3 -3
- package/agents/generate/update-document-structure.yaml +5 -2
- package/agents/generate/user-review-document-structure.mjs +7 -0
- package/agents/generate/wrap-diagram-code.mjs +35 -0
- package/agents/history/index.yaml +6 -0
- package/agents/history/view.mjs +75 -0
- package/agents/translate/index.yaml +3 -2
- package/agents/translate/record-translation-history.mjs +19 -0
- package/agents/translate/translate-multilingual.yaml +2 -1
- package/agents/update/batch-update-document.yaml +1 -1
- package/agents/update/check-document.mjs +5 -1
- package/agents/update/generate-document.yaml +31 -25
- package/agents/update/{generate-and-translate-document.yaml → handle-document-update.yaml} +2 -11
- package/agents/update/index.yaml +1 -0
- package/agents/update/save-and-translate-document.mjs +106 -0
- package/agents/update/update-document-detail.yaml +5 -1
- package/agents/update/update-single-document.yaml +1 -10
- package/agents/update/user-review-document.mjs +10 -1
- package/aigne.yaml +8 -1
- package/package.json +1 -1
- package/prompts/detail/d2-diagram/guide.md +19 -0
- package/prompts/detail/d2-diagram/role-and-personality.md +2 -0
- package/prompts/detail/d2-diagram/rules.md +24 -0
- package/prompts/detail/d2-diagram/{rules-system.md → system-prompt.md} +3 -9
- package/prompts/detail/{document-rules.md → generate/document-rules.md} +1 -1
- package/prompts/detail/generate/system-prompt.md +72 -0
- package/prompts/detail/generate/user-prompt.md +54 -0
- package/prompts/detail/{update-document.md → update/system-prompt.md} +43 -67
- package/prompts/detail/update/user-prompt.md +33 -0
- package/prompts/structure/{generate-structure-system.md → generate/system-prompt.md} +7 -40
- package/prompts/structure/{generate-structure-user.md → generate/user-prompt.md} +17 -13
- package/prompts/structure/{update-document-structure.md → update/system-prompt.md} +16 -27
- package/prompts/structure/update/user-prompt.md +19 -0
- package/tests/agents/generate/user-review-document-structure.test.mjs +2 -0
- package/tests/agents/update/check-document.test.mjs +1 -1
- package/tests/agents/update/save-and-translate-document.test.mjs +369 -0
- package/tests/agents/utils/check-detail-result.test.mjs +13 -0
- package/tests/utils/d2-utils.test.mjs +14 -0
- package/tests/utils/docs-finder-utils.test.mjs +13 -0
- package/tests/utils/history-utils.test.mjs +178 -0
- package/utils/d2-utils.mjs +9 -0
- package/utils/docs-finder-utils.mjs +10 -1
- package/utils/history-utils.mjs +191 -0
- package/utils/markdown-checker.mjs +20 -0
- package/agents/generate/check-d2-diagram-valid.mjs +0 -26
- package/agents/generate/generate-d2-diagram.yaml +0 -23
- package/prompts/detail/generate-document.md +0 -125
- /package/prompts/detail/d2-diagram/{rules-user.md → user-prompt.md} +0 -0
- /package/prompts/detail/{detail-example.md → generate/detail-example.md} +0 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import saveAndTranslateDocument from "../../../agents/update/save-and-translate-document.mjs";
|
|
3
|
+
import * as historyUtils from "../../../utils/history-utils.mjs";
|
|
4
|
+
|
|
5
|
+
describe("save-and-translate-document", () => {
|
|
6
|
+
let mockOptions;
|
|
7
|
+
let consoleErrorSpy;
|
|
8
|
+
let recordUpdateSpy;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Reset all mocks
|
|
12
|
+
mock.restore();
|
|
13
|
+
|
|
14
|
+
mockOptions = {
|
|
15
|
+
prompts: {
|
|
16
|
+
select: mock(async () => "no"),
|
|
17
|
+
},
|
|
18
|
+
context: {
|
|
19
|
+
agents: {
|
|
20
|
+
saveSingleDoc: { mockSaveAgent: true },
|
|
21
|
+
translateMultilingual: { mockTranslateAgent: true },
|
|
22
|
+
},
|
|
23
|
+
invoke: mock(async () => ({ mockResult: true })),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
28
|
+
recordUpdateSpy = spyOn(historyUtils, "recordUpdate").mockImplementation(() => {});
|
|
29
|
+
|
|
30
|
+
// Clear context mock call history
|
|
31
|
+
mockOptions.prompts.select.mockClear();
|
|
32
|
+
mockOptions.context.invoke.mockClear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
consoleErrorSpy?.mockRestore();
|
|
37
|
+
recordUpdateSpy?.mockRestore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// INPUT VALIDATION TESTS
|
|
41
|
+
test("should handle empty or invalid selectedDocs", async () => {
|
|
42
|
+
const testCases = [
|
|
43
|
+
{ selectedDocs: [], description: "empty array" },
|
|
44
|
+
{ selectedDocs: null, description: "null" },
|
|
45
|
+
{ selectedDocs: undefined, description: "undefined" },
|
|
46
|
+
{ selectedDocs: "not-array", description: "non-array" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const testCase of testCases) {
|
|
50
|
+
const input = {
|
|
51
|
+
selectedDocs: testCase.selectedDocs,
|
|
52
|
+
docsDir: "./docs",
|
|
53
|
+
translateLanguages: ["en", "zh"],
|
|
54
|
+
locale: "en",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result = await saveAndTranslateDocument(input, mockOptions);
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual({});
|
|
60
|
+
expect(mockOptions.context.invoke).not.toHaveBeenCalled();
|
|
61
|
+
expect(mockOptions.prompts.select).not.toHaveBeenCalled();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// SCENARIO 1: NO TRANSLATION CONFIGURATION
|
|
66
|
+
test("should skip translation when no translation languages configured", async () => {
|
|
67
|
+
const testCases = [
|
|
68
|
+
{ translateLanguages: null, description: "null" },
|
|
69
|
+
{ translateLanguages: undefined, description: "undefined" },
|
|
70
|
+
{ translateLanguages: [], description: "empty array" },
|
|
71
|
+
{ translateLanguages: ["en"], description: "only current locale" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const testCase of testCases) {
|
|
75
|
+
const input = {
|
|
76
|
+
selectedDocs: [
|
|
77
|
+
{
|
|
78
|
+
path: "/docs/test.md",
|
|
79
|
+
content: "# Test Document",
|
|
80
|
+
translates: {},
|
|
81
|
+
labels: {},
|
|
82
|
+
feedback: "Good content",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
docsDir: "./docs",
|
|
86
|
+
translateLanguages: testCase.translateLanguages,
|
|
87
|
+
locale: "en",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const result = await saveAndTranslateDocument(input, mockOptions);
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({});
|
|
93
|
+
expect(mockOptions.prompts.select).not.toHaveBeenCalled();
|
|
94
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(recordUpdateSpy).toHaveBeenCalledWith({
|
|
96
|
+
operation: "document_update",
|
|
97
|
+
feedback: "Good content",
|
|
98
|
+
documentPath: "/docs/test.md",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Reset mocks for next iteration
|
|
102
|
+
mockOptions.prompts.select.mockClear();
|
|
103
|
+
mockOptions.context.invoke.mockClear();
|
|
104
|
+
recordUpdateSpy.mockClear();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// SCENARIO 2: USER CHOOSES NOT TO TRANSLATE
|
|
109
|
+
test("should save documents and skip translation when user chooses no", async () => {
|
|
110
|
+
const input = {
|
|
111
|
+
selectedDocs: [
|
|
112
|
+
{
|
|
113
|
+
path: "/docs/test1.md",
|
|
114
|
+
content: "# Test Document 1",
|
|
115
|
+
translates: {},
|
|
116
|
+
labels: {},
|
|
117
|
+
feedback: "Update needed",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
path: "/docs/test2.md",
|
|
121
|
+
content: "# Test Document 2",
|
|
122
|
+
translates: {},
|
|
123
|
+
labels: {},
|
|
124
|
+
feedback: "Second feedback",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
path: "/docs/test3.md",
|
|
128
|
+
content: "# Test Document 3",
|
|
129
|
+
translates: {},
|
|
130
|
+
labels: {},
|
|
131
|
+
feedback: " ", // Whitespace only
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
docsDir: "./docs",
|
|
135
|
+
translateLanguages: ["en", "zh", "ja"],
|
|
136
|
+
locale: "en",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
mockOptions.prompts.select.mockResolvedValue("no");
|
|
140
|
+
|
|
141
|
+
const result = await saveAndTranslateDocument(input, mockOptions);
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual({});
|
|
144
|
+
expect(mockOptions.prompts.select).toHaveBeenCalledWith({
|
|
145
|
+
message: "Document update completed. Would you like to translate these documents now?",
|
|
146
|
+
choices: [
|
|
147
|
+
{
|
|
148
|
+
name: "Review documents first, translate later",
|
|
149
|
+
value: "no",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "Translate now",
|
|
153
|
+
value: "yes",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(3); // Only saveDocument calls
|
|
158
|
+
expect(recordUpdateSpy).toHaveBeenCalledTimes(2); // Only documents with non-empty feedback
|
|
159
|
+
expect(recordUpdateSpy).toHaveBeenCalledWith({
|
|
160
|
+
operation: "document_update",
|
|
161
|
+
feedback: "Update needed",
|
|
162
|
+
documentPath: "/docs/test1.md",
|
|
163
|
+
});
|
|
164
|
+
expect(recordUpdateSpy).toHaveBeenCalledWith({
|
|
165
|
+
operation: "document_update",
|
|
166
|
+
feedback: "Second feedback",
|
|
167
|
+
documentPath: "/docs/test2.md",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// SCENARIO 3: USER CHOOSES TO TRANSLATE
|
|
172
|
+
test("should save and translate documents when user chooses yes", async () => {
|
|
173
|
+
const input = {
|
|
174
|
+
selectedDocs: [
|
|
175
|
+
{
|
|
176
|
+
path: "/docs/test1.md",
|
|
177
|
+
content: "# Test Document 1",
|
|
178
|
+
translates: {},
|
|
179
|
+
labels: {},
|
|
180
|
+
feedback: "Translation needed",
|
|
181
|
+
title: "Test Document 1",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
path: "/docs/test2.md",
|
|
185
|
+
content: "# Test Document 2",
|
|
186
|
+
translates: {},
|
|
187
|
+
labels: {},
|
|
188
|
+
title: "Test Document 2",
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
docsDir: "./docs",
|
|
192
|
+
translateLanguages: ["en", "zh"],
|
|
193
|
+
locale: "en",
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
mockOptions.prompts.select.mockResolvedValue("yes");
|
|
197
|
+
mockOptions.context.invoke
|
|
198
|
+
.mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument 1
|
|
199
|
+
.mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument 2
|
|
200
|
+
.mockResolvedValueOnce({ translates: { zh: "# 测试文档 1" } }) // translateMultilingual 1
|
|
201
|
+
.mockResolvedValueOnce({ translates: { zh: "# 测试文档 2" } }) // translateMultilingual 2
|
|
202
|
+
.mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument with translation 1
|
|
203
|
+
.mockResolvedValueOnce({ mockSaveResult: true }); // saveDocument with translation 2
|
|
204
|
+
|
|
205
|
+
const result = await saveAndTranslateDocument(input, mockOptions);
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual({});
|
|
208
|
+
expect(mockOptions.prompts.select).toHaveBeenCalledWith({
|
|
209
|
+
message: "Document update completed. Would you like to translate these documents now?",
|
|
210
|
+
choices: [
|
|
211
|
+
{
|
|
212
|
+
name: "Review documents first, translate later",
|
|
213
|
+
value: "no",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "Translate now",
|
|
217
|
+
value: "yes",
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(6);
|
|
222
|
+
|
|
223
|
+
// Verify feedback is cleared before translation
|
|
224
|
+
expect(input.selectedDocs[0].feedback).toBe("");
|
|
225
|
+
expect(input.selectedDocs[1].feedback).toBe("");
|
|
226
|
+
|
|
227
|
+
expect(recordUpdateSpy).toHaveBeenCalledWith({
|
|
228
|
+
operation: "document_update",
|
|
229
|
+
feedback: "Translation needed",
|
|
230
|
+
documentPath: "/docs/test1.md",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ERROR HANDLING TESTS
|
|
235
|
+
test("should handle errors gracefully", async () => {
|
|
236
|
+
// Test saveDocument error
|
|
237
|
+
const saveErrorInput = {
|
|
238
|
+
selectedDocs: [
|
|
239
|
+
{
|
|
240
|
+
path: "/docs/test1.md",
|
|
241
|
+
content: "# Test Document 1",
|
|
242
|
+
translates: {},
|
|
243
|
+
labels: {},
|
|
244
|
+
feedback: "Error test",
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
docsDir: "./docs",
|
|
248
|
+
translateLanguages: ["en", "zh"],
|
|
249
|
+
locale: "en",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
mockOptions.prompts.select.mockResolvedValue("no");
|
|
253
|
+
mockOptions.context.invoke.mockRejectedValue(new Error("Save failed"));
|
|
254
|
+
|
|
255
|
+
const saveErrorResult = await saveAndTranslateDocument(saveErrorInput, mockOptions);
|
|
256
|
+
|
|
257
|
+
expect(saveErrorResult).toEqual({});
|
|
258
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
259
|
+
"❌ Failed to save document /docs/test1.md:",
|
|
260
|
+
"Save failed",
|
|
261
|
+
);
|
|
262
|
+
expect(recordUpdateSpy).not.toHaveBeenCalled(); // Should not record if save failed
|
|
263
|
+
|
|
264
|
+
// Reset mocks
|
|
265
|
+
mockOptions.prompts.select.mockClear();
|
|
266
|
+
mockOptions.context.invoke.mockClear();
|
|
267
|
+
consoleErrorSpy.mockClear();
|
|
268
|
+
recordUpdateSpy.mockClear();
|
|
269
|
+
|
|
270
|
+
// Test translateMultilingual error
|
|
271
|
+
const translateErrorInput = {
|
|
272
|
+
selectedDocs: [
|
|
273
|
+
{
|
|
274
|
+
path: "/docs/test2.md",
|
|
275
|
+
content: "# Test Document 2",
|
|
276
|
+
translates: {},
|
|
277
|
+
labels: {},
|
|
278
|
+
title: "Test Document 2",
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
docsDir: "./docs",
|
|
282
|
+
translateLanguages: ["en", "zh"],
|
|
283
|
+
locale: "en",
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
mockOptions.prompts.select.mockResolvedValue("yes");
|
|
287
|
+
mockOptions.context.invoke
|
|
288
|
+
.mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument succeeds
|
|
289
|
+
.mockRejectedValueOnce(new Error("Translation failed")); // translateMultilingual fails
|
|
290
|
+
|
|
291
|
+
const translateErrorResult = await saveAndTranslateDocument(translateErrorInput, mockOptions);
|
|
292
|
+
|
|
293
|
+
expect(translateErrorResult).toEqual({});
|
|
294
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
295
|
+
"❌ Failed to translate document /docs/test2.md:",
|
|
296
|
+
"Translation failed",
|
|
297
|
+
);
|
|
298
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// EDGE CASES AND INTEGRATION
|
|
302
|
+
test("should handle edge cases and complex scenarios", async () => {
|
|
303
|
+
// Test edge cases with different document properties
|
|
304
|
+
const edgeCaseInput = {
|
|
305
|
+
selectedDocs: [
|
|
306
|
+
{
|
|
307
|
+
path: "/docs/test1.md",
|
|
308
|
+
content: "# Test Document 1",
|
|
309
|
+
translates: null, // null translates
|
|
310
|
+
labels: {},
|
|
311
|
+
feedback: "", // empty feedback
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
path: "/docs/test2.md",
|
|
315
|
+
content: "# Test Document 2",
|
|
316
|
+
translates: undefined, // undefined translates
|
|
317
|
+
labels: {},
|
|
318
|
+
feedback: " ", // whitespace feedback
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
path: "/docs/test3.md",
|
|
322
|
+
content: "# Test Document 3",
|
|
323
|
+
translates: {},
|
|
324
|
+
labels: {},
|
|
325
|
+
// no title
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
docsDir: "./docs",
|
|
329
|
+
translateLanguages: ["en", "zh"],
|
|
330
|
+
locale: "en",
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
mockOptions.prompts.select.mockResolvedValue("no");
|
|
334
|
+
|
|
335
|
+
const edgeCaseResult = await saveAndTranslateDocument(edgeCaseInput, mockOptions);
|
|
336
|
+
|
|
337
|
+
expect(edgeCaseResult).toEqual({});
|
|
338
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(3);
|
|
339
|
+
expect(recordUpdateSpy).not.toHaveBeenCalled(); // No valid feedback
|
|
340
|
+
|
|
341
|
+
// Reset mocks
|
|
342
|
+
mockOptions.prompts.select.mockClear();
|
|
343
|
+
mockOptions.context.invoke.mockClear();
|
|
344
|
+
recordUpdateSpy.mockClear();
|
|
345
|
+
|
|
346
|
+
// Test batch processing with multiple documents
|
|
347
|
+
const batchInput = {
|
|
348
|
+
selectedDocs: Array.from({ length: 5 }, (_, i) => ({
|
|
349
|
+
path: `/docs/batch${i + 1}.md`,
|
|
350
|
+
content: `# Batch Document ${i + 1}`,
|
|
351
|
+
translates: {},
|
|
352
|
+
labels: {},
|
|
353
|
+
title: `Batch Document ${i + 1}`,
|
|
354
|
+
})),
|
|
355
|
+
docsDir: "./docs",
|
|
356
|
+
translateLanguages: ["en", "zh"],
|
|
357
|
+
locale: "en",
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
mockOptions.prompts.select.mockResolvedValue("yes");
|
|
361
|
+
mockOptions.context.invoke.mockResolvedValue({ mockResult: true });
|
|
362
|
+
|
|
363
|
+
const batchResult = await saveAndTranslateDocument(batchInput, mockOptions);
|
|
364
|
+
|
|
365
|
+
expect(batchResult).toEqual({});
|
|
366
|
+
// 5 documents * 3 calls each (save, translate, save) = 15 calls
|
|
367
|
+
expect(mockOptions.context.invoke).toHaveBeenCalledTimes(15);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -337,6 +337,19 @@ This document demonstrates various programming language code blocks.`;
|
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
describe("D2 syntax validation", () => {
|
|
341
|
+
test("should handle D2 syntax errors", async () => {
|
|
342
|
+
const documentStructure = [];
|
|
343
|
+
const reviewContent =
|
|
344
|
+
"```d2\n" +
|
|
345
|
+
"invalid d2 syntax {{\n" + // Malformed D2
|
|
346
|
+
"```\n\n" +
|
|
347
|
+
"This has proper structure.";
|
|
348
|
+
const result = await checkDetailResult({ documentStructure, reviewContent });
|
|
349
|
+
expect(result.isApproved).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
340
353
|
describe("Advanced table edge cases", () => {
|
|
341
354
|
test("should handle empty table cells correctly", async () => {
|
|
342
355
|
const documentStructure = [];
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getChart,
|
|
15
15
|
isValidCode,
|
|
16
16
|
saveAssets,
|
|
17
|
+
wrapCode,
|
|
17
18
|
} from "../../utils/d2-utils.mjs";
|
|
18
19
|
|
|
19
20
|
describe("d2-utils", () => {
|
|
@@ -420,4 +421,17 @@ E -> F
|
|
|
420
421
|
expect(isValidCode(undefined)).toBe(false);
|
|
421
422
|
});
|
|
422
423
|
});
|
|
424
|
+
|
|
425
|
+
describe("wrapCode", () => {
|
|
426
|
+
test("should return original content when D2 block already exists", () => {
|
|
427
|
+
const content = "```d2\nA -> B\n```";
|
|
428
|
+
expect(wrapCode({ content })).toBe(content);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("should wrap plain content in a D2 code block", () => {
|
|
432
|
+
const content = "A -> B";
|
|
433
|
+
const expected = "```d2\nA -> B\n```";
|
|
434
|
+
expect(wrapCode({ content })).toBe(expected);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
423
437
|
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
fileNameToFlatPath,
|
|
7
7
|
findItemByFlatName,
|
|
8
8
|
findItemByPath,
|
|
9
|
+
generateFileName,
|
|
9
10
|
getActionText,
|
|
10
11
|
getMainLanguageFiles,
|
|
11
12
|
processSelectedFiles,
|
|
@@ -633,5 +634,17 @@ describe("docs-finder-utils", () => {
|
|
|
633
634
|
"⚠️ No documentation structure item found for file: test.md",
|
|
634
635
|
);
|
|
635
636
|
});
|
|
637
|
+
|
|
638
|
+
test("fileNameToFlatPath should handle files with multiple language suffixes", () => {
|
|
639
|
+
expect(fileNameToFlatPath("file.zh-CN.md")).toBe("file");
|
|
640
|
+
expect(fileNameToFlatPath("file.en-US.md")).toBe("file");
|
|
641
|
+
expect(fileNameToFlatPath("file.fr-FR.md")).toBe("file");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("generateFileName should handle special characters in flatName", () => {
|
|
645
|
+
expect(generateFileName("api-v1-guide", "en")).toBe("api-v1-guide.md");
|
|
646
|
+
expect(generateFileName("api-v1-guide", "zh")).toBe("api-v1-guide.zh.md");
|
|
647
|
+
expect(generateFileName("test_special-chars", "fr")).toBe("test_special-chars.fr.md");
|
|
648
|
+
});
|
|
636
649
|
});
|
|
637
650
|
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DOC_SMITH_DIR } from "../../utils/constants/index.mjs";
|
|
5
|
+
import { getHistory, isGitAvailable, recordUpdate } from "../../utils/history-utils.mjs";
|
|
6
|
+
|
|
7
|
+
const TEST_DIR = join(process.cwd(), `${DOC_SMITH_DIR}-test`);
|
|
8
|
+
const ORIGINAL_CWD = process.cwd();
|
|
9
|
+
|
|
10
|
+
describe("History Utils - Unified", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Clean up test directory
|
|
13
|
+
if (existsSync(TEST_DIR)) {
|
|
14
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
17
|
+
|
|
18
|
+
// Change to test directory
|
|
19
|
+
process.chdir(TEST_DIR);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Restore original directory
|
|
24
|
+
process.chdir(ORIGINAL_CWD);
|
|
25
|
+
|
|
26
|
+
// Clean up
|
|
27
|
+
if (existsSync(TEST_DIR)) {
|
|
28
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("detects git availability", () => {
|
|
33
|
+
const hasGit = isGitAvailable();
|
|
34
|
+
expect(typeof hasGit).toBe("boolean");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("skips recording on empty feedback", () => {
|
|
38
|
+
recordUpdate({ operation: "document_update", feedback: "" });
|
|
39
|
+
const history = getHistory();
|
|
40
|
+
expect(history.entries.length).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("skips recording on whitespace-only feedback", () => {
|
|
44
|
+
recordUpdate({ operation: "document_update", feedback: " " });
|
|
45
|
+
const history = getHistory();
|
|
46
|
+
expect(history.entries.length).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("records update in YAML", () => {
|
|
50
|
+
recordUpdate({
|
|
51
|
+
operation: "structure_update",
|
|
52
|
+
feedback: "Test feedback",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const history = getHistory();
|
|
56
|
+
expect(history.entries.length).toBe(1);
|
|
57
|
+
expect(history.entries[0].feedback).toBe("Test feedback");
|
|
58
|
+
expect(history.entries[0].operation).toBe("structure_update");
|
|
59
|
+
expect(history.entries[0].timestamp).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("records document path when provided as string", () => {
|
|
63
|
+
recordUpdate({
|
|
64
|
+
operation: "document_update",
|
|
65
|
+
feedback: "Update document",
|
|
66
|
+
documentPath: "/getting-started",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const history = getHistory();
|
|
70
|
+
expect(history.entries.length).toBe(1);
|
|
71
|
+
expect(history.entries[0].documentPath).toBe("/getting-started");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("records single document path for each update", () => {
|
|
75
|
+
recordUpdate({
|
|
76
|
+
operation: "document_update",
|
|
77
|
+
feedback: "Update single document",
|
|
78
|
+
documentPath: "/getting-started",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const history = getHistory();
|
|
82
|
+
expect(history.entries.length).toBe(1);
|
|
83
|
+
expect(history.entries[0].feedback).toBe("Update single document");
|
|
84
|
+
expect(history.entries[0].documentPath).toBe("/getting-started");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("does not include documentPath field when documentPath is null", () => {
|
|
88
|
+
recordUpdate({
|
|
89
|
+
operation: "structure_update",
|
|
90
|
+
feedback: "Update structure",
|
|
91
|
+
documentPath: null,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const history = getHistory();
|
|
95
|
+
expect(history.entries.length).toBe(1);
|
|
96
|
+
expect(history.entries[0].documentPath).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("maintains chronological order (newest first)", () => {
|
|
100
|
+
recordUpdate({ operation: "structure_update", feedback: "First" });
|
|
101
|
+
// Small delay to ensure different timestamps
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
while (Date.now() === now) {
|
|
104
|
+
// Wait for next millisecond
|
|
105
|
+
}
|
|
106
|
+
recordUpdate({ operation: "document_update", feedback: "Second" });
|
|
107
|
+
|
|
108
|
+
const history = getHistory();
|
|
109
|
+
expect(history.entries.length).toBe(2);
|
|
110
|
+
expect(history.entries[0].feedback).toBe("Second");
|
|
111
|
+
expect(history.entries[1].feedback).toBe("First");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("handles multiple updates", () => {
|
|
115
|
+
recordUpdate({ operation: "structure_update", feedback: "Update 1" });
|
|
116
|
+
recordUpdate({ operation: "document_update", feedback: "Update 2", documentPath: "/home" });
|
|
117
|
+
recordUpdate({ operation: "document_update", feedback: "Update 3", documentPath: "/about" });
|
|
118
|
+
recordUpdate({
|
|
119
|
+
operation: "translation_update",
|
|
120
|
+
feedback: "Update 4",
|
|
121
|
+
documentPath: "/api",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const history = getHistory();
|
|
125
|
+
expect(history.entries.length).toBe(4);
|
|
126
|
+
expect(history.entries[0].feedback).toBe("Update 4");
|
|
127
|
+
expect(history.entries[0].documentPath).toBe("/api");
|
|
128
|
+
expect(history.entries[1].feedback).toBe("Update 3");
|
|
129
|
+
expect(history.entries[1].documentPath).toBe("/about");
|
|
130
|
+
expect(history.entries[2].feedback).toBe("Update 2");
|
|
131
|
+
expect(history.entries[2].documentPath).toBe("/home");
|
|
132
|
+
expect(history.entries[3].feedback).toBe("Update 1");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns empty history when file does not exist", () => {
|
|
136
|
+
const history = getHistory();
|
|
137
|
+
expect(history.entries).toBeDefined();
|
|
138
|
+
expect(history.entries.length).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("handles corrupted history file gracefully", () => {
|
|
142
|
+
// Create corrupted YAML file
|
|
143
|
+
const historyPath = join(process.cwd(), DOC_SMITH_DIR, "history.yaml");
|
|
144
|
+
mkdirSync(join(process.cwd(), DOC_SMITH_DIR), { recursive: true });
|
|
145
|
+
writeFileSync(historyPath, "invalid: yaml: content: [[[", "utf8");
|
|
146
|
+
|
|
147
|
+
const history = getHistory();
|
|
148
|
+
expect(history.entries).toBeDefined();
|
|
149
|
+
expect(history.entries.length).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test(`creates ${DOC_SMITH_DIR} directory if not exists`, () => {
|
|
153
|
+
recordUpdate({
|
|
154
|
+
operation: "document_update",
|
|
155
|
+
feedback: "Test",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const docSmithDir = join(process.cwd(), DOC_SMITH_DIR);
|
|
159
|
+
expect(existsSync(docSmithDir)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("timestamp is in ISO 8601 format", () => {
|
|
163
|
+
recordUpdate({
|
|
164
|
+
operation: "structure_update",
|
|
165
|
+
feedback: "Test timestamp",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const history = getHistory();
|
|
169
|
+
const timestamp = history.entries[0].timestamp;
|
|
170
|
+
|
|
171
|
+
// Validate ISO 8601 format
|
|
172
|
+
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
173
|
+
|
|
174
|
+
// Validate it's a valid date
|
|
175
|
+
const date = new Date(timestamp);
|
|
176
|
+
expect(date.toISOString()).toBe(timestamp);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/utils/d2-utils.mjs
CHANGED
|
@@ -195,3 +195,12 @@ export async function ensureTmpDir() {
|
|
|
195
195
|
export function isValidCode(lang) {
|
|
196
196
|
return lang?.toLowerCase() === "d2";
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
export function wrapCode({ content }) {
|
|
200
|
+
const matches = Array.from(content.matchAll(codeBlockRegex));
|
|
201
|
+
if (matches.length > 0) {
|
|
202
|
+
return content;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return `\`\`\`d2\n${content}\n\`\`\``;
|
|
206
|
+
}
|
|
@@ -12,13 +12,22 @@ export function getActionText(isTranslate, baseText) {
|
|
|
12
12
|
return baseText.replace("{action}", action);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Convert path to flattened name format
|
|
17
|
+
* @param {string} path - Document path (e.g., "/api/users")
|
|
18
|
+
* @returns {string} Flattened name (e.g., "api-users")
|
|
19
|
+
*/
|
|
20
|
+
export function pathToFlatName(path) {
|
|
21
|
+
return path.replace(/^\//, "").replace(/\//g, "-");
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/**
|
|
16
25
|
* Generate filename based on flattened path and locale
|
|
17
26
|
* @param {string} flatName - Flattened path name
|
|
18
27
|
* @param {string} locale - Main language locale (e.g., 'en', 'zh', 'fr')
|
|
19
28
|
* @returns {string} Generated filename
|
|
20
29
|
*/
|
|
21
|
-
function generateFileName(flatName, locale) {
|
|
30
|
+
export function generateFileName(flatName, locale) {
|
|
22
31
|
const isEnglish = locale === "en";
|
|
23
32
|
return isEnglish ? `${flatName}.md` : `${flatName}.${locale}.md`;
|
|
24
33
|
}
|