tests: happy-dom dom testing, mocks setup helepr, obsidian stub,

timesheetTimer tests
This commit is contained in:
Jacobtread 2026-04-03 12:31:33 +13:00
parent 499ba00868
commit 7cd6e7e7d0
12 changed files with 334 additions and 85 deletions

81
package-lock.json generated
View file

@ -20,6 +20,7 @@
"@types/node": "^20.19.37",
"@types/pdfmake": "^0.3.2",
"@vitest/coverage-v8": "^4.1.1",
"happy-dom": "^20.8.9",
"obsidian": "latest",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
@ -1320,6 +1321,23 @@
"@types/estree": "*"
}
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz",
@ -1582,6 +1600,19 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@ -1674,6 +1705,24 @@
"node": ">=10"
}
},
"node_modules/happy-dom": {
"version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2733,6 +2782,16 @@
"license": "MIT",
"peer": true
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -2750,6 +2809,28 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmldoc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz",

View file

@ -29,6 +29,7 @@
"@types/node": "^20.19.37",
"@types/pdfmake": "^0.3.2",
"@vitest/coverage-v8": "^4.1.1",
"happy-dom": "^20.8.9",
"obsidian": "latest",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",

View file

@ -298,3 +298,122 @@ export const MockNotice = vi.fn().mockImplementation(function (
) {
return {};
});
/**
* Helper to create the {@link Node.createEl} function added by obsidian
*
* @param el Container to create elements within
* @returns The createDiv function for that container
*/
function createMockCreateEl(el: Node) {
return <K extends keyof HTMLElementTagNameMap>(
tag: K,
o?: DomElementInfo | string,
callback?: (el: HTMLElementTagNameMap[K]) => void
): HTMLElementTagNameMap[K] => {
const child = document.createElement(tag);
// Apply options
if (o) {
if (typeof o === "string") {
child.textContent = o;
} else {
applyDomElementInfo(child, o);
}
}
// Setup helpers
setObsidianMockElementHelpers(child);
// Add to the parent
el.appendChild(child);
// Callback from creation
if (callback) callback(child);
return child;
};
}
/**
* Sets the createEl/createDiv/createSpan helper functions
* on the provided node
*
* @param node The node to add the helper functions to
*/
export function setObsidianMockElementHelpers(node: Node) {
node.createEl = createMockCreateEl(node);
node.createDiv = (o?: DomElementInfo | string, callback?: (el: HTMLDivElement) => void) =>
node.createEl("div", o, callback);
node.createSpan = (o?: DomElementInfo | string, callback?: (el: HTMLDivElement) => void) =>
node.createEl("span", o, callback);
}
/**
* Applies the attributes from the DomElementInfo onto
* the provided HTML element
*
* @param el The element to apply to
* @param info The dom element info to apply
*/
function applyDomElementInfo(el: HTMLElement, info: DomElementInfo) {
if (info.cls) {
if (Array.isArray(info.cls)) {
el.classList.add(...info.cls);
} else {
el.classList.add(info.cls);
}
}
if (info.text) {
if (info.text instanceof DocumentFragment) {
el.appendChild(info.text);
} else {
el.textContent = info.text;
}
}
if (info.attr) {
for (const [key, value] of Object.entries(info.attr)) {
if (value === null || value === false) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
}
if (info.title) {
el.title = info.title;
}
if (info.parent) {
if (info.prepend && info.parent.firstChild) {
info.parent.insertBefore(el, info.parent.firstChild);
} else {
info.parent.appendChild(el);
}
}
if (el instanceof HTMLInputElement) {
if (info.value !== undefined) {
el.value = info.value;
}
if (info.placeholder !== undefined) {
el.placeholder = info.placeholder;
}
}
if (info.href && el instanceof HTMLAnchorElement) {
el.href = info.href;
}
}
/**
* Helper for creating a mock container element extended with
*/
export function createMockContainer() {
const container = document.createElement("div");
setObsidianMockElementHelpers(container);
return container;
}

View file

@ -0,0 +1,7 @@
/**
* Stub file for testing module resolution, this file doesn't contain anything just
* vitest needs a module that it can resolve for browser testing mode otherwise
* it will error from being unable to resolve the "obsidian" module
*/
export {};

View file

@ -0,0 +1,21 @@
import { vi } from "vitest";
import {
MockComponent,
MockNotice,
MockTAbstractFile,
MockTFile,
MockTFolder,
MockVault,
} from "@/__mocks__/obsidian";
vi.mock("obsidian", () => {
return {
TFile: MockTFile,
TFolder: MockTFolder,
TAbstractFile: MockTAbstractFile,
Vault: MockVault,
Component: MockComponent,
Notice: MockNotice,
};
});

View file

@ -1,30 +1,12 @@
import type { App } from "obsidian";
import { describe, vi, it, expect } from "vitest";
import { describe, it, expect } from "vitest";
import {
MockComponent,
MockNotice,
MockTAbstractFile,
MockTFile,
MockTFolder,
MockVault,
} from "@/__mocks__/obsidian";
import { MockNotice, MockVault } from "@/__mocks__/obsidian";
import { createCodeBlock } from "@/utils/codeblock";
import stopAllTimekeeps from "./stopAllTimekeeps";
vi.mock("obsidian", () => {
return {
TFile: MockTFile,
TFolder: MockTFolder,
TAbstractFile: MockTAbstractFile,
Vault: MockVault,
Component: MockComponent,
Notice: MockNotice,
};
});
describe("stopFileTimekeeps", () => {
it("when nothing is stopped should get a notice", async () => {
const vault = new MockVault();

View file

@ -2,29 +2,11 @@ import type { App } from "obsidian";
import { describe, vi, it, expect } from "vitest";
import {
MockComponent,
MockNotice,
MockTAbstractFile,
MockTFile,
MockTFolder,
MockVault,
} from "@/__mocks__/obsidian";
import { MockNotice, MockVault } from "@/__mocks__/obsidian";
import { createCodeBlock } from "@/utils/codeblock";
import stopFileTimekeeps from "./stopFileTimekeeps";
vi.mock("obsidian", () => {
return {
TFile: MockTFile,
TFolder: MockTFolder,
TAbstractFile: MockTAbstractFile,
Vault: MockVault,
Component: MockComponent,
Notice: MockNotice,
};
});
describe("stopFileTimekeeps", () => {
it("when no active file is detected should get a notice", async () => {
const app = {

View file

@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { describe, it, beforeEach, expect } from "vitest";
import { createMockContainer } from "@/__mocks__/obsidian";
import { TimesheetTimer } from "./timesheetTimer";
describe("TimesheetTimer", () => {
let container: ReturnType<typeof createMockContainer>;
let timer: TimesheetTimer;
beforeEach(() => {
container = createMockContainer();
timer = new TimesheetTimer(container, "Test Label");
});
it("should create timer elements on load", () => {
timer.onload();
const timerEl = container.children[0] as HTMLElement;
expect(timerEl).toBeDefined();
expect(timerEl.className).toBe("timekeep-timer");
const primary = timerEl.children[0] as HTMLElement;
const secondary = timerEl.children[1] as HTMLElement;
const label = timerEl.children[2] as HTMLElement;
expect(primary.className).toBe("timekeep-timer-value");
expect(secondary.className).toBe("timekeep-timer-value-small");
expect(label.textContent).toBe("Test Label");
expect(primary.textContent).toBe("");
expect(secondary.textContent).toBe(" ");
expect(secondary.hidden).toBe(false);
});
it("should remove timer element on unload", () => {
timer.onload();
const timerEl = container.children[0] as HTMLElement;
expect(timerEl).toBeDefined();
timer.onunload();
expect(timerEl.parentElement).toBeNull();
});
it("should hide or show the timer element", () => {
timer.onload();
const timerEl = container.children[0] as HTMLElement;
timer.setHidden(true);
expect(timerEl.hidden).toBe(true);
timer.setHidden(false);
expect(timerEl.hidden).toBe(false);
});
it("should set primary and secondary values correctly", () => {
timer.onload();
const timerEl = container.children[0] as HTMLElement;
const primary = timerEl.children[0] as HTMLDivElement;
const secondary = timerEl.children[1] as HTMLDivElement;
timer.setValues("1h 55m 30s", "1.9h");
expect(primary.textContent).toBe("1h 55m 30s");
expect(secondary.textContent).toBe("1.9h");
expect(secondary.hidden).toBe(false);
timer.setValues("2h 0m 5s", "");
expect(primary.textContent).toBe("2h 0m 5s");
expect(secondary.textContent).toBe("");
expect(secondary.hidden).toBe(true);
});
it("secondary value should be hidden if it is empty", () => {
timer.onload();
const timerEl = container.children[0] as HTMLElement;
const secondary = timerEl.children[1] as HTMLDivElement;
timer.setValues("1h 55m 30s", "1.9h");
expect(secondary.hidden).toBe(false);
timer.setValues("2h 0m 5s", "");
expect(secondary.hidden).toBe(true);
});
it("should not throw if setValues called before onload", () => {
expect(() => timer.setValues("1", "2")).not.toThrow();
});
it("should not throw if setHidden called before onload", () => {
expect(() => timer.setHidden(true)).not.toThrow();
});
});

View file

@ -28,20 +28,11 @@ export class TimesheetTimer extends Component {
onload(): void {
super.onload();
const timerEl = this.#containerEl.createDiv({
cls: "timekeep-timer",
});
const timerEl = this.#containerEl.createDiv({ cls: "timekeep-timer" });
this.#timerEl = timerEl;
const primaryValueEl = timerEl.createDiv({
cls: "timekeep-timer-value",
});
const secondaryValueEl = timerEl.createDiv({
cls: "timekeep-timer-value-small",
});
const primaryValueEl = timerEl.createDiv({ cls: "timekeep-timer-value" });
const secondaryValueEl = timerEl.createDiv({ cls: "timekeep-timer-value-small" });
timerEl.createSpan({ text: this.#label });
this.#primaryValueEl = primaryValueEl;

View file

@ -2,13 +2,7 @@ import moment from "moment";
import { v4 } from "uuid";
import { describe, expect, it, vi } from "vitest";
import {
MockComponent,
MockTAbstractFile,
MockTFile,
MockTFolder,
MockVault,
} from "@/__mocks__/obsidian";
import { MockVault } from "@/__mocks__/obsidian";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { Timekeep } from "@/timekeep/schema";
@ -17,16 +11,6 @@ import { createCodeBlock } from "@/utils/codeblock";
import { TimekeepAutocomplete } from "./autocomplete";
import { TimekeepRegistry } from "./registry";
vi.mock("obsidian", () => {
return {
TFile: MockTFile,
TFolder: MockTFolder,
TAbstractFile: MockTAbstractFile,
Vault: MockVault,
Component: MockComponent,
};
});
describe("TimekeepAutocomplete", () => {
describe("autocomplete enabled", () => {
it("empty vault should have no names", async () => {

View file

@ -2,13 +2,7 @@ import moment from "moment";
import { v4 } from "uuid";
import { describe, vi, it, expect } from "vitest";
import {
MockComponent,
MockTAbstractFile,
MockTFile,
MockTFolder,
MockVault,
} from "@/__mocks__/obsidian";
import { MockVault } from "@/__mocks__/obsidian";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
@ -16,16 +10,6 @@ import { createCodeBlock } from "@/utils/codeblock";
import { TimekeepEntryItemType, TimekeepRegistry, TimekeepRegistryEntryMarkdown } from "./registry";
vi.mock("obsidian", () => {
return {
TFile: MockTFile,
TFolder: MockTFolder,
TAbstractFile: MockTAbstractFile,
Vault: MockVault,
Component: MockComponent,
};
});
describe("TimekeepRegistry", () => {
describe("getFileRegistryEntry", () => {
it("returns null for markdown without timekeeps", async () => {

View file

@ -51,10 +51,12 @@ export default defineConfig((env) => ({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
obsidian: path.resolve(__dirname, "src", "__mocks__", "obsidianStub"),
},
},
test: {
setupFiles: path.resolve(__dirname, "src", "__mocks__", "setupObsidianMocks.ts"),
coverage: {
// Exclude mocks and fixtures from coverage
exclude: ["**/__mocks__/**", "**/__fixtures__/**"],