feat: add vitest, vite, move to tdd
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# Vitest + Clean Architecture Refactoring
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Установить Vitest, переделать архитектуру ближе к чистой (порты/адаптеры),
|
||||||
|
написать нехрупкие тесты на реальные юзкейсы.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### Новые файлы
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/
|
||||||
|
├── ports.ts # Интерфейсы: IFileSystem, ITaskRepository, IConfigProvider
|
||||||
|
├── storage/
|
||||||
|
│ ├── FsTaskRepository.ts # ITaskRepository → зависит только от IFileSystem, markdown, uuid
|
||||||
|
│ ├── VscodeConfigProvider.ts # IConfigProvider → зависит от vscode
|
||||||
|
│ └── NodeFileSystem.ts # IFileSystem → обёртка над fs.promises
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── utils/
|
||||||
|
│ ├── markdown.test.ts
|
||||||
|
│ └── uuid.test.ts
|
||||||
|
├── storage/
|
||||||
|
│ ├── FsTaskRepository.test.ts
|
||||||
|
│ └── helpers/
|
||||||
|
│ └── InMemoryFileSystem.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Удалить
|
||||||
|
|
||||||
|
- `src/storage/taskStore.ts`
|
||||||
|
- `src/storage/configStore.ts`
|
||||||
|
|
||||||
|
### Изменить
|
||||||
|
|
||||||
|
- `src/views/taskTreeProvider.ts` — конструктор принимает `ITaskRepository` +
|
||||||
|
`IConfigProvider`
|
||||||
|
- `src/extension.ts` — сборка зависимостей (DI)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
|
||||||
|
- `npm i -D vitest`
|
||||||
|
- `vitest.config.ts`
|
||||||
|
- `package.json` — скрипты `test`, `test:run`
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
| Тест | I/O | Что проверяет |
|
||||||
|
| ------------------ | ------------------------ | -------------------------------------------- |
|
||||||
|
| FsTaskRepository | InMemoryFileSystem (Map) | CRUD, фильтрация по extension, контент файла |
|
||||||
|
| markdown | — | parse, serialize, round-trip, default-ы |
|
||||||
|
| uuid | — | валидный формат, уникальность |
|
||||||
|
| InMemoryFileSystem | — | read/write/readdir/mkdir |
|
||||||
|
|
||||||
|
## Что НЕ тестируем (пока)
|
||||||
|
|
||||||
|
- NodeFileSystem — тривиальная обёртка
|
||||||
|
- VscodeConfigProvider — требует Extension Host
|
||||||
|
- TaskTreeProvider — UI, требует Extension Host
|
||||||
Generated
+1282
-1
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -88,17 +88,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"vscode:prepublish": "npm run compile",
|
"build": "vite build",
|
||||||
"compile": "tsc -p ./",
|
"watch": "vite build --watch",
|
||||||
"watch": "tsc -watch -p ./",
|
"vscode:prepublish": "npm run build",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/vscode": "^1.96.0",
|
"@types/vscode": "^1.96.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^8.1.4",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
|||||||
+14
-8
@@ -1,13 +1,18 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { ConfigStore } from "./storage/configStore";
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
import { TaskStore } from "./storage/taskStore";
|
import { NodeFileSystem } from "@/storage/NodeFileSystem";
|
||||||
import { TaskTreeProvider } from "./views/taskTreeProvider";
|
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
|
||||||
|
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
const taskStore = new TaskStore();
|
const fileSystem = new NodeFileSystem();
|
||||||
const configStore = new ConfigStore();
|
const configStore = new VscodeConfigProvider();
|
||||||
const taskTreeProvider = new TaskTreeProvider();
|
const taskStore = new FsTaskRepository(
|
||||||
|
fileSystem,
|
||||||
|
configStore.getFileExtension(),
|
||||||
|
);
|
||||||
|
const taskTreeProvider = new TaskTreeProvider(taskStore, configStore);
|
||||||
|
|
||||||
vscode.window.registerTreeDataProvider(
|
vscode.window.registerTreeDataProvider(
|
||||||
"projectTasks.tasks",
|
"projectTasks.tasks",
|
||||||
@@ -16,7 +21,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
const projectPath = configStore.getProjectTaskPath();
|
const projectPath = configStore.getProjectTaskPath();
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
const taskPattern = path.join(projectPath, "*.task.md").replace(/\\/g, "/");
|
const ext = configStore.getFileExtension();
|
||||||
|
const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/");
|
||||||
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
|
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
|
||||||
watcher.onDidCreate(() => taskTreeProvider.refresh());
|
watcher.onDidCreate(() => taskTreeProvider.refresh());
|
||||||
watcher.onDidChange(() => taskTreeProvider.refresh());
|
watcher.onDidChange(() => taskTreeProvider.refresh());
|
||||||
@@ -48,7 +54,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await taskStore.createTask(projectPath, {
|
await taskStore.create(projectPath, {
|
||||||
title,
|
title,
|
||||||
description: "",
|
description: "",
|
||||||
status: "todo",
|
status: "todo",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Task } from "@/model/task";
|
||||||
|
|
||||||
|
export interface IFileSystem {
|
||||||
|
readFile(path: string): Promise<string>;
|
||||||
|
writeFile(path: string, content: string): Promise<void>;
|
||||||
|
deleteFile(path: string): Promise<void>;
|
||||||
|
readdir(path: string): Promise<string[]>;
|
||||||
|
mkdir(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITaskRepository {
|
||||||
|
list(folderPath: string): Promise<Task[]>;
|
||||||
|
getById(folderPath: string, id: string): Promise<Task | undefined>;
|
||||||
|
create(
|
||||||
|
folderPath: string,
|
||||||
|
task: Omit<Task, "id" | "created" | "updated">,
|
||||||
|
): Promise<Task>;
|
||||||
|
update(folderPath: string, task: Task): Promise<void>;
|
||||||
|
delete(folderPath: string, id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConfigProvider {
|
||||||
|
getProjectTaskPath(): string | undefined;
|
||||||
|
getGlobalTaskPath(): string | undefined;
|
||||||
|
getFileExtension(): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { IFileSystem, ITaskRepository } from "@/ports";
|
||||||
|
import { parseTaskFile, serializeTask } from "@/utils/markdown";
|
||||||
|
import { generateId } from "@/utils/uuid";
|
||||||
|
|
||||||
|
export class FsTaskRepository implements ITaskRepository {
|
||||||
|
constructor(
|
||||||
|
private fs: IFileSystem,
|
||||||
|
private fileExtension: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(folderPath: string): Promise<Task[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await this.fs.readdir(folderPath);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = entries.filter((name) => name.endsWith(this.fileExtension));
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const fullPath = this.joinPath(folderPath, file);
|
||||||
|
const content = await this.fs.readFile(fullPath);
|
||||||
|
const task = parseTaskFile(content);
|
||||||
|
if (!task.id) continue;
|
||||||
|
tasks.push(task);
|
||||||
|
} catch {
|
||||||
|
// skip unparseable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks.sort(
|
||||||
|
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(folderPath: string, id: string): Promise<Task | undefined> {
|
||||||
|
const tasks = await this.list(folderPath);
|
||||||
|
return tasks.find((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
folderPath: string,
|
||||||
|
task: Omit<Task, "id" | "created" | "updated">,
|
||||||
|
): Promise<Task> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newTask: Task = {
|
||||||
|
...task,
|
||||||
|
id: generateId(),
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.fs.mkdir(folderPath);
|
||||||
|
const filePath = this.taskFilePath(folderPath, newTask);
|
||||||
|
await this.fs.writeFile(filePath, serializeTask(newTask));
|
||||||
|
|
||||||
|
return newTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(folderPath: string, task: Task): Promise<void> {
|
||||||
|
const existing = await this.getById(folderPath, task.id);
|
||||||
|
const updatedTask = { ...task, updated: new Date().toISOString() };
|
||||||
|
const newPath = this.taskFilePath(folderPath, updatedTask);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const oldPath = this.taskFilePath(folderPath, existing);
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
await this.fs.deleteFile(oldPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fs.writeFile(newPath, serializeTask(updatedTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(folderPath: string, id: string): Promise<void> {
|
||||||
|
const task = await this.getById(folderPath, id);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const filePath = this.taskFilePath(folderPath, task);
|
||||||
|
try {
|
||||||
|
await this.fs.deleteFile(filePath);
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private taskFilePath(folderPath: string, task: Task): string {
|
||||||
|
const safeName = task.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return this.joinPath(folderPath, `${safeName}${this.fileExtension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private joinPath(...segments: string[]): string {
|
||||||
|
return segments.join("/").replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import { IFileSystem } from "@/ports";
|
||||||
|
|
||||||
|
export class NodeFileSystem implements IFileSystem {
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
return fs.promises.readFile(path, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
await fs.promises.writeFile(path, content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(path: string): Promise<void> {
|
||||||
|
await fs.promises.unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readdir(path: string): Promise<string[]> {
|
||||||
|
const entries = await fs.promises.readdir(path, { withFileTypes: true });
|
||||||
|
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(path: string): Promise<void> {
|
||||||
|
await fs.promises.mkdir(path, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import { IConfigProvider } from "@/ports";
|
||||||
|
|
||||||
export class ConfigStore {
|
export class VscodeConfigProvider implements IConfigProvider {
|
||||||
getProjectTaskPath(): string | undefined {
|
getProjectTaskPath(): string | undefined {
|
||||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!workspaceRoot) return undefined;
|
if (!workspaceRoot) return undefined;
|
||||||
@@ -13,8 +14,7 @@ export class ConfigStore {
|
|||||||
|
|
||||||
getGlobalTaskPath(): string | undefined {
|
getGlobalTaskPath(): string | undefined {
|
||||||
const config = vscode.workspace.getConfiguration("projectTasks");
|
const config = vscode.workspace.getConfiguration("projectTasks");
|
||||||
const globalPath = config.get<string>("globalPath");
|
return config.get<string>("globalPath") || undefined;
|
||||||
return globalPath || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileExtension(): string {
|
getFileExtension(): string {
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import * as vscode from "vscode";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import { Task } from "../model/task";
|
|
||||||
import { parseTaskFile, serializeTask } from "../utils/markdown";
|
|
||||||
import { generateId } from "../utils/uuid";
|
|
||||||
|
|
||||||
export class TaskStore {
|
|
||||||
private fileExtension: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const config = vscode.workspace.getConfiguration("projectTasks");
|
|
||||||
this.fileExtension = config.get<string>("fileExtension") ?? ".task.md";
|
|
||||||
}
|
|
||||||
|
|
||||||
getProjectTaskPath(): string | undefined {
|
|
||||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
||||||
if (!workspaceRoot) return undefined;
|
|
||||||
|
|
||||||
const config = vscode.workspace.getConfiguration("projectTasks");
|
|
||||||
const relativePath = config.get<string>("projectPath") ?? ".vscode/tasks";
|
|
||||||
return path.join(workspaceRoot, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
getGlobalTaskPath(): string | undefined {
|
|
||||||
const config = vscode.workspace.getConfiguration("projectTasks");
|
|
||||||
const globalPath = config.get<string>("globalPath");
|
|
||||||
return globalPath || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listTasks(folderPath: string): Promise<Task[]> {
|
|
||||||
try {
|
|
||||||
await fs.promises.mkdir(folderPath, { recursive: true });
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await fs.promises.readdir(folderPath, {
|
|
||||||
withFileTypes: true,
|
|
||||||
});
|
|
||||||
const files = entries.filter(
|
|
||||||
(e) => e.isFile() && e.name.endsWith(this.fileExtension),
|
|
||||||
);
|
|
||||||
|
|
||||||
const tasks: Task[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const content = await fs.promises.readFile(
|
|
||||||
path.join(folderPath, file.name),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
tasks.push(parseTaskFile(content));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to parse task file ${file.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks.sort(
|
|
||||||
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTask(folderPath: string, taskId: string): Promise<Task | undefined> {
|
|
||||||
const tasks = await this.listTasks(folderPath);
|
|
||||||
return tasks.find((t) => t.id === taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTask(
|
|
||||||
folderPath: string,
|
|
||||||
task: Omit<Task, "id" | "created" | "updated">,
|
|
||||||
): Promise<Task> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const newTask: Task = {
|
|
||||||
...task,
|
|
||||||
id: generateId(),
|
|
||||||
created: now,
|
|
||||||
updated: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.promises.mkdir(folderPath, { recursive: true });
|
|
||||||
const filePath = this.taskFilePath(folderPath, newTask);
|
|
||||||
await fs.promises.writeFile(filePath, serializeTask(newTask), "utf-8");
|
|
||||||
|
|
||||||
return newTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateTask(folderPath: string, task: Task): Promise<void> {
|
|
||||||
const updatedTask = { ...task, updated: new Date().toISOString() };
|
|
||||||
const filePath = this.taskFilePath(folderPath, updatedTask);
|
|
||||||
await fs.promises.writeFile(filePath, serializeTask(updatedTask), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteTask(folderPath: string, taskId: string): Promise<void> {
|
|
||||||
const task = await this.getTask(folderPath, taskId);
|
|
||||||
if (!task) return;
|
|
||||||
|
|
||||||
const filePath = this.taskFilePath(folderPath, task);
|
|
||||||
try {
|
|
||||||
await fs.promises.unlink(filePath);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to delete task file:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private taskFilePath(folderPath: string, task: Task): string {
|
|
||||||
const safeName = task.title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")
|
|
||||||
.slice(0, 80);
|
|
||||||
|
|
||||||
return path.join(folderPath, `${safeName}${this.fileExtension}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseTaskFile, serializeTask } from "./markdown";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
|
||||||
|
const fullTask: Task = {
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
title: "Пофиксить баг логина",
|
||||||
|
description: "Ошибка валидации токена",
|
||||||
|
status: "in-progress",
|
||||||
|
priority: "high",
|
||||||
|
tags: ["bug", "frontend"],
|
||||||
|
assignee: "ivanov",
|
||||||
|
created: "2026-07-12T10:00:00.000Z",
|
||||||
|
updated: "2026-07-12T10:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("markdown", () => {
|
||||||
|
it("parses a .task.md file with full frontmatter", () => {
|
||||||
|
const content = [
|
||||||
|
"---",
|
||||||
|
'id: "550e8400-e29b-41d4-a716-446655440000"',
|
||||||
|
'title: "Пофиксить баг логина"',
|
||||||
|
"status: in-progress",
|
||||||
|
"priority: high",
|
||||||
|
"tags: [bug, frontend]",
|
||||||
|
'assignee: "ivanov"',
|
||||||
|
"created: 2026-07-12T10:00:00.000Z",
|
||||||
|
"updated: 2026-07-12T10:00:00.000Z",
|
||||||
|
"---",
|
||||||
|
"Ошибка валидации токена",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const task = parseTaskFile(content);
|
||||||
|
expect(task).toEqual(fullTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills defaults for missing fields", () => {
|
||||||
|
const content = [
|
||||||
|
"---",
|
||||||
|
'id: "abc"',
|
||||||
|
'title: "Minimal"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const task = parseTaskFile(content);
|
||||||
|
expect(task.id).toBe("abc");
|
||||||
|
expect(task.title).toBe("Minimal");
|
||||||
|
expect(task.description).toBe("");
|
||||||
|
expect(task.status).toBe("todo");
|
||||||
|
expect(task.priority).toBe("medium");
|
||||||
|
expect(task.tags).toEqual([]);
|
||||||
|
expect(task.assignee).toBe("");
|
||||||
|
expect(task.created).toBeTruthy();
|
||||||
|
expect(task.updated).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializeTask produces valid frontmatter", () => {
|
||||||
|
const result = serializeTask(fullTask);
|
||||||
|
expect(result).toContain("---");
|
||||||
|
expect(result).toContain("title: Пофиксить баг логина");
|
||||||
|
expect(result).toContain("status: in-progress");
|
||||||
|
expect(result).toContain("Ошибка валидации токена");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip: serialize → parse produces the same task", () => {
|
||||||
|
const serialized = serializeTask(fullTask);
|
||||||
|
const parsed = parseTaskFile(serialized);
|
||||||
|
expect(parsed).toEqual(fullTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description with utf-8 survives round-trip", () => {
|
||||||
|
const task: Task = {
|
||||||
|
...fullTask,
|
||||||
|
description: "Тестовая задача с кириллицей\nи переносом строки",
|
||||||
|
};
|
||||||
|
const serialized = serializeTask(task);
|
||||||
|
const parsed = parseTaskFile(serialized);
|
||||||
|
expect(parsed.description).toBe(task.description);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import matter from "gray-matter";
|
import matter from "gray-matter";
|
||||||
import { Task } from "../model/task";
|
import { Task } from "@/model/task";
|
||||||
|
|
||||||
|
function toISO(value: unknown): string {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTaskFile(content: string): Task {
|
export function parseTaskFile(content: string): Task {
|
||||||
const parsed = matter(content);
|
const parsed = matter(content);
|
||||||
@@ -11,8 +17,8 @@ export function parseTaskFile(content: string): Task {
|
|||||||
priority: parsed.data.priority ?? "medium",
|
priority: parsed.data.priority ?? "medium",
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
assignee: parsed.data.assignee ?? "",
|
assignee: parsed.data.assignee ?? "",
|
||||||
created: parsed.data.created ?? new Date().toISOString(),
|
created: toISO(parsed.data.created),
|
||||||
updated: parsed.data.updated ?? new Date().toISOString(),
|
updated: toISO(parsed.data.updated),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { Task, TaskStatus } from "../model/task";
|
import { Task, TaskStatus } from "@/model/task";
|
||||||
import { TaskStore } from "../storage/taskStore";
|
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||||
import { ConfigStore } from "../storage/configStore";
|
|
||||||
|
|
||||||
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
|
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
|
||||||
|
|
||||||
@@ -30,8 +29,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
|
|||||||
>();
|
>();
|
||||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||||
|
|
||||||
private taskStore = new TaskStore();
|
constructor(
|
||||||
private configStore = new ConfigStore();
|
private taskStore: ITaskRepository,
|
||||||
|
private configStore: IConfigProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
refresh(): void {
|
refresh(): void {
|
||||||
this._onDidChangeTreeData.fire();
|
this._onDidChangeTreeData.fire();
|
||||||
@@ -54,7 +55,7 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
|
|||||||
return [new vscode.TreeItem("Open a workspace folder to start")];
|
return [new vscode.TreeItem("Open a workspace folder to start")];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = await this.taskStore.listTasks(projectPath);
|
const tasks = await this.taskStore.list(projectPath);
|
||||||
|
|
||||||
return STATUS_ORDER.map((status) => {
|
return STATUS_ORDER.map((status) => {
|
||||||
const groupTasks = tasks.filter((t) => t.status === status);
|
const groupTasks = tasks.filter((t) => t.status === status);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { IFileSystem } from "@/ports";
|
||||||
|
|
||||||
|
export class InMemoryFileSystem implements IFileSystem {
|
||||||
|
private files = new Map<string, string>();
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.files.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
const content = this.files.get(path);
|
||||||
|
if (content === undefined) {
|
||||||
|
throw new Error(`ENOENT: no such file: ${path}`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
this.files.set(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(path: string): Promise<void> {
|
||||||
|
this.files.delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readdir(path: string): Promise<string[]> {
|
||||||
|
const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/";
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const key of this.files.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
const relative = key.slice(prefix.length);
|
||||||
|
const top = relative.split(/[/\\]/)[0];
|
||||||
|
if (top) seen.add(top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(_path: string): Promise<void> {
|
||||||
|
// no-op for in-memory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { InMemoryFileSystem } from "../helpers/InMemoryFileSystem";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
|
||||||
|
describe("FsTaskRepository", () => {
|
||||||
|
let fs: InMemoryFileSystem;
|
||||||
|
let repo: ITaskRepository;
|
||||||
|
|
||||||
|
const folder = "/tasks";
|
||||||
|
|
||||||
|
const sampleTask = {
|
||||||
|
title: "Fix login bug",
|
||||||
|
description: "Token validation error",
|
||||||
|
status: "todo" as const,
|
||||||
|
priority: "high" as const,
|
||||||
|
tags: ["bug", "frontend"],
|
||||||
|
assignee: "ivanov",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs = new InMemoryFileSystem();
|
||||||
|
repo = new FsTaskRepository(fs, ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a task and writes it as a .task.md file", async () => {
|
||||||
|
const task = await repo.create(folder, sampleTask);
|
||||||
|
|
||||||
|
expect(task.id).toBeTruthy();
|
||||||
|
expect(task.title).toBe("Fix login bug");
|
||||||
|
expect(task.created).toBeTruthy();
|
||||||
|
expect(task.updated).toBe(task.created);
|
||||||
|
|
||||||
|
const files = await fs.readdir(folder);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/\.task\.md$/);
|
||||||
|
|
||||||
|
const content = await fs.readFile(`${folder}/${files[0]}`);
|
||||||
|
expect(content).toContain("Fix login bug");
|
||||||
|
expect(content).toContain("status: todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list returns created tasks sorted by updated desc", async () => {
|
||||||
|
const a = await repo.create(folder, { ...sampleTask, title: "A" });
|
||||||
|
await delay(10);
|
||||||
|
const b = await repo.create(folder, { ...sampleTask, title: "B" });
|
||||||
|
|
||||||
|
const tasks = await repo.list(folder);
|
||||||
|
expect(tasks).toHaveLength(2);
|
||||||
|
expect(tasks[0].title).toBe("B");
|
||||||
|
expect(tasks[1].title).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getById returns the correct task", async () => {
|
||||||
|
const created = await repo.create(folder, sampleTask);
|
||||||
|
const found = await repo.getById(folder, created.id);
|
||||||
|
expect(found).toEqual(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getById returns undefined for unknown id", async () => {
|
||||||
|
const found = await repo.getById(folder, "nonexistent");
|
||||||
|
expect(found).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update modifies the file and updates timestamp", async () => {
|
||||||
|
const task = await repo.create(folder, sampleTask);
|
||||||
|
await delay(5);
|
||||||
|
const updated: Task = { ...task, title: "Fixed!", status: "done" };
|
||||||
|
|
||||||
|
await repo.update(folder, updated);
|
||||||
|
const found = await repo.getById(folder, task.id);
|
||||||
|
|
||||||
|
expect(found!.title).toBe("Fixed!");
|
||||||
|
expect(found!.status).toBe("done");
|
||||||
|
expect(new Date(found!.updated).getTime()).toBeGreaterThan(
|
||||||
|
new Date(task.updated).getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete removes the task file", async () => {
|
||||||
|
const task = await repo.create(folder, sampleTask);
|
||||||
|
await repo.delete(folder, task.id);
|
||||||
|
|
||||||
|
const tasks = await repo.list(folder);
|
||||||
|
expect(tasks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete is a no-op for unknown id", async () => {
|
||||||
|
await expect(repo.delete(folder, "nope")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list returns empty array for non-existent folder", async () => {
|
||||||
|
const tasks = await repo.list("/nonexistent");
|
||||||
|
expect(tasks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters files by extension", async () => {
|
||||||
|
fs.writeFile(`${folder}/notes.txt`, "hello");
|
||||||
|
fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
|
||||||
|
|
||||||
|
await repo.create(folder, sampleTask);
|
||||||
|
|
||||||
|
const tasks = await repo.list(folder);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unparseable .task.md files silently", async () => {
|
||||||
|
fs.writeFile(`${folder}/corrupt.task.md`, "not valid frontmatter");
|
||||||
|
|
||||||
|
await repo.create(folder, sampleTask);
|
||||||
|
const tasks = await repo.list(folder);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"include": ["**/*", "../src/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": ".."
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -12,7 +12,10 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "out", ".vscode-test"]
|
"exclude": ["node_modules", "out", ".vscode-test"]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { builtinModules } from "node:module";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
target: "node20",
|
||||||
|
ssr: path.resolve(__dirname, "src/extension.ts"),
|
||||||
|
outDir: "out",
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "extension.js",
|
||||||
|
},
|
||||||
|
external: [
|
||||||
|
"vscode",
|
||||||
|
...builtinModules,
|
||||||
|
...builtinModules.map((m) => `node:${m}`),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||||
|
exclude: ["node_modules", "out"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user