test: update tests, add eslint rules

This commit is contained in:
2026-07-13 15:01:55 +05:00
parent 06e059367e
commit 9705bac7af
7 changed files with 202 additions and 74 deletions
+3
View File
@@ -1,5 +1,8 @@
# Vitest + Clean Architecture Refactoring # Vitest + Clean Architecture Refactoring
> **Статус: выполнено / архив**
> План закрыт, дальше не ведём и не обновляем. Исторический снимок задачи.
## Цель ## Цель
Установить Vitest, переделать архитектуру ближе к чистой (порты/адаптеры), Установить Vitest, переделать архитектуру ближе к чистой (порты/адаптеры),
+19
View File
@@ -37,6 +37,25 @@ export default defineConfig(
caughtErrorsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_",
}, },
], ],
"@typescript-eslint/parameter-properties": [
"error",
{
prefer: "class-property",
allow: [],
},
],
"@typescript-eslint/naming-convention": [
"error",
{
selector: "classProperty",
modifiers: ["private"],
format: null,
custom: {
regex: "^.*$",
match: false,
},
},
],
}, },
}, },
{ {
+94 -23
View File
@@ -4,11 +4,18 @@ import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { FsTaskRepository } from "./FsTaskRepository"; import { FsTaskRepository } from "./FsTaskRepository";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
describe("FsTaskRepository", () => { describe("FsTaskRepository", () => {
let fs: InMemoryFileSystem; let fs: InMemoryFileSystem;
let repo: ITaskRepository; let repo: ITaskRepository;
let clockStep: number;
const folder = "/tasks"; const folder = "/tasks";
const t0 = "2026-01-01T00:00:00.000Z";
const t1 = "2026-01-01T00:00:01.000Z";
const t2 = "2026-01-01T00:00:02.000Z";
const sampleTask = { const sampleTask = {
title: "Fix login bug", title: "Fix login bug",
@@ -21,35 +28,41 @@ describe("FsTaskRepository", () => {
beforeEach(() => { beforeEach(() => {
fs = new InMemoryFileSystem(); fs = new InMemoryFileSystem();
repo = new FsTaskRepository(fs, ".task.md"); clockStep = 0;
const timestamps = [t0, t1, t2];
repo = new FsTaskRepository(fs, ".task.md", () => {
const value = timestamps[clockStep] ?? t2;
clockStep += 1;
return value;
});
}); });
it("creates a task and writes it as a .task.md file", async () => { it("creates a task and writes it as a .task.md file", async () => {
const task = await repo.create(folder, sampleTask); const task = await repo.create(folder, sampleTask);
expect(task.id).toBeTruthy(); expect(task.id).toMatch(UUID_RE);
expect(task.title).toBe("Fix login bug"); expect(task.title).toBe("Fix login bug");
expect(task.created).toBeTruthy(); expect(task.created).toBe(t0);
expect(task.updated).toBe(task.created); expect(task.updated).toBe(t0);
const files = await fs.readdir(folder); const files = await fs.readdir(folder);
expect(files).toHaveLength(1); expect(files).toEqual(["fix-login-bug.task.md"]);
expect(files[0]).toMatch(/\.task\.md$/);
const content = await fs.readFile(`${folder}/${files[0]}`); const content = await fs.readFile(`${folder}/fix-login-bug.task.md`);
expect(content).toContain("Fix login bug"); expect(content).toContain("Fix login bug");
expect(content).toContain("status: todo"); expect(content).toContain("status: todo");
}); });
it("list returns created tasks sorted by updated desc", async () => { it("list returns created tasks sorted by updated desc", async () => {
await repo.create(folder, { ...sampleTask, title: "A" }); await repo.create(folder, { ...sampleTask, title: "A" });
await delay(10);
await repo.create(folder, { ...sampleTask, title: "B" }); await repo.create(folder, { ...sampleTask, title: "B" });
const tasks = await repo.list(folder); const tasks = await repo.list(folder);
expect(tasks).toHaveLength(2); expect(tasks).toHaveLength(2);
expect(tasks[0].title).toBe("B"); expect(tasks[0].title).toBe("B");
expect(tasks[0].updated).toBe(t1);
expect(tasks[1].title).toBe("A"); expect(tasks[1].title).toBe("A");
expect(tasks[1].updated).toBe(t0);
}); });
it("getById returns the correct task", async () => { it("getById returns the correct task", async () => {
@@ -63,19 +76,50 @@ describe("FsTaskRepository", () => {
expect(found).toBeUndefined(); expect(found).toBeUndefined();
}); });
it("update modifies the file and updates timestamp", async () => { it("update modifies fields and bumps updated timestamp", async () => {
const task = await repo.create(folder, sampleTask); const task = await repo.create(folder, sampleTask);
await delay(5); const updated: Task = { ...task, status: "done" };
const updated: Task = { ...task, title: "Fixed!", status: "done" };
await repo.update(folder, updated); await repo.update(folder, updated);
const found = await repo.getById(folder, task.id); const found = await repo.getById(folder, task.id);
expect(found!.title).toBe("Fixed!");
expect(found!.status).toBe("done"); expect(found!.status).toBe("done");
expect(new Date(found!.updated).getTime()).toBeGreaterThan( expect(found!.title).toBe(task.title);
new Date(task.updated).getTime(), expect(found!.updated).toBe(t1);
); expect(found!.created).toBe(t0);
});
it("update renames file when title changes", async () => {
const task = await repo.create(folder, sampleTask);
expect(await fs.readdir(folder)).toEqual(["fix-login-bug.task.md"]);
await repo.update(folder, { ...task, title: "Fixed!" });
const files = await fs.readdir(folder);
expect(files).toEqual(["fixed.task.md"]);
const found = await repo.getById(folder, task.id);
expect(found!.title).toBe("Fixed!");
expect(found!.updated).toBe(t1);
});
it("update of unknown id writes a new file", async () => {
const orphan: Task = {
...sampleTask,
id: "orphan-id",
title: "Orphan",
created: t0,
updated: t0,
};
await repo.update(folder, orphan);
const found = await repo.getById(folder, "orphan-id");
expect(found).toMatchObject({
id: "orphan-id",
title: "Orphan",
updated: t0,
});
expect(await fs.readdir(folder)).toEqual(["orphan.task.md"]);
}); });
it("delete removes the task file", async () => { it("delete removes the task file", async () => {
@@ -84,6 +128,7 @@ describe("FsTaskRepository", () => {
const tasks = await repo.list(folder); const tasks = await repo.list(folder);
expect(tasks).toHaveLength(0); expect(tasks).toHaveLength(0);
expect(await fs.readdir(folder)).toEqual([]);
}); });
it("delete is a no-op for unknown id", async () => { it("delete is a no-op for unknown id", async () => {
@@ -96,8 +141,8 @@ describe("FsTaskRepository", () => {
}); });
it("filters files by extension", async () => { it("filters files by extension", async () => {
fs.writeFile(`${folder}/notes.txt`, "hello"); await fs.writeFile(`${folder}/notes.txt`, "hello");
fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n"); await fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
await repo.create(folder, sampleTask); await repo.create(folder, sampleTask);
@@ -105,15 +150,41 @@ describe("FsTaskRepository", () => {
expect(tasks).toHaveLength(1); expect(tasks).toHaveLength(1);
}); });
it("skips unparseable .task.md files silently", async () => { it("list returns empty when only non-matching extensions exist", async () => {
fs.writeFile(`${folder}/corrupt.task.md`, "not valid frontmatter"); await fs.writeFile(`${folder}/notes.txt`, "hello");
await fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
const tasks = await repo.list(folder);
expect(tasks).toEqual([]);
});
it("skips files without id", async () => {
await fs.writeFile(`${folder}/no-id.task.md`, "not valid frontmatter");
await fs.writeFile(
`${folder}/empty-id.task.md`,
"---\ntitle: No Id\n---\n",
);
await repo.create(folder, sampleTask); await repo.create(folder, sampleTask);
const tasks = await repo.list(folder); const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1); expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("Fix login bug");
});
it("create with same title overwrites the previous file", async () => {
const first = await repo.create(folder, sampleTask);
const second = await repo.create(folder, {
...sampleTask,
description: "second",
});
const files = await fs.readdir(folder);
expect(files).toEqual(["fix-login-bug.task.md"]);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe(second.id);
expect(tasks[0].id).not.toBe(first.id);
expect(tasks[0].description).toBe("second");
}); });
}); });
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+25 -14
View File
@@ -5,27 +5,38 @@ import { generateId } from "@/utils/uuid";
const MAX_SAFE_NAME_LENGTH = 80; const MAX_SAFE_NAME_LENGTH = 80;
export type NowFn = () => string;
export class FsTaskRepository implements ITaskRepository { export class FsTaskRepository implements ITaskRepository {
#fs: IFileSystem;
#fileExtension: string;
#now: NowFn;
constructor( constructor(
private fs: IFileSystem, fs: IFileSystem,
private fileExtension: string, fileExtension: string,
) {} now: NowFn = () => new Date().toISOString(),
) {
this.#fs = fs;
this.#fileExtension = fileExtension;
this.#now = now;
}
async list(folderPath: string): Promise<Task[]> { async list(folderPath: string): Promise<Task[]> {
let entries: string[]; let entries: string[];
try { try {
entries = await this.fs.readdir(folderPath); entries = await this.#fs.readdir(folderPath);
} catch { } catch {
return []; return [];
} }
const files = entries.filter((name) => name.endsWith(this.fileExtension)); const files = entries.filter((name) => name.endsWith(this.#fileExtension));
const tasks: Task[] = []; const tasks: Task[] = [];
for (const file of files) { for (const file of files) {
try { try {
const fullPath = this.joinPath(folderPath, file); const fullPath = this.joinPath(folderPath, file);
const content = await this.fs.readFile(fullPath); const content = await this.#fs.readFile(fullPath);
const task = parseTaskFile(content); const task = parseTaskFile(content);
if (!task.id) continue; if (!task.id) continue;
tasks.push(task); tasks.push(task);
@@ -48,7 +59,7 @@ export class FsTaskRepository implements ITaskRepository {
folderPath: string, folderPath: string,
task: Omit<Task, "id" | "created" | "updated">, task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task> { ): Promise<Task> {
const now = new Date().toISOString(); const now = this.#now();
const newTask: Task = { const newTask: Task = {
...task, ...task,
id: generateId(), id: generateId(),
@@ -56,26 +67,26 @@ export class FsTaskRepository implements ITaskRepository {
updated: now, updated: now,
}; };
await this.fs.mkdir(folderPath); await this.#fs.mkdir(folderPath);
const filePath = this.taskFilePath(folderPath, newTask); const filePath = this.taskFilePath(folderPath, newTask);
await this.fs.writeFile(filePath, serializeTask(newTask)); await this.#fs.writeFile(filePath, serializeTask(newTask));
return newTask; return newTask;
} }
async update(folderPath: string, task: Task): Promise<void> { async update(folderPath: string, task: Task): Promise<void> {
const existing = await this.getById(folderPath, task.id); const existing = await this.getById(folderPath, task.id);
const updatedTask = { ...task, updated: new Date().toISOString() }; const updatedTask = { ...task, updated: this.#now() };
const newPath = this.taskFilePath(folderPath, updatedTask); const newPath = this.taskFilePath(folderPath, updatedTask);
if (existing) { if (existing) {
const oldPath = this.taskFilePath(folderPath, existing); const oldPath = this.taskFilePath(folderPath, existing);
if (oldPath !== newPath) { if (oldPath !== newPath) {
await this.fs.deleteFile(oldPath).catch(() => {}); await this.#fs.deleteFile(oldPath).catch(() => {});
} }
} }
await this.fs.writeFile(newPath, serializeTask(updatedTask)); await this.#fs.writeFile(newPath, serializeTask(updatedTask));
} }
async delete(folderPath: string, id: string): Promise<void> { async delete(folderPath: string, id: string): Promise<void> {
@@ -84,7 +95,7 @@ export class FsTaskRepository implements ITaskRepository {
const filePath = this.taskFilePath(folderPath, task); const filePath = this.taskFilePath(folderPath, task);
try { try {
await this.fs.deleteFile(filePath); await this.#fs.deleteFile(filePath);
} catch { } catch {
// already gone // already gone
} }
@@ -97,7 +108,7 @@ export class FsTaskRepository implements ITaskRepository {
.replace(/^-|-$/g, "") .replace(/^-|-$/g, "")
.slice(0, MAX_SAFE_NAME_LENGTH); .slice(0, MAX_SAFE_NAME_LENGTH);
return this.joinPath(folderPath, `${safeName}${this.fileExtension}`); return this.joinPath(folderPath, `${safeName}${this.#fileExtension}`);
} }
private joinPath(...segments: string[]): string { private joinPath(...segments: string[]): string {
+30 -12
View File
@@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest";
import { parseTaskFile, serializeTask } from "./markdown";
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import { describe, expect, it } from "vitest";
import { parseTaskFile, serializeTask } from "./markdown";
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/;
const fullTask: Task = { const fullTask: Task = {
id: "550e8400-e29b-41d4-a716-446655440000", id: "550e8400-e29b-41d4-a716-446655440000",
@@ -51,20 +53,17 @@ describe("markdown", () => {
expect(task.priority).toBe("medium"); expect(task.priority).toBe("medium");
expect(task.tags).toEqual([]); expect(task.tags).toEqual([]);
expect(task.assignee).toBe(""); expect(task.assignee).toBe("");
expect(task.created).toBeTruthy(); expect(task.created).toMatch(ISO_RE);
expect(task.updated).toBeTruthy(); expect(task.updated).toMatch(ISO_RE);
});
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", () => { it("round-trip: serialize → parse produces the same task", () => {
const serialized = serializeTask(fullTask); const serialized = serializeTask(fullTask);
expect(serialized).toContain("---");
expect(serialized).toContain("title: Пофиксить баг логина");
expect(serialized).toContain("status: in-progress");
expect(serialized).toContain("Ошибка валидации токена");
const parsed = parseTaskFile(serialized); const parsed = parseTaskFile(serialized);
expect(parsed).toEqual(fullTask); expect(parsed).toEqual(fullTask);
}); });
@@ -78,4 +77,23 @@ describe("markdown", () => {
const parsed = parseTaskFile(serialized); const parsed = parseTaskFile(serialized);
expect(parsed.description).toBe(task.description); expect(parsed.description).toBe(task.description);
}); });
it("converts Date values from YAML to ISO strings", () => {
const created = new Date("2026-07-12T10:00:00.000Z");
const updated = new Date("2026-07-12T11:00:00.000Z");
const content = [
"---",
'id: "date-task"',
'title: "With dates"',
`created: ${created.toISOString()}`,
`updated: ${updated.toISOString()}`,
"---",
"",
].join("\n");
// front-matter/js-yaml may return Date for unquoted ISO-like timestamps
const task = parseTaskFile(content);
expect(task.created).toBe("2026-07-12T10:00:00.000Z");
expect(task.updated).toBe("2026-07-12T11:00:00.000Z");
});
}); });
+24 -18
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { Task, TaskStatus } from "@/model/task"; import { Task, TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports"; import { IConfigProvider, ITaskRepository } from "@/ports";
import * as vscode from "vscode";
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"]; const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
@@ -12,30 +12,32 @@ const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
}; };
class TaskGroupTreeItem extends vscode.TreeItem { class TaskGroupTreeItem extends vscode.TreeItem {
constructor( readonly tasks: Task[];
label: string,
public readonly tasks: Task[], constructor(label: string, tasks: Task[], icon: vscode.ThemeIcon) {
icon: vscode.ThemeIcon,
) {
super(label, vscode.TreeItemCollapsibleState.Collapsed); super(label, vscode.TreeItemCollapsibleState.Collapsed);
this.iconPath = icon; this.iconPath = icon;
this.tasks = tasks;
this.description = `(${tasks.length})`; this.description = `(${tasks.length})`;
} }
} }
export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> { export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private _onDidChangeTreeData = new vscode.EventEmitter< #onDidChangeTreeData = new vscode.EventEmitter<
vscode.TreeItem | undefined | null | void vscode.TreeItem | undefined | null | void
>(); >();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event; readonly onDidChangeTreeData = this.#onDidChangeTreeData.event;
constructor( #taskStore: ITaskRepository;
private taskStore: ITaskRepository, #configStore: IConfigProvider;
private configStore: IConfigProvider,
) {} constructor(taskStore: ITaskRepository, configStore: IConfigProvider) {
this.#taskStore = taskStore;
this.#configStore = configStore;
}
refresh(): void { refresh(): void {
this._onDidChangeTreeData.fire(); this.#onDidChangeTreeData.fire();
} }
getTreeItem(element: vscode.TreeItem): vscode.TreeItem { getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
@@ -50,18 +52,22 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
} }
private async getRootChildren(): Promise<vscode.TreeItem[]> { private async getRootChildren(): Promise<vscode.TreeItem[]> {
const projectPath = this.configStore.getProjectTaskPath(); const projectPath = this.#configStore.getProjectTaskPath();
if (!projectPath) { if (!projectPath) {
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.list(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);
const label = status === "todo" ? "To Do" const label =
: status === "in-progress" ? "In Progress" status === "todo"
: status === "done" ? "Done" ? "To Do"
: status === "in-progress"
? "In Progress"
: status === "done"
? "Done"
: "Cancelled"; : "Cancelled";
const item = new TaskGroupTreeItem( const item = new TaskGroupTreeItem(
label, label,
+6 -6
View File
@@ -1,14 +1,14 @@
import { IFileSystem } from "@/ports"; import { IFileSystem } from "@/ports";
export class InMemoryFileSystem implements IFileSystem { export class InMemoryFileSystem implements IFileSystem {
private files = new Map<string, string>(); #files = new Map<string, string>();
reset(): void { reset(): void {
this.files.clear(); this.#files.clear();
} }
async readFile(path: string): Promise<string> { async readFile(path: string): Promise<string> {
const content = this.files.get(path); const content = this.#files.get(path);
if (content === undefined) { if (content === undefined) {
throw new Error(`ENOENT: no such file: ${path}`); throw new Error(`ENOENT: no such file: ${path}`);
} }
@@ -16,17 +16,17 @@ export class InMemoryFileSystem implements IFileSystem {
} }
async writeFile(path: string, content: string): Promise<void> { async writeFile(path: string, content: string): Promise<void> {
this.files.set(path, content); this.#files.set(path, content);
} }
async deleteFile(path: string): Promise<void> { async deleteFile(path: string): Promise<void> {
this.files.delete(path); this.#files.delete(path);
} }
async readdir(path: string): Promise<string[]> { async readdir(path: string): Promise<string[]> {
const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/"; const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/";
const seen = new Set<string>(); const seen = new Set<string>();
for (const key of this.files.keys()) { for (const key of this.#files.keys()) {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {
const relative = key.slice(prefix.length); const relative = key.slice(prefix.length);
const top = relative.split(/[/\\]/)[0]; const top = relative.split(/[/\\]/)[0];