diff --git a/docs/REFACTOR-AND-TEST.md b/docs/REFACTOR-AND-TEST.md index 43fdb56..5a0e43f 100644 --- a/docs/REFACTOR-AND-TEST.md +++ b/docs/REFACTOR-AND-TEST.md @@ -1,5 +1,8 @@ # Vitest + Clean Architecture Refactoring +> **Статус: выполнено / архив** +> План закрыт, дальше не ведём и не обновляем. Исторический снимок задачи. + ## Цель Установить Vitest, переделать архитектуру ближе к чистой (порты/адаптеры), diff --git a/eslint.config.mjs b/eslint.config.mjs index 464b248..34fdad0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,6 +37,25 @@ export default defineConfig( 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, + }, + }, + ], }, }, { diff --git a/src/storage/FsTaskRepository.test.ts b/src/storage/FsTaskRepository.test.ts index de1773c..5d2b018 100644 --- a/src/storage/FsTaskRepository.test.ts +++ b/src/storage/FsTaskRepository.test.ts @@ -4,11 +4,18 @@ import { beforeEach, describe, expect, it } from "vitest"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; 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", () => { let fs: InMemoryFileSystem; let repo: ITaskRepository; + let clockStep: number; 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 = { title: "Fix login bug", @@ -21,35 +28,41 @@ describe("FsTaskRepository", () => { beforeEach(() => { 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 () => { 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.created).toBeTruthy(); - expect(task.updated).toBe(task.created); + expect(task.created).toBe(t0); + expect(task.updated).toBe(t0); const files = await fs.readdir(folder); - expect(files).toHaveLength(1); - expect(files[0]).toMatch(/\.task\.md$/); + expect(files).toEqual(["fix-login-bug.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("status: todo"); }); it("list returns created tasks sorted by updated desc", async () => { await repo.create(folder, { ...sampleTask, title: "A" }); - await delay(10); 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[0].updated).toBe(t1); expect(tasks[1].title).toBe("A"); + expect(tasks[1].updated).toBe(t0); }); it("getById returns the correct task", async () => { @@ -63,19 +76,50 @@ describe("FsTaskRepository", () => { 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); - await delay(5); - const updated: Task = { ...task, title: "Fixed!", status: "done" }; + const updated: Task = { ...task, 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(), - ); + expect(found!.title).toBe(task.title); + 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 () => { @@ -84,6 +128,7 @@ describe("FsTaskRepository", () => { const tasks = await repo.list(folder); expect(tasks).toHaveLength(0); + expect(await fs.readdir(folder)).toEqual([]); }); it("delete is a no-op for unknown id", async () => { @@ -96,8 +141,8 @@ describe("FsTaskRepository", () => { }); it("filters files by extension", async () => { - fs.writeFile(`${folder}/notes.txt`, "hello"); - fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n"); + await fs.writeFile(`${folder}/notes.txt`, "hello"); + await fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n"); await repo.create(folder, sampleTask); @@ -105,15 +150,41 @@ describe("FsTaskRepository", () => { expect(tasks).toHaveLength(1); }); - it("skips unparseable .task.md files silently", async () => { - fs.writeFile(`${folder}/corrupt.task.md`, "not valid frontmatter"); + it("list returns empty when only non-matching extensions exist", async () => { + 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); const tasks = await repo.list(folder); 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 { - return new Promise((r) => setTimeout(r, ms)); -} diff --git a/src/storage/FsTaskRepository.ts b/src/storage/FsTaskRepository.ts index 96bbe55..a21ceaa 100644 --- a/src/storage/FsTaskRepository.ts +++ b/src/storage/FsTaskRepository.ts @@ -5,27 +5,38 @@ import { generateId } from "@/utils/uuid"; const MAX_SAFE_NAME_LENGTH = 80; +export type NowFn = () => string; + export class FsTaskRepository implements ITaskRepository { + #fs: IFileSystem; + #fileExtension: string; + #now: NowFn; + constructor( - private fs: IFileSystem, - private fileExtension: string, - ) {} + fs: IFileSystem, + fileExtension: string, + now: NowFn = () => new Date().toISOString(), + ) { + this.#fs = fs; + this.#fileExtension = fileExtension; + this.#now = now; + } async list(folderPath: string): Promise { let entries: string[]; try { - entries = await this.fs.readdir(folderPath); + entries = await this.#fs.readdir(folderPath); } catch { return []; } - const files = entries.filter((name) => name.endsWith(this.fileExtension)); + 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 content = await this.#fs.readFile(fullPath); const task = parseTaskFile(content); if (!task.id) continue; tasks.push(task); @@ -48,7 +59,7 @@ export class FsTaskRepository implements ITaskRepository { folderPath: string, task: Omit, ): Promise { - const now = new Date().toISOString(); + const now = this.#now(); const newTask: Task = { ...task, id: generateId(), @@ -56,26 +67,26 @@ export class FsTaskRepository implements ITaskRepository { updated: now, }; - await this.fs.mkdir(folderPath); + await this.#fs.mkdir(folderPath); const filePath = this.taskFilePath(folderPath, newTask); - await this.fs.writeFile(filePath, serializeTask(newTask)); + await this.#fs.writeFile(filePath, serializeTask(newTask)); return newTask; } async update(folderPath: string, task: Task): Promise { 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); if (existing) { const oldPath = this.taskFilePath(folderPath, existing); 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 { @@ -84,7 +95,7 @@ export class FsTaskRepository implements ITaskRepository { const filePath = this.taskFilePath(folderPath, task); try { - await this.fs.deleteFile(filePath); + await this.#fs.deleteFile(filePath); } catch { // already gone } @@ -97,7 +108,7 @@ export class FsTaskRepository implements ITaskRepository { .replace(/^-|-$/g, "") .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 { diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts index abfd473..1850e22 100644 --- a/src/utils/markdown.test.ts +++ b/src/utils/markdown.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from "vitest"; -import { parseTaskFile, serializeTask } from "./markdown"; 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 = { id: "550e8400-e29b-41d4-a716-446655440000", @@ -51,20 +53,17 @@ describe("markdown", () => { 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("Ошибка валидации токена"); + expect(task.created).toMatch(ISO_RE); + expect(task.updated).toMatch(ISO_RE); }); it("round-trip: serialize → parse produces the same task", () => { const serialized = serializeTask(fullTask); + expect(serialized).toContain("---"); + expect(serialized).toContain("title: Пофиксить баг логина"); + expect(serialized).toContain("status: in-progress"); + expect(serialized).toContain("Ошибка валидации токена"); + const parsed = parseTaskFile(serialized); expect(parsed).toEqual(fullTask); }); @@ -78,4 +77,23 @@ describe("markdown", () => { const parsed = parseTaskFile(serialized); 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"); + }); }); diff --git a/src/views/taskTreeProvider.ts b/src/views/taskTreeProvider.ts index 4ab077e..36a9153 100644 --- a/src/views/taskTreeProvider.ts +++ b/src/views/taskTreeProvider.ts @@ -1,6 +1,6 @@ -import * as vscode from "vscode"; import { Task, TaskStatus } from "@/model/task"; import { IConfigProvider, ITaskRepository } from "@/ports"; +import * as vscode from "vscode"; const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"]; @@ -12,30 +12,32 @@ const STATUS_ICONS: Record = { }; class TaskGroupTreeItem extends vscode.TreeItem { - constructor( - label: string, - public readonly tasks: Task[], - icon: vscode.ThemeIcon, - ) { + readonly tasks: Task[]; + + constructor(label: string, tasks: Task[], icon: vscode.ThemeIcon) { super(label, vscode.TreeItemCollapsibleState.Collapsed); this.iconPath = icon; + this.tasks = tasks; this.description = `(${tasks.length})`; } } export class TaskTreeProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter< + #onDidChangeTreeData = new vscode.EventEmitter< vscode.TreeItem | undefined | null | void >(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + readonly onDidChangeTreeData = this.#onDidChangeTreeData.event; - constructor( - private taskStore: ITaskRepository, - private configStore: IConfigProvider, - ) {} + #taskStore: ITaskRepository; + #configStore: IConfigProvider; + + constructor(taskStore: ITaskRepository, configStore: IConfigProvider) { + this.#taskStore = taskStore; + this.#configStore = configStore; + } refresh(): void { - this._onDidChangeTreeData.fire(); + this.#onDidChangeTreeData.fire(); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { @@ -50,19 +52,23 @@ export class TaskTreeProvider implements vscode.TreeDataProvider { - const projectPath = this.configStore.getProjectTaskPath(); + const projectPath = this.#configStore.getProjectTaskPath(); if (!projectPath) { 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) => { const groupTasks = tasks.filter((t) => t.status === status); - const label = status === "todo" ? "To Do" - : status === "in-progress" ? "In Progress" - : status === "done" ? "Done" - : "Cancelled"; + const label = + status === "todo" + ? "To Do" + : status === "in-progress" + ? "In Progress" + : status === "done" + ? "Done" + : "Cancelled"; const item = new TaskGroupTreeItem( label, groupTasks, diff --git a/tests/helpers/InMemoryFileSystem.ts b/tests/helpers/InMemoryFileSystem.ts index f6eae98..186fe96 100644 --- a/tests/helpers/InMemoryFileSystem.ts +++ b/tests/helpers/InMemoryFileSystem.ts @@ -1,14 +1,14 @@ import { IFileSystem } from "@/ports"; export class InMemoryFileSystem implements IFileSystem { - private files = new Map(); + #files = new Map(); reset(): void { - this.files.clear(); + this.#files.clear(); } async readFile(path: string): Promise { - const content = this.files.get(path); + const content = this.#files.get(path); if (content === undefined) { throw new Error(`ENOENT: no such file: ${path}`); } @@ -16,17 +16,17 @@ export class InMemoryFileSystem implements IFileSystem { } async writeFile(path: string, content: string): Promise { - this.files.set(path, content); + this.#files.set(path, content); } async deleteFile(path: string): Promise { - this.files.delete(path); + this.#files.delete(path); } async readdir(path: string): Promise { const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/"; const seen = new Set(); - for (const key of this.files.keys()) { + for (const key of this.#files.keys()) { if (key.startsWith(prefix)) { const relative = key.slice(prefix.length); const top = relative.split(/[/\\]/)[0];