From 51a074fe274cd04e7c80e0c54c5ea78cbbbb3955 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Thu, 9 Apr 2026 20:36:00 +1200 Subject: [PATCH] tests: assertion helper and tests, timesheetRowContentEditing tests, mock modal tracking and cleanup, mock modal button attributes --- src/__mocks__/obsidian.ts | 12 + src/__mocks__/setupObsidianMocks.ts | 7 +- .../timesheetRowContentEditing.test.ts | 239 +++++++++++++++++- src/components/timesheetRowContentEditing.ts | 34 ++- src/utils/assert.test.ts | 30 +++ src/utils/assert.ts | 5 + src/views/confirm-modal.ts | 15 +- src/views/file-name-prompt-modal.test.ts | 6 +- 8 files changed, 319 insertions(+), 29 deletions(-) create mode 100644 src/utils/assert.test.ts create mode 100644 src/utils/assert.ts diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index 0924d26..4dfdc57 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -399,6 +399,8 @@ export class MockSetting { } export class MockModal implements CloseableComponent { + static instances: Set = new Set(); + app: App; scope = {} as Scope; @@ -417,6 +419,8 @@ export class MockModal implements CloseableComponent { constructor(app: App) { this.app = app; + MockModal.instances.add(this); + this.containerEl = createMockContainer(); this.modalEl = this.containerEl.createDiv(); this.titleEl = this.modalEl.createSpan(); @@ -434,6 +438,7 @@ export class MockModal implements CloseableComponent { this.onClose(); if (this.closeCallback) this.closeCallback(); + MockModal.instances.delete(this); }); } @@ -461,6 +466,13 @@ export class MockModal implements CloseableComponent { this.closeCallback = callback; return this; } + + static cleanupAll() { + for (const instance of MockModal.instances) { + instance.close(); + } + MockModal.instances.clear(); + } } export const MockNotice = vi.fn( diff --git a/src/__mocks__/setupObsidianMocks.ts b/src/__mocks__/setupObsidianMocks.ts index 9378e3d..da56c6b 100644 --- a/src/__mocks__/setupObsidianMocks.ts +++ b/src/__mocks__/setupObsidianMocks.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; import { MockButtonComponent, @@ -16,6 +16,11 @@ import { vi.mock("obsidian", () => { setObsidianMockElementHelpersGlobal(); + // Ensure all mock modals are detached from the DOM after each test + afterEach(() => { + MockModal.cleanupAll(); + }); + return { TFile: MockTFile, TFolder: MockTFolder, diff --git a/src/components/timesheetRowContentEditing.test.ts b/src/components/timesheetRowContentEditing.test.ts index 14ffcc1..5e56725 100644 --- a/src/components/timesheetRowContentEditing.test.ts +++ b/src/components/timesheetRowContentEditing.test.ts @@ -4,7 +4,7 @@ import type { App } from "obsidian"; import moment from "moment"; import { v4 } from "uuid"; -import { beforeEach, it, describe, Mock, vi } from "vitest"; +import { beforeEach, it, describe, Mock, vi, expect, afterEach } from "vitest"; import type { TimekeepSettings } from "@/settings"; import type { Store } from "@/store"; @@ -17,33 +17,37 @@ import { createStore } from "@/store"; import { TimesheetRowContentEditing } from "./timesheetRowContentEditing"; describe("TimesheetRowContentEditing", () => { - const start = moment(); - - const entry: TimeEntry = { - id: v4(), - name: "Test", - startTime: moment(start), - endTime: null, - subEntries: null, - }; - let containerEl: HTMLElement; let app: App; let timekeep: Store; let settings: Store; let onFinishEditing: Mock<() => void>; + let component: TimesheetRowContentEditing; beforeEach(() => { app = {} as App; containerEl = createMockContainer(); timekeep = createStore({ entries: [] }); settings = createStore(defaultSettings); - onFinishEditing = vi.fn(); }); + afterEach(() => { + if (component) component.unload(); + }); + it("should load without error", () => { - const component = new TimesheetRowContentEditing( + const start = moment(); + + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }; + + component = new TimesheetRowContentEditing( containerEl, app, timekeep, @@ -53,4 +57,213 @@ describe("TimesheetRowContentEditing", () => { ); component.load(); }); + + it("clicking the cancel button should call onFinishEditing", () => { + const start = moment(); + + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }; + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + component.load(); + + const cancelButton = containerEl.querySelector('.timekeep-action[data-action="cancel"]'); + expect(cancelButton).not.toBeNull(); + (cancelButton as HTMLButtonElement).click(); + expect(onFinishEditing).toHaveBeenCalledOnce(); + }); + + it("clicking the save button should update the timekeep state call onFinishEditing", () => { + const start = moment(); + + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: moment(start), + subEntries: null, + }; + + const setState = vi.spyOn(timekeep, "setState"); + + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + const onSubmit = vi.spyOn(component, "onSubmit"); + component.load(); + + const form = containerEl.querySelector("form.timesheet-editing"); + expect(form).not.toBeNull(); + (form as HTMLFormElement).dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: true }) + ); + + expect(onSubmit).toHaveBeenCalledOnce(); + expect(onFinishEditing).toHaveBeenCalledOnce(); + expect(setState).toHaveBeenCalledOnce(); + }); + + it("editing a group entry should hide the start and end time inputs", () => { + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: null, + endTime: null, + subEntries: [], + }; + + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + component.load(); + + const startTime = + containerEl.querySelector('.timekeep-input[name="start-time"]')?.parentElement ?? null; + const endTime = + containerEl.querySelector('.timekeep-input[name="end-time"]')?.parentElement ?? null; + + expect(startTime).not.toBeNull(); + expect(endTime).not.toBeNull(); + expect((startTime as HTMLElement).hidden).toBeTruthy(); + expect((endTime as HTMLElement).hidden).toBeTruthy(); + }); + + it("clicking delete on an entry should open a modal for confirmation", () => { + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: null, + endTime: null, + subEntries: [], + }; + + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + const onConfirmDelete = vi.spyOn(component, "onConfirmDelete"); + component.load(); + + const deleteButton = containerEl.querySelector('.timekeep-action[data-action="delete"]'); + expect(deleteButton).not.toBeNull(); + (deleteButton as HTMLButtonElement).click(); + + expect(onConfirmDelete).toHaveBeenCalled(); + + const contentEl: HTMLElement = document!.querySelector(".mock-modal-content")!; + expect(contentEl).toBeInstanceOf(HTMLElement); + }); + + it("cancelling deletion should do nothing", () => { + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: null, + endTime: null, + subEntries: [], + }; + + timekeep.setState({ entries: [entry] }); + + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + const onConfirmDelete = vi.spyOn(component, "onConfirmDelete"); + const onConfirmedDelete = vi.spyOn(component, "onConfirmedDelete"); + component.load(); + + const deleteButton = containerEl.querySelector('.timekeep-action[data-action="delete"]'); + expect(deleteButton).not.toBeNull(); + (deleteButton as HTMLButtonElement).click(); + + expect(onConfirmDelete).toHaveBeenCalled(); + + const contentEl: HTMLElement = document!.querySelector(".mock-modal-content")!; + expect(contentEl).toBeInstanceOf(HTMLElement); + + const cancelButton = contentEl.querySelector( + '.timekeep-confirm-modal-button[data-action="cancel"]' + ); + expect(cancelButton).not.toBeNull(); + (cancelButton as HTMLButtonElement).click(); + + expect(onConfirmedDelete).toHaveBeenCalledWith(false); + + // Timekeep should not be empty + expect(timekeep.getState()).toEqual({ entries: [entry] }); + }); + + it("confirming deletion should remove the entry from the timekeep", () => { + const entry: TimeEntry = { + id: v4(), + name: "Test", + startTime: null, + endTime: null, + subEntries: [], + }; + + timekeep.setState({ entries: [entry] }); + + component = new TimesheetRowContentEditing( + containerEl, + app, + timekeep, + settings, + entry, + onFinishEditing + ); + const onConfirmDelete = vi.spyOn(component, "onConfirmDelete"); + const onConfirmedDelete = vi.spyOn(component, "onConfirmedDelete"); + component.load(); + + const deleteButton = containerEl.querySelector('.timekeep-action[data-action="delete"]'); + expect(deleteButton).not.toBeNull(); + (deleteButton as HTMLButtonElement).click(); + + expect(onConfirmDelete).toHaveBeenCalled(); + + const contentEl: HTMLElement = document!.querySelector(".mock-modal-content")!; + expect(contentEl).toBeInstanceOf(HTMLElement); + + const okButton = contentEl.querySelector( + '.timekeep-confirm-modal-button[data-action="ok"]' + ); + expect(okButton).not.toBeNull(); + (okButton as HTMLButtonElement).click(); + + expect(onConfirmedDelete).toHaveBeenCalledWith(true); + + // Timekeep should be empty + expect(timekeep.getState()).toEqual({ entries: [] }); + }); }); diff --git a/src/components/timesheetRowContentEditing.ts b/src/components/timesheetRowContentEditing.ts index 5cab479..b43f3ab 100644 --- a/src/components/timesheetRowContentEditing.ts +++ b/src/components/timesheetRowContentEditing.ts @@ -1,5 +1,7 @@ import type { App } from "obsidian"; +import { assert } from "vitest"; + import type { TimekeepSettings } from "@/settings"; import type { Store } from "@/store"; import type { TimeEntry, Timekeep } from "@/timekeep/schema"; @@ -72,32 +74,38 @@ export class TimesheetRowContentEditing extends ReplaceableComponent { const formEl = colEl.createEl("form", { cls: "timesheet-editing" }); this.registerDomEvent(formEl, "submit", this.onSubmit.bind(this)); - const nameLabelEl = formEl.createEl("label", { text: " Name" }); + const nameLabelEl = formEl.createEl("label", { + cls: "timekeep-input-label", + text: "Name", + }); const nameInputEl = nameLabelEl.createEl("input", { cls: "timekeep-input", type: "text", }); - + nameInputEl.name = "name"; this.#nameInputEl = nameInputEl; const startTimeLabelEl = formEl.createEl("label", { text: "Start Time", }); this.#startTimeLabelEl = startTimeLabelEl; - const startTimeInputEl = startTimeLabelEl.createEl("input", { cls: "timekeep-input", type: "text", }); + startTimeInputEl.name = "start-time"; this.#startTimeInputEl = startTimeInputEl; - const endTimeLabelEl = formEl.createEl("label", { text: "End Time" }); + const endTimeLabelEl = formEl.createEl("label", { + cls: "timekeep-input-label", + text: "End Time", + }); this.#endTimeLabelEl = endTimeLabelEl; - const endTimeInputEl = endTimeLabelEl.createEl("input", { cls: "timekeep-input", type: "text", }); + endTimeInputEl.name = "end-time"; this.#endTimeInputEl = endTimeInputEl; const actionsEl = formEl.createDiv({ @@ -106,6 +114,9 @@ export class TimesheetRowContentEditing extends ReplaceableComponent { const saveButton = actionsEl.createEl("button", { cls: "timekeep-action", + attr: { + "data-action": "save", + }, }); saveButton.type = "submit"; createObsidianIcon(saveButton, "edit", "text-button-icon"); @@ -113,6 +124,9 @@ export class TimesheetRowContentEditing extends ReplaceableComponent { const cancelButton = actionsEl.createEl("button", { cls: "timekeep-action", + attr: { + "data-action": "cancel", + }, }); cancelButton.type = "button"; createObsidianIcon(cancelButton, "x", "text-button-icon"); @@ -121,6 +135,9 @@ export class TimesheetRowContentEditing extends ReplaceableComponent { const deleteButton = actionsEl.createEl("button", { cls: "timekeep-action", + attr: { + "data-action": "delete", + }, }); deleteButton.type = "button"; createObsidianIcon(deleteButton, "trash", "text-button-icon"); @@ -184,9 +201,10 @@ export class TimesheetRowContentEditing extends ReplaceableComponent { } onSubmit(event: Event) { - if (!this.#nameInputEl || !this.#startTimeInputEl || !this.#endTimeInputEl) { - return; - } + assert( + this.#nameInputEl && this.#startTimeInputEl && this.#endTimeInputEl, + "Expected inputs to be defined" + ); event.preventDefault(); event.stopPropagation(); diff --git a/src/utils/assert.test.ts b/src/utils/assert.test.ts new file mode 100644 index 0000000..e612e39 --- /dev/null +++ b/src/utils/assert.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; + +import { assert } from "./assert"; + +describe("assert", () => { + it("does nothing when condition is true", () => { + expect(() => assert(true)).not.toThrow(); + }); + + it("throws when condition is false", () => { + expect(() => assert(false)).toThrow("Assertion failed"); + }); + + it("throws with a custom message", () => { + expect(() => assert(false, "Custom error")).toThrow("Custom error"); + }); + + it("treats truthy values as true", () => { + expect(() => assert(1 as unknown as boolean)).not.toThrow(); + expect(() => assert("hello" as unknown as boolean)).not.toThrow(); + expect(() => assert({} as unknown as boolean)).not.toThrow(); + }); + + it("treats falsy values as false", () => { + expect(() => assert(0 as unknown as boolean)).toThrow(); + expect(() => assert("" as unknown as boolean)).toThrow(); + expect(() => assert(null as unknown as boolean)).toThrow(); + expect(() => assert(undefined as unknown as boolean)).toThrow(); + }); +}); diff --git a/src/utils/assert.ts b/src/utils/assert.ts new file mode 100644 index 0000000..290f29e --- /dev/null +++ b/src/utils/assert.ts @@ -0,0 +1,5 @@ +export function assert(condition: boolean, message = "Assertion failed") { + if (!condition) { + throw new Error(message); + } +} diff --git a/src/views/confirm-modal.ts b/src/views/confirm-modal.ts index 7055cec..53a5e5a 100644 --- a/src/views/confirm-modal.ts +++ b/src/views/confirm-modal.ts @@ -26,8 +26,19 @@ export class ConfirmModal extends Modal { this.contentEl.createEl("p", { text: this.message }); new Setting(this.contentEl) - .addButton((btn) => btn.setButtonText("Ok").setCta().onClick(this.onOk.bind(this))) - .addButton((btn) => btn.setButtonText("Cancel").onClick(this.onCancel.bind(this))); + .addButton((btn) => { + btn.buttonEl.setAttribute("data-action", "ok"); + btn.setClass("timekeep-confirm-modal-button") + .setButtonText("Ok") + .setCta() + .onClick(this.onOk.bind(this)); + }) + .addButton((btn) => { + btn.buttonEl.setAttribute("data-action", "cancel"); + btn.setClass("timekeep-confirm-modal-button") + .setButtonText("Cancel") + .onClick(this.onCancel.bind(this)); + }); } onOk() { diff --git a/src/views/file-name-prompt-modal.test.ts b/src/views/file-name-prompt-modal.test.ts index 9ea7bcb..e7ab6e8 100644 --- a/src/views/file-name-prompt-modal.test.ts +++ b/src/views/file-name-prompt-modal.test.ts @@ -2,7 +2,7 @@ import type { App } from "obsidian"; -import { describe, it, expect, beforeEach, Mock, vi, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, Mock, vi } from "vitest"; import { FileNamePromptModal } from "./file-name-prompt-modal"; @@ -17,10 +17,6 @@ describe("ConfirmModal", () => { component = new FileNamePromptModal(app, onChoice); }); - afterEach(() => { - component.close(); - }); - it("should open without error", () => { expect(() => component.open()).not.toThrow(); });