feat: mock component handle loading and unload edge cases, split start

block into form and running container, tests for start form
This commit is contained in:
Jacobtread 2026-04-09 22:49:22 +12:00
parent a6cade73f6
commit dbe4ac4a9e
10 changed files with 541 additions and 145 deletions

View file

@ -251,36 +251,44 @@ export class MockVault {
export class MockComponent {
private children: Component[] = [];
private loaded = false;
private events: EventRef[] = [];
private callbacks: VoidFunction[] = [];
constructor() {}
load() {
if (this.loaded) {
return;
}
this.loaded = true;
this.onload();
this.children.map((c) => c.load());
this.children.forEach((c) => c.load());
}
unload() {
this.children.forEach((c) => c.unload());
this.onunload();
for (const eventRef of this.events) {
const ref = eventRef as MockEventRef;
ref.callback();
if (!this.loaded) {
return;
}
this.loaded = false;
this.children.forEach((c) => c.unload());
for (const callback of this.callbacks) {
callback();
}
this.loaded = false;
this.onunload();
}
onload() {}
onunload() {}
registerEvent(eventRef: EventRef) {
this.events.push(eventRef);
this.callbacks.push(() => {
const ref = eventRef as MockEventRef;
ref.callback();
});
}
register(callback: VoidFunction) {

View file

@ -9,7 +9,8 @@ import { Timekeep } from "@/timekeep/schema";
import { DomComponent } from "./domComponent";
import { TimesheetCounters } from "./timesheetCounters";
import { TimesheetExportActions } from "./timesheetExportActions";
import { TimesheetStart } from "./timesheetStart";
import { TimesheetStartContainer } from "./timesheetStartContainer";
import { TimesheetStartForm } from "./timesheetStartForm";
import { TimesheetTable } from "./timesheetTable";
/**
@ -53,15 +54,19 @@ export class TimesheetApp extends DomComponent {
this.wrapperEl = wrapperEl;
console.log(this);
const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep);
const start = new TimesheetStart(
const startForm = new TimesheetStartForm(
wrapperEl,
this.timekeep,
this.settings,
this.autocomplete
);
const startContainer = new TimesheetStartContainer(wrapperEl, this.timekeep, this.settings);
const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings);
const exportActions = new TimesheetExportActions(
@ -73,7 +78,8 @@ export class TimesheetApp extends DomComponent {
);
this.addChild(counters);
this.addChild(start);
this.addChild(startForm);
this.addChild(startContainer);
this.addChild(table);
this.addChild(exportActions);
}

View file

@ -1,40 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, it, describe } from "vitest";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep } from "@/timekeep/schema";
import { createMockContainer, MockVault } from "@/__mocks__/obsidian";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepRegistry } from "@/service/registry";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { TimesheetStart } from "./timesheetStart";
describe("TimesheetStart", () => {
let containerEl: HTMLElement;
let vault: MockVault;
let timekeep: Store<Timekeep>;
let settings: Store<TimekeepSettings>;
let registry: TimekeepRegistry;
let autocomplete: TimekeepAutocomplete;
beforeEach(() => {
vault = new MockVault();
containerEl = createMockContainer();
timekeep = createStore({ entries: [] });
settings = createStore(defaultSettings);
registry = new TimekeepRegistry(vault.asVault(), settings);
autocomplete = new TimekeepAutocomplete(registry, settings);
});
it("should load without error", () => {
const component = new TimesheetStart(containerEl, timekeep, settings, autocomplete);
component.load();
});
});

View file

@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import moment from "moment";
import { v4 } from "uuid";
import { beforeEach, it, describe, expect, afterEach } from "vitest";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep } from "@/timekeep/schema";
import { createMockContainer } from "@/__mocks__/obsidian";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { TimesheetStartContainer } from "./timesheetStartContainer";
import { TimesheetStartEditing } from "./timesheetStartEditing";
import { TimesheetStartRunning } from "./timesheetStartRunning";
describe("TimesheetStartContainer", () => {
let containerEl: HTMLElement;
let timekeep: Store<Timekeep>;
let settings: Store<TimekeepSettings>;
let component: TimesheetStartContainer;
beforeEach(() => {
containerEl = createMockContainer();
timekeep = createStore({ entries: [] });
settings = createStore(defaultSettings);
});
afterEach(() => {
if (component) component.unload();
});
it("should load without error", () => {
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
});
it("should be undefined content without a running entry", () => {
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
expect(component.getContent()).toBeUndefined();
});
it("should be TimesheetStartRunning when theres a running entry", () => {
const start = moment();
timekeep.setState({
entries: [
{
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
expect(component.getContent()).toBeInstanceOf(TimesheetStartRunning);
});
it("should be TimesheetStartEditing when theres a running entry and edit is pressed", () => {
const start = moment();
timekeep.setState({
entries: [
{
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
const content = component.getContent() as TimesheetStartRunning;
content.onStartEditing();
expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing);
});
it("timekeep updates when editing should update the running entry", () => {
const start = moment();
timekeep.setState({
entries: [
{
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
const content = component.getContent() as TimesheetStartRunning;
content.onStartEditing();
expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing);
timekeep.setState(timekeep.getState());
expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing);
});
it("after a timekeep update when editing if theres no more current entry should return to undefined state", () => {
const start = moment();
timekeep.setState({
entries: [
{
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
component = new TimesheetStartContainer(containerEl, timekeep, settings);
component.load();
const content = component.getContent() as TimesheetStartRunning;
content.onStartEditing();
expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing);
timekeep.setState({ entries: [] });
expect(component.getContent()).toBeUndefined();
});
});

View file

@ -0,0 +1,97 @@
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep } from "@/timekeep/schema";
import { getRunningEntry } from "@/timekeep/queries";
import { assert } from "@/utils/assert";
import { ContentComponent } from "./contentComponent";
import { TimesheetStartEditing } from "./timesheetStartEditing";
import { TimesheetStartRunning } from "./timesheetStartRunning";
export class TimesheetStartContainer extends ContentComponent<
TimesheetStartRunning | TimesheetStartEditing
> {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
constructor(
containerEl: HTMLElement,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>
) {
super(containerEl);
this.timekeep = timekeep;
this.settings = settings;
}
onload(): void {
super.onload();
const onUpdate = this.onUpdate.bind(this);
this.register(this.timekeep.subscribe(onUpdate));
onUpdate();
}
onUpdate() {
if (this.getContent() instanceof TimesheetStartEditing) {
this.setEditingView();
return;
}
this.setCurrentView();
}
/**
* Switch to the editing view
*/
setEditingView() {
const contentEl = this.containerEl;
assert(contentEl, "Content element should be defined");
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
this.setContent(undefined);
return;
}
this.setContent(
new TimesheetStartEditing(
contentEl,
this.timekeep,
this.settings,
currentEntry.name,
this.setCurrentView.bind(this)
)
);
}
/**
* Switch to the default creation view
*/
setCurrentView() {
const contentEl = this.containerEl;
assert(contentEl, "Content element should be defined");
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
this.setContent(undefined);
return;
}
this.setContent(
new TimesheetStartRunning(
contentEl,
this.timekeep,
this.settings,
currentEntry,
this.setEditingView.bind(this)
)
);
}
}

View file

@ -1,6 +1,8 @@
// @vitest-environment happy-dom
import { beforeEach, it, describe, vi } from "vitest";
import moment from "moment";
import { v4 } from "uuid";
import { beforeEach, it, describe, vi, expect } from "vitest";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
@ -35,4 +37,69 @@ describe("TimesheetStartEditing", () => {
);
component.load();
});
it("should save nothing if the running entry doesn't exist", () => {
const component = new TimesheetStartEditing(
containerEl,
timekeep,
settings,
"Test Entry",
onFinishedEditing
);
const onSave = vi.spyOn(component, "onSave");
component.load();
const formEl = component.wrapperEl!;
(formEl as HTMLFormElement).dispatchEvent(
new SubmitEvent("submit", { bubbles: true, cancelable: false })
);
expect(onSave).toHaveBeenCalled();
});
it("should be able to save the running entry", () => {
const start = moment();
const end = moment().add(1, "hour");
vi.setSystemTime(end.toDate());
const entry = {
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
};
timekeep.setState({
entries: [entry],
});
const component = new TimesheetStartEditing(
containerEl,
timekeep,
settings,
"Test Entry",
onFinishedEditing
);
const onSave = vi.spyOn(component, "onSave");
component.load();
const formEl = component.wrapperEl!;
const nameInputEl = formEl.querySelector(".timekeep-name");
expect(nameInputEl).not.toBeNull();
(nameInputEl as HTMLInputElement).value = "New Name";
(formEl as HTMLFormElement).dispatchEvent(
new SubmitEvent("submit", { bubbles: true, cancelable: false })
);
expect(onSave).toHaveBeenCalled();
expect(timekeep.getState()).toEqual({
entries: [{ ...entry, name: "New Name" }],
});
});
});

