/** * 编辑模式测试 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; import { createWholeFileEdit, createSearchReplaceEdit, createSingleSearchReplaceEdit, parseSearchReplaceBlocks, detectEditMode, normalizeSearchString, findSearchPositions, getSearchLineNumbers, validateEdit, applyEdit, applyBatchEdits, previewEdit, type SearchReplaceBlock, } from '../../src/editors/index.js'; describe('Edit Parsers', () => { describe('parseSearchReplaceBlocks', () => { it('should parse marker format blocks', () => { const content = ` <<<<<<< SEARCH old content ======= new content >>>>>>> REPLACE `; const blocks = parseSearchReplaceBlocks(content); expect(blocks).toHaveLength(1); expect(blocks[0].search).toBe('old content'); expect(blocks[0].replace).toBe('new content'); }); it('should parse multiple marker format blocks', () => { const content = ` <<<<<<< SEARCH first old ======= first new >>>>>>> REPLACE <<<<<<< SEARCH second old ======= second new >>>>>>> REPLACE `; const blocks = parseSearchReplaceBlocks(content); expect(blocks).toHaveLength(2); expect(blocks[0].search).toBe('first old'); expect(blocks[1].search).toBe('second old'); }); it('should parse JSON array format', () => { const content = JSON.stringify([ { search: 'old1', replace: 'new1' }, { search: 'old2', replace: 'new2' }, ]); const blocks = parseSearchReplaceBlocks(content); expect(blocks).toHaveLength(2); expect(blocks[0].search).toBe('old1'); expect(blocks[1].replace).toBe('new2'); }); }); describe('createWholeFileEdit', () => { it('should create whole file edit', () => { const edit = createWholeFileEdit('/test/file.ts', 'new content'); expect(edit.mode).toBe('whole'); expect(edit.filePath).toBe('/test/file.ts'); expect(edit.content).toBe('new content'); }); }); describe('createSearchReplaceEdit', () => { it('should create search-replace edit', () => { const blocks: SearchReplaceBlock[] = [ { search: 'old', replace: 'new' }, ]; const edit = createSearchReplaceEdit('/test/file.ts', blocks); expect(edit.mode).toBe('search-replace'); expect(edit.blocks).toHaveLength(1); }); }); describe('detectEditMode', () => { it('should detect diff mode', () => { const content = `diff --git a/file.txt b/file.txt @@ -1,3 +1,4 @@ line1 +new line line2`; expect(detectEditMode(content, 100)).toBe('diff'); }); it('should detect search-replace mode', () => { const content = `<<<<<<< SEARCH old ======= new >>>>>>> REPLACE`; expect(detectEditMode(content, 100)).toBe('search-replace'); }); it('should default to whole for small content', () => { const content = 'simple content'; expect(detectEditMode(content, 100)).toBe('whole'); }); }); describe('normalizeSearchString', () => { it('should normalize line endings', () => { const input = 'line1\r\nline2\rline3\n'; const result = normalizeSearchString(input); expect(result).toBe('line1\nline2\nline3\n'); }); it('should trim trailing whitespace', () => { const input = 'line1 \nline2 '; const result = normalizeSearchString(input, { trimTrailingWhitespace: true }); expect(result).toBe('line1\nline2'); }); }); describe('findSearchPositions', () => { it('should find all positions', () => { const content = 'foo bar foo baz foo'; const positions = findSearchPositions(content, 'foo'); expect(positions).toEqual([0, 8, 16]); }); it('should return empty array when not found', () => { const content = 'hello world'; const positions = findSearchPositions(content, 'xyz'); expect(positions).toEqual([]); }); }); describe('getSearchLineNumbers', () => { it('should return line numbers', () => { const content = 'line1\nfoo\nline3\nfoo'; const lines = getSearchLineNumbers(content, 'foo'); expect(lines).toEqual([2, 4]); }); }); }); describe('Edit Validator', () => { let tempDir: string; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `edit-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { // ignore } }); describe('validateEdit', () => { it('should validate whole file edit for new file', async () => { const filePath = path.join(tempDir, 'new.txt'); const edit = createWholeFileEdit(filePath, 'new content'); const result = await validateEdit(edit); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); it('should validate search-replace with unique match', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'hello world'); const edit = createSingleSearchReplaceEdit(filePath, 'hello', 'hi'); const result = await validateEdit(edit); expect(result.valid).toBe(true); }); it('should fail search-replace when not found', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'hello world'); const edit = createSingleSearchReplaceEdit(filePath, 'xyz', 'abc'); const result = await validateEdit(edit); expect(result.valid).toBe(false); expect(result.errors[0].type).toBe('not_found'); }); it('should fail search-replace when ambiguous', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'foo bar foo baz'); const edit = createSingleSearchReplaceEdit(filePath, 'foo', 'qux'); const result = await validateEdit(edit); expect(result.valid).toBe(false); expect(result.errors[0].type).toBe('ambiguous'); expect(result.errors[0].occurrences).toBe(2); }); it('should fail search-replace when file not exists', async () => { const filePath = path.join(tempDir, 'nonexistent.txt'); const edit = createSingleSearchReplaceEdit(filePath, 'old', 'new'); const result = await validateEdit(edit); expect(result.valid).toBe(false); expect(result.errors[0].type).toBe('not_found'); }); }); }); describe('Edit Applier', () => { let tempDir: string; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `edit-apply-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { // ignore } }); describe('applyEdit', () => { it('should apply whole file edit to new file', async () => { const filePath = path.join(tempDir, 'new.txt'); const edit = createWholeFileEdit(filePath, 'hello world'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.newContent).toBe('hello world'); const content = await fs.readFile(filePath, 'utf-8'); expect(content).toBe('hello world'); }); it('should apply whole file edit to existing file', async () => { const filePath = path.join(tempDir, 'existing.txt'); await fs.writeFile(filePath, 'old content'); const edit = createWholeFileEdit(filePath, 'new content'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.originalContent).toBe('old content'); expect(result.newContent).toBe('new content'); }); it('should apply single search-replace edit', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'hello world'); const edit = createSingleSearchReplaceEdit(filePath, 'world', 'universe'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.newContent).toBe('hello universe'); }); it('should apply multiple search-replace blocks', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'const foo = 1;\nconst bar = 2;'); const edit = createSearchReplaceEdit(filePath, [ { search: 'const foo = 1;', replace: 'const foo = 10;' }, { search: 'const bar = 2;', replace: 'const bar = 20;' }, ]); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.newContent).toBe('const foo = 10;\nconst bar = 20;'); }); it('should fail when search not found', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'hello world'); const edit = createSingleSearchReplaceEdit(filePath, 'xyz', 'abc'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(false); expect(result.error).toContain('验证失败'); }); it('should fail when search is ambiguous', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'foo foo foo'); const edit = createSingleSearchReplaceEdit(filePath, 'foo', 'bar'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(false); expect(result.error).toContain('验证失败'); }); }); describe('previewEdit', () => { it('should preview edit without writing', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'hello world'); const edit = createSingleSearchReplaceEdit(filePath, 'world', 'universe'); const result = await previewEdit(edit); expect(result.success).toBe(true); expect(result.newContent).toBe('hello universe'); // 文件应该保持不变 const content = await fs.readFile(filePath, 'utf-8'); expect(content).toBe('hello world'); }); }); describe('applyBatchEdits', () => { it('should apply multiple edits', async () => { const file1 = path.join(tempDir, 'file1.txt'); const file2 = path.join(tempDir, 'file2.txt'); await fs.writeFile(file1, 'content 1'); await fs.writeFile(file2, 'content 2'); const result = await applyBatchEdits({ edits: [ createWholeFileEdit(file1, 'new content 1'), createWholeFileEdit(file2, 'new content 2'), ], }, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.results).toHaveLength(2); const content1 = await fs.readFile(file1, 'utf-8'); const content2 = await fs.readFile(file2, 'utf-8'); expect(content1).toBe('new content 1'); expect(content2).toBe('new content 2'); }); it('should rollback atomic batch on failure', async () => { const file1 = path.join(tempDir, 'file1.txt'); const file2 = path.join(tempDir, 'file2.txt'); await fs.writeFile(file1, 'original 1'); await fs.writeFile(file2, 'original 2'); const result = await applyBatchEdits({ edits: [ createWholeFileEdit(file1, 'new content 1'), createSingleSearchReplaceEdit(file2, 'not found', 'replacement'), ], atomic: true, }, { runDiagnostics: false }); expect(result.success).toBe(false); // file1 应该被回滚 const content1 = await fs.readFile(file1, 'utf-8'); expect(content1).toBe('original 1'); }); it('should calculate total stats', async () => { const file1 = path.join(tempDir, 'file1.txt'); const file2 = path.join(tempDir, 'file2.txt'); await fs.writeFile(file1, 'line1\nline2'); await fs.writeFile(file2, 'a\nb\nc'); const result = await applyBatchEdits({ edits: [ createWholeFileEdit(file1, 'line1\nline2\nline3'), createWholeFileEdit(file2, 'x\ny'), ], }, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.totalStats.blocksApplied).toBe(2); }); }); }); describe('Edit Stats', () => { let tempDir: string; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `edit-stats-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { // ignore } }); it('should count additions for new file', async () => { const filePath = path.join(tempDir, 'new.txt'); const edit = createWholeFileEdit(filePath, 'line1\nline2\nline3'); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.stats?.additions).toBeGreaterThan(0); }); it('should count blocks applied', async () => { const filePath = path.join(tempDir, 'test.txt'); await fs.writeFile(filePath, 'aaa bbb ccc'); const edit = createSearchReplaceEdit(filePath, [ { search: 'aaa', replace: 'AAA' }, { search: 'ccc', replace: 'CCC' }, ]); const result = await applyEdit(edit, { runDiagnostics: false }); expect(result.success).toBe(true); expect(result.stats?.blocksApplied).toBe(2); }); });