mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): implement v0.1.0 foundation
- Project setup with tsup, vitest, ESM support - Domain entities: Session, Project - Value objects: FileData, FileAST, FileMeta, ChatMessage, ToolCall, ToolResult, UndoEntry - Service interfaces: IStorage, ILLMClient, ITool, IIndexer, IToolRegistry - Shared: Config (zod), IpuaroError, utils (hash, tokens), Result type - CLI with placeholder commands (start, init, index) - 91 unit tests with 100% coverage - Fix package scope @puaros -> @samiyev in CLAUDE.md
This commit is contained in:
106
packages/ipuaro/tests/unit/domain/entities/Project.test.ts
Normal file
106
packages/ipuaro/tests/unit/domain/entities/Project.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Project } from "../../../../src/domain/entities/Project.js"
|
||||
|
||||
describe("Project", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create project with generated name", () => {
|
||||
const project = new Project("/home/user/projects/myapp")
|
||||
|
||||
expect(project.rootPath).toBe("/home/user/projects/myapp")
|
||||
expect(project.name).toBe("projects-myapp")
|
||||
expect(project.createdAt).toBe(Date.now())
|
||||
expect(project.lastIndexedAt).toBeNull()
|
||||
expect(project.fileCount).toBe(0)
|
||||
expect(project.indexingInProgress).toBe(false)
|
||||
})
|
||||
|
||||
it("should accept custom createdAt", () => {
|
||||
const customTime = 1000000
|
||||
const project = new Project("/path", customTime)
|
||||
|
||||
expect(project.createdAt).toBe(customTime)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateProjectName", () => {
|
||||
it("should generate name from parent and project folder", () => {
|
||||
expect(Project.generateProjectName("/home/user/projects/myapp")).toBe("projects-myapp")
|
||||
})
|
||||
|
||||
it("should handle root-level project", () => {
|
||||
expect(Project.generateProjectName("/myapp")).toBe("myapp")
|
||||
})
|
||||
})
|
||||
|
||||
describe("indexing lifecycle", () => {
|
||||
it("should mark indexing started", () => {
|
||||
const project = new Project("/path")
|
||||
|
||||
project.markIndexingStarted()
|
||||
|
||||
expect(project.indexingInProgress).toBe(true)
|
||||
})
|
||||
|
||||
it("should mark indexing completed", () => {
|
||||
const project = new Project("/path")
|
||||
project.markIndexingStarted()
|
||||
|
||||
project.markIndexingCompleted(100)
|
||||
|
||||
expect(project.indexingInProgress).toBe(false)
|
||||
expect(project.lastIndexedAt).toBe(Date.now())
|
||||
expect(project.fileCount).toBe(100)
|
||||
})
|
||||
|
||||
it("should mark indexing failed", () => {
|
||||
const project = new Project("/path")
|
||||
project.markIndexingStarted()
|
||||
|
||||
project.markIndexingFailed()
|
||||
|
||||
expect(project.indexingInProgress).toBe(false)
|
||||
expect(project.lastIndexedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isIndexed", () => {
|
||||
it("should return false when not indexed", () => {
|
||||
const project = new Project("/path")
|
||||
|
||||
expect(project.isIndexed()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when indexed", () => {
|
||||
const project = new Project("/path")
|
||||
project.markIndexingCompleted(10)
|
||||
|
||||
expect(project.isIndexed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTimeSinceIndexed", () => {
|
||||
it("should return null when not indexed", () => {
|
||||
const project = new Project("/path")
|
||||
|
||||
expect(project.getTimeSinceIndexed()).toBeNull()
|
||||
})
|
||||
|
||||
it("should return time since last indexed", () => {
|
||||
const project = new Project("/path")
|
||||
project.markIndexingCompleted(10)
|
||||
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
expect(project.getTimeSinceIndexed()).toBe(5000)
|
||||
})
|
||||
})
|
||||
})
|
||||
165
packages/ipuaro/tests/unit/domain/entities/Session.test.ts
Normal file
165
packages/ipuaro/tests/unit/domain/entities/Session.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Session } from "../../../../src/domain/entities/Session.js"
|
||||
import { createUserMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||
|
||||
describe("Session", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should create session with defaults", () => {
|
||||
const session = new Session("session-1", "test-project")
|
||||
|
||||
expect(session.id).toBe("session-1")
|
||||
expect(session.projectName).toBe("test-project")
|
||||
expect(session.history).toEqual([])
|
||||
expect(session.undoStack).toEqual([])
|
||||
expect(session.stats.totalTokens).toBe(0)
|
||||
})
|
||||
|
||||
describe("addMessage", () => {
|
||||
it("should add message to history", () => {
|
||||
const session = new Session("1", "proj")
|
||||
const msg = createUserMessage("Hello")
|
||||
|
||||
session.addMessage(msg)
|
||||
|
||||
expect(session.history).toHaveLength(1)
|
||||
expect(session.history[0]).toBe(msg)
|
||||
})
|
||||
|
||||
it("should update stats from message", () => {
|
||||
const session = new Session("1", "proj")
|
||||
const msg = {
|
||||
role: "assistant" as const,
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
stats: { tokens: 50, timeMs: 100, toolCalls: 2 },
|
||||
}
|
||||
|
||||
session.addMessage(msg)
|
||||
|
||||
expect(session.stats.totalTokens).toBe(50)
|
||||
expect(session.stats.totalTimeMs).toBe(100)
|
||||
expect(session.stats.toolCalls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("undoStack", () => {
|
||||
it("should add undo entry", () => {
|
||||
const session = new Session("1", "proj")
|
||||
const entry: UndoEntry = {
|
||||
id: "undo-1",
|
||||
timestamp: Date.now(),
|
||||
filePath: "test.ts",
|
||||
previousContent: ["old"],
|
||||
newContent: ["new"],
|
||||
description: "Edit",
|
||||
}
|
||||
|
||||
session.addUndoEntry(entry)
|
||||
|
||||
expect(session.undoStack).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should limit undo stack size", () => {
|
||||
const session = new Session("1", "proj")
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
session.addUndoEntry({
|
||||
id: `undo-${i}`,
|
||||
timestamp: Date.now(),
|
||||
filePath: "test.ts",
|
||||
previousContent: [],
|
||||
newContent: [],
|
||||
description: `Edit ${i}`,
|
||||
})
|
||||
}
|
||||
|
||||
expect(session.undoStack).toHaveLength(10)
|
||||
expect(session.undoStack[0].id).toBe("undo-5")
|
||||
})
|
||||
|
||||
it("should pop undo entry", () => {
|
||||
const session = new Session("1", "proj")
|
||||
const entry: UndoEntry = {
|
||||
id: "undo-1",
|
||||
timestamp: Date.now(),
|
||||
filePath: "test.ts",
|
||||
previousContent: [],
|
||||
newContent: [],
|
||||
description: "Edit",
|
||||
}
|
||||
|
||||
session.addUndoEntry(entry)
|
||||
const popped = session.popUndoEntry()
|
||||
|
||||
expect(popped).toBe(entry)
|
||||
expect(session.undoStack).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("inputHistory", () => {
|
||||
it("should add input to history", () => {
|
||||
const session = new Session("1", "proj")
|
||||
|
||||
session.addInputToHistory("command 1")
|
||||
session.addInputToHistory("command 2")
|
||||
|
||||
expect(session.inputHistory).toEqual(["command 1", "command 2"])
|
||||
})
|
||||
|
||||
it("should not add duplicate consecutive inputs", () => {
|
||||
const session = new Session("1", "proj")
|
||||
|
||||
session.addInputToHistory("command")
|
||||
session.addInputToHistory("command")
|
||||
|
||||
expect(session.inputHistory).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should not add empty inputs", () => {
|
||||
const session = new Session("1", "proj")
|
||||
|
||||
session.addInputToHistory("")
|
||||
session.addInputToHistory(" ")
|
||||
|
||||
expect(session.inputHistory).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearHistory", () => {
|
||||
it("should clear history and context", () => {
|
||||
const session = new Session("1", "proj")
|
||||
session.addMessage(createUserMessage("Hello"))
|
||||
session.context.filesInContext = ["file1.ts"]
|
||||
|
||||
session.clearHistory()
|
||||
|
||||
expect(session.history).toHaveLength(0)
|
||||
expect(session.context.filesInContext).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSessionDurationFormatted", () => {
|
||||
it("should format minutes only", () => {
|
||||
const session = new Session("1", "proj")
|
||||
vi.advanceTimersByTime(15 * 60 * 1000)
|
||||
|
||||
expect(session.getSessionDurationFormatted()).toBe("15m")
|
||||
})
|
||||
|
||||
it("should format hours and minutes", () => {
|
||||
const session = new Session("1", "proj")
|
||||
vi.advanceTimersByTime(90 * 60 * 1000)
|
||||
|
||||
expect(session.getSessionDurationFormatted()).toBe("1h 30m")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
createUserMessage,
|
||||
createAssistantMessage,
|
||||
createToolMessage,
|
||||
createSystemMessage,
|
||||
} from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||
|
||||
describe("ChatMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("createUserMessage", () => {
|
||||
it("should create user message", () => {
|
||||
const msg = createUserMessage("Hello")
|
||||
|
||||
expect(msg.role).toBe("user")
|
||||
expect(msg.content).toBe("Hello")
|
||||
expect(msg.timestamp).toBe(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAssistantMessage", () => {
|
||||
it("should create assistant message without tool calls", () => {
|
||||
const msg = createAssistantMessage("Response")
|
||||
|
||||
expect(msg.role).toBe("assistant")
|
||||
expect(msg.content).toBe("Response")
|
||||
expect(msg.toolCalls).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create assistant message with tool calls", () => {
|
||||
const toolCalls = [
|
||||
{ id: "1", name: "get_lines", params: {}, timestamp: Date.now() },
|
||||
]
|
||||
const stats = { tokens: 100, timeMs: 500, toolCalls: 1 }
|
||||
const msg = createAssistantMessage("Response", toolCalls, stats)
|
||||
|
||||
expect(msg.toolCalls).toEqual(toolCalls)
|
||||
expect(msg.stats).toEqual(stats)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createToolMessage", () => {
|
||||
it("should create tool message with results", () => {
|
||||
const results = [
|
||||
{ callId: "1", success: true, data: "data", executionTimeMs: 10 },
|
||||
]
|
||||
const msg = createToolMessage(results)
|
||||
|
||||
expect(msg.role).toBe("tool")
|
||||
expect(msg.toolResults).toEqual(results)
|
||||
expect(msg.content).toContain("[1] Success")
|
||||
})
|
||||
|
||||
it("should format error results", () => {
|
||||
const results = [
|
||||
{ callId: "2", success: false, error: "Not found", executionTimeMs: 5 },
|
||||
]
|
||||
const msg = createToolMessage(results)
|
||||
|
||||
expect(msg.content).toContain("[2] Error: Not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSystemMessage", () => {
|
||||
it("should create system message", () => {
|
||||
const msg = createSystemMessage("System prompt")
|
||||
|
||||
expect(msg.role).toBe("system")
|
||||
expect(msg.content).toBe("System prompt")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { createEmptyFileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||
|
||||
describe("FileAST", () => {
|
||||
describe("createEmptyFileAST", () => {
|
||||
it("should create empty AST with all arrays empty", () => {
|
||||
const ast = createEmptyFileAST()
|
||||
|
||||
expect(ast.imports).toEqual([])
|
||||
expect(ast.exports).toEqual([])
|
||||
expect(ast.functions).toEqual([])
|
||||
expect(ast.classes).toEqual([])
|
||||
expect(ast.interfaces).toEqual([])
|
||||
expect(ast.typeAliases).toEqual([])
|
||||
expect(ast.parseError).toBe(false)
|
||||
expect(ast.parseErrorMessage).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createFileData,
|
||||
isFileDataEqual,
|
||||
} from "../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
describe("FileData", () => {
|
||||
describe("createFileData", () => {
|
||||
it("should create FileData with all fields", () => {
|
||||
const lines = ["line1", "line2"]
|
||||
const hash = "abc123"
|
||||
const size = 100
|
||||
const lastModified = Date.now()
|
||||
|
||||
const result = createFileData(lines, hash, size, lastModified)
|
||||
|
||||
expect(result.lines).toEqual(lines)
|
||||
expect(result.hash).toBe(hash)
|
||||
expect(result.size).toBe(size)
|
||||
expect(result.lastModified).toBe(lastModified)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isFileDataEqual", () => {
|
||||
it("should return true for equal hashes", () => {
|
||||
const a = createFileData(["a"], "hash1", 1, 1)
|
||||
const b = createFileData(["b"], "hash1", 2, 2)
|
||||
|
||||
expect(isFileDataEqual(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for different hashes", () => {
|
||||
const a = createFileData(["a"], "hash1", 1, 1)
|
||||
const b = createFileData(["a"], "hash2", 1, 1)
|
||||
|
||||
expect(isFileDataEqual(a, b)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createFileMeta,
|
||||
isHubFile,
|
||||
} from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
describe("FileMeta", () => {
|
||||
describe("createFileMeta", () => {
|
||||
it("should create FileMeta with defaults", () => {
|
||||
const meta = createFileMeta()
|
||||
|
||||
expect(meta.complexity.loc).toBe(0)
|
||||
expect(meta.complexity.nesting).toBe(0)
|
||||
expect(meta.complexity.cyclomaticComplexity).toBe(1)
|
||||
expect(meta.complexity.score).toBe(0)
|
||||
expect(meta.dependencies).toEqual([])
|
||||
expect(meta.dependents).toEqual([])
|
||||
expect(meta.isHub).toBe(false)
|
||||
expect(meta.isEntryPoint).toBe(false)
|
||||
expect(meta.fileType).toBe("unknown")
|
||||
})
|
||||
|
||||
it("should merge partial values", () => {
|
||||
const meta = createFileMeta({
|
||||
isHub: true,
|
||||
fileType: "source",
|
||||
dependencies: ["dep1.ts"],
|
||||
})
|
||||
|
||||
expect(meta.isHub).toBe(true)
|
||||
expect(meta.fileType).toBe("source")
|
||||
expect(meta.dependencies).toEqual(["dep1.ts"])
|
||||
expect(meta.dependents).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isHubFile", () => {
|
||||
it("should return true for >5 dependents", () => {
|
||||
expect(isHubFile(6)).toBe(true)
|
||||
expect(isHubFile(10)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for <=5 dependents", () => {
|
||||
expect(isHubFile(5)).toBe(false)
|
||||
expect(isHubFile(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { createToolCall } from "../../../../src/domain/value-objects/ToolCall.js"
|
||||
|
||||
describe("ToolCall", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("createToolCall", () => {
|
||||
it("should create tool call with all fields", () => {
|
||||
const params = { path: "test.ts", line: 10 }
|
||||
const call = createToolCall("call-1", "get_lines", params)
|
||||
|
||||
expect(call.id).toBe("call-1")
|
||||
expect(call.name).toBe("get_lines")
|
||||
expect(call.params).toEqual(params)
|
||||
expect(call.timestamp).toBe(Date.now())
|
||||
})
|
||||
|
||||
it("should handle empty params", () => {
|
||||
const call = createToolCall("call-2", "git_status", {})
|
||||
|
||||
expect(call.params).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createSuccessResult,
|
||||
createErrorResult,
|
||||
} from "../../../../src/domain/value-objects/ToolResult.js"
|
||||
|
||||
describe("ToolResult", () => {
|
||||
describe("createSuccessResult", () => {
|
||||
it("should create success result", () => {
|
||||
const data = { lines: ["line1", "line2"] }
|
||||
const result = createSuccessResult("call-1", data, 50)
|
||||
|
||||
expect(result.callId).toBe("call-1")
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toEqual(data)
|
||||
expect(result.executionTimeMs).toBe(50)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createErrorResult", () => {
|
||||
it("should create error result", () => {
|
||||
const result = createErrorResult("call-2", "File not found", 10)
|
||||
|
||||
expect(result.callId).toBe("call-2")
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File not found")
|
||||
expect(result.executionTimeMs).toBe(10)
|
||||
expect(result.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
createUndoEntry,
|
||||
canUndo,
|
||||
} from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||
|
||||
describe("UndoEntry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("createUndoEntry", () => {
|
||||
it("should create undo entry with all fields", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old line"],
|
||||
["new line"],
|
||||
"Edit line 1"
|
||||
)
|
||||
|
||||
expect(entry.id).toBe("undo-1")
|
||||
expect(entry.filePath).toBe("test.ts")
|
||||
expect(entry.previousContent).toEqual(["old line"])
|
||||
expect(entry.newContent).toEqual(["new line"])
|
||||
expect(entry.description).toBe("Edit line 1")
|
||||
expect(entry.timestamp).toBe(Date.now())
|
||||
expect(entry.toolCallId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create undo entry with toolCallId", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-2",
|
||||
"test.ts",
|
||||
[],
|
||||
[],
|
||||
"Create file",
|
||||
"tool-123"
|
||||
)
|
||||
|
||||
expect(entry.toolCallId).toBe("tool-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("canUndo", () => {
|
||||
it("should return true when current content matches newContent", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
|
||||
expect(canUndo(entry, ["new"])).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when content differs", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
|
||||
expect(canUndo(entry, ["modified"])).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when length differs", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
|
||||
expect(canUndo(entry, ["new", "extra"])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
80
packages/ipuaro/tests/unit/shared/config/loader.test.ts
Normal file
80
packages/ipuaro/tests/unit/shared/config/loader.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { loadConfig, validateConfig, getConfigErrors } from "../../../../src/shared/config/loader.js"
|
||||
import { DEFAULT_CONFIG } from "../../../../src/shared/constants/config.js"
|
||||
import * as fs from "node:fs"
|
||||
|
||||
vi.mock("node:fs")
|
||||
|
||||
describe("config loader", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("should return default config when no files exist", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
const config = loadConfig("/project")
|
||||
|
||||
expect(config).toEqual(DEFAULT_CONFIG)
|
||||
})
|
||||
|
||||
it("should merge project config with defaults", () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
||||
return path === "/project/.ipuaro.json"
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ llm: { model: "custom-model" } })
|
||||
)
|
||||
|
||||
const config = loadConfig("/project")
|
||||
|
||||
expect(config.llm.model).toBe("custom-model")
|
||||
expect(config.redis.host).toBe("localhost")
|
||||
})
|
||||
|
||||
it("should handle invalid JSON gracefully", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.readFileSync).mockReturnValue("invalid json")
|
||||
|
||||
const config = loadConfig("/project")
|
||||
|
||||
expect(config).toEqual(DEFAULT_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("should return true for valid config", () => {
|
||||
expect(validateConfig(DEFAULT_CONFIG)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for partial valid config", () => {
|
||||
expect(validateConfig({ redis: { host: "redis.local" } })).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for invalid config", () => {
|
||||
expect(validateConfig({ redis: { port: "not a number" } })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfigErrors", () => {
|
||||
it("should return empty array for valid config", () => {
|
||||
const errors = getConfigErrors(DEFAULT_CONFIG)
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return errors for invalid config", () => {
|
||||
const errors = getConfigErrors({
|
||||
redis: { port: "invalid" },
|
||||
})
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("redis.port")
|
||||
})
|
||||
})
|
||||
})
|
||||
48
packages/ipuaro/tests/unit/shared/constants/messages.test.ts
Normal file
48
packages/ipuaro/tests/unit/shared/constants/messages.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { MESSAGES } from "../../../../src/shared/constants/messages.js"
|
||||
|
||||
describe("MESSAGES", () => {
|
||||
it("should have status messages", () => {
|
||||
expect(MESSAGES.STATUS_READY).toBe("Ready")
|
||||
expect(MESSAGES.STATUS_THINKING).toBe("Thinking...")
|
||||
expect(MESSAGES.STATUS_INDEXING).toBe("Indexing...")
|
||||
expect(MESSAGES.STATUS_ERROR).toBe("Error")
|
||||
})
|
||||
|
||||
it("should have error messages", () => {
|
||||
expect(MESSAGES.ERROR_REDIS_UNAVAILABLE).toContain("Redis")
|
||||
expect(MESSAGES.ERROR_OLLAMA_UNAVAILABLE).toContain("Ollama")
|
||||
expect(MESSAGES.ERROR_MODEL_NOT_FOUND).toContain("Model")
|
||||
expect(MESSAGES.ERROR_FILE_NOT_FOUND).toBe("File not found")
|
||||
expect(MESSAGES.ERROR_PARSE_FAILED).toContain("parse")
|
||||
expect(MESSAGES.ERROR_TOOL_FAILED).toContain("Tool")
|
||||
expect(MESSAGES.ERROR_COMMAND_BLACKLISTED).toContain("blacklisted")
|
||||
expect(MESSAGES.ERROR_PATH_OUTSIDE_PROJECT).toContain("outside")
|
||||
})
|
||||
|
||||
it("should have confirmation messages", () => {
|
||||
expect(MESSAGES.CONFIRM_APPLY_EDIT).toContain("Apply")
|
||||
expect(MESSAGES.CONFIRM_DELETE_FILE).toContain("Delete")
|
||||
expect(MESSAGES.CONFIRM_RUN_COMMAND).toContain("Run")
|
||||
expect(MESSAGES.CONFIRM_CREATE_FILE).toContain("Create")
|
||||
expect(MESSAGES.CONFIRM_GIT_COMMIT).toContain("commit")
|
||||
})
|
||||
|
||||
it("should have info messages", () => {
|
||||
expect(MESSAGES.INFO_SESSION_LOADED).toContain("loaded")
|
||||
expect(MESSAGES.INFO_SESSION_CREATED).toContain("created")
|
||||
expect(MESSAGES.INFO_INDEXING_COMPLETE).toContain("complete")
|
||||
expect(MESSAGES.INFO_EDIT_APPLIED).toContain("applied")
|
||||
expect(MESSAGES.INFO_EDIT_CANCELLED).toContain("cancelled")
|
||||
expect(MESSAGES.INFO_UNDO_SUCCESS).toContain("reverted")
|
||||
expect(MESSAGES.INFO_UNDO_EMPTY).toContain("Nothing")
|
||||
})
|
||||
|
||||
it("should have help text", () => {
|
||||
expect(MESSAGES.HELP_COMMANDS).toContain("/help")
|
||||
expect(MESSAGES.HELP_COMMANDS).toContain("/clear")
|
||||
expect(MESSAGES.HELP_COMMANDS).toContain("/undo")
|
||||
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+C")
|
||||
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+D")
|
||||
})
|
||||
})
|
||||
86
packages/ipuaro/tests/unit/shared/errors/IpuaroError.test.ts
Normal file
86
packages/ipuaro/tests/unit/shared/errors/IpuaroError.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
|
||||
describe("IpuaroError", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create error with all fields", () => {
|
||||
const error = new IpuaroError("file", "Not found", true, "Check path")
|
||||
|
||||
expect(error.name).toBe("IpuaroError")
|
||||
expect(error.type).toBe("file")
|
||||
expect(error.message).toBe("Not found")
|
||||
expect(error.recoverable).toBe(true)
|
||||
expect(error.suggestion).toBe("Check path")
|
||||
})
|
||||
|
||||
it("should default recoverable to true", () => {
|
||||
const error = new IpuaroError("parse", "Parse failed")
|
||||
|
||||
expect(error.recoverable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("static factories", () => {
|
||||
it("should create redis error", () => {
|
||||
const error = IpuaroError.redis("Connection failed")
|
||||
|
||||
expect(error.type).toBe("redis")
|
||||
expect(error.recoverable).toBe(false)
|
||||
expect(error.suggestion).toContain("Redis")
|
||||
})
|
||||
|
||||
it("should create parse error", () => {
|
||||
const error = IpuaroError.parse("Syntax error", "test.ts")
|
||||
|
||||
expect(error.type).toBe("parse")
|
||||
expect(error.message).toContain("test.ts")
|
||||
expect(error.recoverable).toBe(true)
|
||||
})
|
||||
|
||||
it("should create parse error without file", () => {
|
||||
const error = IpuaroError.parse("Syntax error")
|
||||
|
||||
expect(error.message).toBe("Syntax error")
|
||||
})
|
||||
|
||||
it("should create llm error", () => {
|
||||
const error = IpuaroError.llm("Timeout")
|
||||
|
||||
expect(error.type).toBe("llm")
|
||||
expect(error.recoverable).toBe(true)
|
||||
expect(error.suggestion).toContain("Ollama")
|
||||
})
|
||||
|
||||
it("should create file error", () => {
|
||||
const error = IpuaroError.file("Not found")
|
||||
|
||||
expect(error.type).toBe("file")
|
||||
})
|
||||
|
||||
it("should create command error", () => {
|
||||
const error = IpuaroError.command("Blacklisted")
|
||||
|
||||
expect(error.type).toBe("command")
|
||||
})
|
||||
|
||||
it("should create conflict error", () => {
|
||||
const error = IpuaroError.conflict("File changed")
|
||||
|
||||
expect(error.type).toBe("conflict")
|
||||
expect(error.suggestion).toContain("Regenerate")
|
||||
})
|
||||
|
||||
it("should create validation error", () => {
|
||||
const error = IpuaroError.validation("Invalid param")
|
||||
|
||||
expect(error.type).toBe("validation")
|
||||
})
|
||||
|
||||
it("should create timeout error", () => {
|
||||
const error = IpuaroError.timeout("Request timeout")
|
||||
|
||||
expect(error.type).toBe("timeout")
|
||||
expect(error.suggestion).toContain("timeout")
|
||||
})
|
||||
})
|
||||
})
|
||||
51
packages/ipuaro/tests/unit/shared/types/index.test.ts
Normal file
51
packages/ipuaro/tests/unit/shared/types/index.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { ok, err, isOk, isErr, type Result } from "../../../../src/shared/types/index.js"
|
||||
|
||||
describe("Result type", () => {
|
||||
describe("ok", () => {
|
||||
it("should create success result", () => {
|
||||
const result = ok("data")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBe("data")
|
||||
})
|
||||
})
|
||||
|
||||
describe("err", () => {
|
||||
it("should create error result", () => {
|
||||
const error = new Error("failed")
|
||||
const result = err(error)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isOk", () => {
|
||||
it("should return true for success", () => {
|
||||
const result: Result<string> = ok("data")
|
||||
|
||||
expect(isOk(result)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for error", () => {
|
||||
const result: Result<string> = err(new Error("fail"))
|
||||
|
||||
expect(isOk(result)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isErr", () => {
|
||||
it("should return true for error", () => {
|
||||
const result: Result<string> = err(new Error("fail"))
|
||||
|
||||
expect(isErr(result)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for success", () => {
|
||||
const result: Result<string> = ok("data")
|
||||
|
||||
expect(isErr(result)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
56
packages/ipuaro/tests/unit/shared/utils/hash.test.ts
Normal file
56
packages/ipuaro/tests/unit/shared/utils/hash.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { md5, hashLines, shortHash } from "../../../../src/shared/utils/hash.js"
|
||||
|
||||
describe("hash utils", () => {
|
||||
describe("md5", () => {
|
||||
it("should return consistent hash for same input", () => {
|
||||
const hash1 = md5("hello")
|
||||
const hash2 = md5("hello")
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it("should return different hash for different input", () => {
|
||||
const hash1 = md5("hello")
|
||||
const hash2 = md5("world")
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it("should return 32 character hex string", () => {
|
||||
const hash = md5("test")
|
||||
|
||||
expect(hash).toHaveLength(32)
|
||||
expect(hash).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hashLines", () => {
|
||||
it("should hash joined lines", () => {
|
||||
const lines = ["line1", "line2", "line3"]
|
||||
const hash = hashLines(lines)
|
||||
|
||||
expect(hash).toBe(md5("line1\nline2\nline3"))
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const hash = hashLines([])
|
||||
|
||||
expect(hash).toBe(md5(""))
|
||||
})
|
||||
})
|
||||
|
||||
describe("shortHash", () => {
|
||||
it("should return truncated hash", () => {
|
||||
const hash = shortHash("test")
|
||||
|
||||
expect(hash).toHaveLength(8)
|
||||
})
|
||||
|
||||
it("should accept custom length", () => {
|
||||
const hash = shortHash("test", 12)
|
||||
|
||||
expect(hash).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
packages/ipuaro/tests/unit/shared/utils/tokens.test.ts
Normal file
61
packages/ipuaro/tests/unit/shared/utils/tokens.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
estimateTokens,
|
||||
estimateTokensForLines,
|
||||
truncateToTokens,
|
||||
formatTokenCount,
|
||||
} from "../../../../src/shared/utils/tokens.js"
|
||||
|
||||
describe("tokens utils", () => {
|
||||
describe("estimateTokens", () => {
|
||||
it("should estimate ~4 chars per token", () => {
|
||||
expect(estimateTokens("")).toBe(0)
|
||||
expect(estimateTokens("test")).toBe(1)
|
||||
expect(estimateTokens("12345678")).toBe(2)
|
||||
})
|
||||
|
||||
it("should round up", () => {
|
||||
expect(estimateTokens("12345")).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateTokensForLines", () => {
|
||||
it("should estimate tokens for array of lines", () => {
|
||||
const lines = ["line1", "line2"]
|
||||
const expected = estimateTokens("line1\nline2")
|
||||
|
||||
expect(estimateTokensForLines(lines)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
expect(estimateTokensForLines([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateToTokens", () => {
|
||||
it("should not truncate short text", () => {
|
||||
const text = "short"
|
||||
expect(truncateToTokens(text, 10)).toBe(text)
|
||||
})
|
||||
|
||||
it("should truncate long text", () => {
|
||||
const text = "a".repeat(100)
|
||||
const result = truncateToTokens(text, 10)
|
||||
|
||||
expect(result).toBe("a".repeat(40) + "...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("should format small numbers as-is", () => {
|
||||
expect(formatTokenCount(500)).toBe("500")
|
||||
expect(formatTokenCount(999)).toBe("999")
|
||||
})
|
||||
|
||||
it("should format thousands with k suffix", () => {
|
||||
expect(formatTokenCount(1000)).toBe("1.0k")
|
||||
expect(formatTokenCount(1500)).toBe("1.5k")
|
||||
expect(formatTokenCount(12345)).toBe("12.3k")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user