mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
tests: assertion helper and tests, timesheetRowContentEditing tests,
mock modal tracking and cleanup, mock modal button attributes
This commit is contained in:
parent
9d70a2a3b4
commit
51a074fe27
8 changed files with 319 additions and 29 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
30
src/utils/assert.test.ts
Normal 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
5
src/utils/assert.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function assert(condition: boolean, message = "Assertion failed") {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue