tests: assertion helper and tests, timesheetRowContentEditing tests,

mock modal tracking and cleanup, mock modal button attributes
This commit is contained in:
Jacobtread 2026-04-09 20:36:00 +12:00
parent 9d70a2a3b4
commit 51a074fe27
8 changed files with 319 additions and 29 deletions

View file

@ -399,6 +399,8 @@ export class MockSetting {
}
export class MockModal implements CloseableComponent {
static instances: Set<MockModal> = 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(

View file

@ -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,

View file

@ -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<Timekeep>;
let settings: Store<TimekeepSettings>;
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: [] });
});
});

View file

@ -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();

30
src/utils/assert.test.ts Normal file
View file

@ -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();
});
});

5
src/utils/assert.ts Normal file
View file

@ -0,0 +1,5 @@
export function assert(condition: boolean, message = "Assertion failed") {
if (!condition) {
throw new Error(message);
}
}

View file

@ -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() {

View file

@ -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();
});