View file

@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import moment from "moment";
import { beforeEach, it, describe, afterEach, vi, expect } from "vitest";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep } from "@/timekeep/schema";
import { createMockContainer, MockVault } from "@/__mocks__/obsidian";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepRegistry } from "@/service/registry";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { TimesheetStartForm } from "./timesheetStartForm";
vi.mock(import("uuid"), async (importOriginal) => {
return {
...(await importOriginal()),
v4: vi.fn(() => "mocked-uuid"),
};
});
describe("TimesheetStart", () => {
let containerEl: HTMLElement;
let vault: MockVault;
let timekeep: Store<Timekeep>;
let settings: Store<TimekeepSettings>;
let registry: TimekeepRegistry;
let autocomplete: TimekeepAutocomplete;
let component: TimesheetStartForm;
beforeEach(() => {
vault = new MockVault();
containerEl = createMockContainer();
timekeep = createStore({ entries: [] });
settings = createStore(defaultSettings);
registry = new TimekeepRegistry(vault.asVault(), settings);
autocomplete = new TimekeepAutocomplete(registry, settings);
});
afterEach(() => {
if (component) component.unload();
});
it("should load without error", () => {
component = new TimesheetStartForm(containerEl, timekeep, settings, autocomplete);
component.load();
});
it("clicking start should start a new entry with the name", () => {
const start = moment();
vi.setSystemTime(start.toDate());
component = new TimesheetStartForm(containerEl, timekeep, settings, autocomplete);
const onStart = vi.spyOn(component, "onStart");
component.load();
const formEl = component.wrapperEl as HTMLFormElement;
const nameInputEl = formEl.querySelector("#timekeepBlockName");
(nameInputEl as HTMLInputElement).value = "Test";
formEl.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(onStart).toHaveBeenCalled();
expect(timekeep.getState()).toEqual({
entries: [
{
id: "mocked-uuid",
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
});
});

View file

@ -9,18 +9,14 @@ import { getRunningEntry } from "@/timekeep/queries";
import { startNewEntry } from "@/timekeep/start";
import { assert } from "@/utils/assert";
import { ContentComponent } from "./contentComponent";
import { DomComponent } from "./domComponent";
import { createObsidianIcon } from "./obsidianIcon";
import { TimesheetNameInput } from "./timesheetNameInput";
import { TimesheetStartEditing } from "./timesheetStartEditing";
import { TimesheetStartRunning } from "./timesheetStartRunning";
/**
* The start section above the timesheet table
*/
export class TimesheetStart extends ContentComponent<
TimesheetStartRunning | TimesheetStartEditing
> {
export class TimesheetStartForm extends DomComponent {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
@ -28,9 +24,6 @@ export class TimesheetStart extends ContentComponent<
/** Access to autocomplete */
autocomplete: TimekeepAutocomplete;
/** Content container element */
#contentEl: HTMLElement | undefined;
/** Name input for starting entries */
#nameInput: TimesheetNameInput | undefined;
@ -54,16 +47,10 @@ export class TimesheetStart extends ContentComponent<
onload(): void {
super.onload();
const wrapperEl = this.containerEl.createDiv();
this.wrapperEl = wrapperEl;
const contentEl = wrapperEl.createDiv();
this.#contentEl = contentEl;
this.setCurrentView();
const formEl = wrapperEl.createEl("form", { cls: "timekeep-start-area" });
const formEl = this.containerEl.createEl("form", { cls: "timekeep-start-area" });
formEl.setAttribute("data-area", "start");
this.wrapperEl = formEl;
this.registerDomEvent(formEl, "submit", this.onStart.bind(this));
const nameWrapperEl = formEl.createDiv({ cls: "timekeep-name-wrapper" });
@ -92,85 +79,23 @@ export class TimesheetStart extends ContentComponent<
this.#startButtonEl = startButton;
const onUpdate = this.onUpdate.bind(this);
const unsubscribeTimekeep = this.timekeep.subscribe(onUpdate);
this.register(this.timekeep.subscribe(onUpdate));
onUpdate();
this.register(unsubscribeTimekeep);
}
onUpdate() {
this.updateRunning();
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (this.getContent() instanceof TimesheetStartEditing) {
this.setEditingView();
return;
}
this.setCurrentView();
}
updateRunning() {
const blockPauseWarningEl = this.#blockPauseWarningEl;
const startButtonEl = this.#startButtonEl;
assert(blockPauseWarningEl && startButtonEl, "Elements should be defined");
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
const isTimekeepRunning = currentEntry !== null;
blockPauseWarningEl.hidden = currentEntry === null || currentEntry.startTime === null;
startButtonEl.title = isTimekeepRunning ? "Stop and start" : "Start";
}
/**
* Switch to the editing view
*/
setEditingView() {
const contentEl = this.#contentEl;
assert(contentEl, "Content element should be defined");
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
return this.setContent(undefined);
}
this.setContent(
new TimesheetStartEditing(
contentEl,
this.timekeep,
this.settings,
currentEntry.name,
this.setCurrentView.bind(this)
)
);
}
/**
* Switch to the default creation view
*/
setCurrentView() {
const contentEl = this.#contentEl;
assert(contentEl, "Content element should be defined");
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (currentEntry === null || currentEntry.startTime === null) {
return this.setContent(undefined);
}
this.setContent(
new TimesheetStartRunning(
contentEl,
this.timekeep,
this.settings,
currentEntry,
this.setEditingView.bind(this)
)
);
}
onStart(event: Event) {
// Prevent form submission from reloading Obsidian
event.preventDefault();

View file

@ -2,7 +2,7 @@
import moment from "moment";
import { v4 } from "uuid";
import { describe, it, vi, beforeEach } from "vitest";
import { describe, it, vi, beforeEach, afterEach, expect } from "vitest";
import type { TimeEntry, Timekeep } from "@/timekeep/schema";
@ -17,6 +17,7 @@ describe("TimesheetStartRunning", () => {
let timekeep: Store<Timekeep>;
let settings: Store<TimekeepSettings>;
let onStartEditing: VoidFunction;
let component: TimesheetStartRunning;
const start = moment();
const entry: TimeEntry = {
@ -42,8 +43,12 @@ describe("TimesheetStartRunning", () => {
onStartEditing = vi.fn();
});
afterEach(() => {
if (component) component.unload();
});
it("should load without error", () => {
const component = new TimesheetStartRunning(
component = new TimesheetStartRunning(
containerEl,
timekeep,
settings,
@ -52,4 +57,107 @@ describe("TimesheetStartRunning", () => {
);
component.load();
});
it("should be able to stop the entry", () => {
const start = moment();
const end = moment().add(1, "hour");
vi.setSystemTime(end.toDate());
const id = v4();
timekeep.setState({
entries: [
{
id,
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
},
],
});
component = new TimesheetStartRunning(
containerEl,
timekeep,
settings,
entry,
onStartEditing
);
const onStop = vi.spyOn(component, "onStop");
component.load();
const formEl = component.wrapperEl;
expect(formEl).not.toBeNull();
(formEl as HTMLFormElement).dispatchEvent(
new SubmitEvent("submit", { bubbles: true, cancelable: true })
);
expect(onStop).toHaveBeenCalled();
expect(timekeep.getState()).toEqual({
entries: [
{
id,
name: "Test",
startTime: moment(start),
endTime: moment(end),
subEntries: null,
},
],
});
});
it("nested running entry should have a path", () => {
const start = moment();
const end = moment().add(1, "hour");
vi.setSystemTime(end.toDate());
const entry = {
id: v4(),
name: "Test",
startTime: moment(start),
endTime: null,
subEntries: null,
};
timekeep.setState({
entries: [
{
id: v4(),
name: "Outer",
startTime: null,
endTime: null,
subEntries: [
{
id: v4(),
name: "Inner",
startTime: null,
endTime: null,
subEntries: [entry],
},
],
},
],
});
component = new TimesheetStartRunning(
containerEl,
timekeep,
settings,
entry,
onStartEditing
);
component.load();
const pathEls = component.wrapperEl!.querySelectorAll(".timekeep-path-to-entry__segment");
expect(pathEls.length).toBe(3);
expect(pathEls.item(0).textContent).toBe("Outer >");
expect(pathEls.item(1).textContent).toBe("Inner >");
expect(pathEls.item(2).textContent).toBe("Test");
});
});

View file

@ -131,12 +131,12 @@ export class TimesheetStartRunning extends DomComponent {
pathEl.empty();
const pathToEntry = getPathToEntry(timekeep.entries, currentEntry);
if (pathToEntry && pathToEntry.length > 0) {
for (let i = 0; i < pathToEntry.length; i++) {
const path = pathToEntry[i];
const text = `${path.name} ${i < pathToEntry.length - 1 ? " >" : ""}`;
pathEl.createSpan({ cls: "timekeep-path-to-entry__segment", text });
}
assert(pathToEntry, "Entry path should exist");
for (let i = 0; i < pathToEntry.length; i++) {
const path = pathToEntry[i];
const text = `${path.name}${i < pathToEntry.length - 1 ? " >" : ""}`;
pathEl.createSpan({ cls: "timekeep-path-to-entry__segment", text });
}
}