generated from nhcarrigan/template
b3d79a82ef
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
|
|
|
|
// Mock HTMLAudioElement for soundPlayer tests
|
|
class MockAudioElement {
|
|
src: string = "";
|
|
preload: string = "";
|
|
volume: number = 1;
|
|
|
|
constructor(src?: string) {
|
|
if (src) this.src = src;
|
|
}
|
|
|
|
cloneNode(): MockAudioElement {
|
|
const clone = new MockAudioElement(this.src);
|
|
clone.volume = this.volume;
|
|
return clone;
|
|
}
|
|
|
|
async play(): Promise<void> {
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
// Store original Audio before mocking
|
|
const OriginalAudio = globalThis.Audio;
|
|
|
|
describe("notifications", () => {
|
|
describe("NotificationType enum", () => {
|
|
it("has SUCCESS type", () => {
|
|
expect(NotificationType.SUCCESS).toBe("success");
|
|
});
|
|
|
|
it("has ERROR type", () => {
|
|
expect(NotificationType.ERROR).toBe("error");
|
|
});
|
|
|
|
it("has PERMISSION type", () => {
|
|
expect(NotificationType.PERMISSION).toBe("permission");
|
|
});
|
|
|
|
it("has CONNECTION type", () => {
|
|
expect(NotificationType.CONNECTION).toBe("connection");
|
|
});
|
|
|
|
it("has TASK_START type", () => {
|
|
expect(NotificationType.TASK_START).toBe("task_start");
|
|
});
|
|
|
|
it("has ACHIEVEMENT type", () => {
|
|
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
|
});
|
|
|
|
it("has exactly 6 notification types", () => {
|
|
const types = Object.values(NotificationType);
|
|
expect(types.length).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("NOTIFICATION_SOUNDS constant", () => {
|
|
it("has sounds for all notification types", () => {
|
|
Object.values(NotificationType).forEach((type) => {
|
|
expect(NOTIFICATION_SOUNDS[type]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("each sound has required properties", () => {
|
|
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
|
expect(sound.type).toBeDefined();
|
|
expect(sound.filename).toBeDefined();
|
|
expect(sound.phrase).toBeDefined();
|
|
expect(typeof sound.filename).toBe("string");
|
|
expect(typeof sound.phrase).toBe("string");
|
|
expect(sound.filename.endsWith(".mp3")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("SUCCESS sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.SUCCESS];
|
|
expect(sound.type).toBe(NotificationType.SUCCESS);
|
|
expect(sound.filename).toBe("im-done.mp3");
|
|
expect(sound.phrase).toBe("I'm done!");
|
|
expect(sound.volume).toBe(0.7);
|
|
});
|
|
|
|
it("ERROR sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.ERROR];
|
|
expect(sound.type).toBe(NotificationType.ERROR);
|
|
expect(sound.filename).toBe("oh-no.mp3");
|
|
expect(sound.phrase).toBe("Oh no...");
|
|
expect(sound.volume).toBe(0.8);
|
|
});
|
|
|
|
it("PERMISSION sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.PERMISSION];
|
|
expect(sound.type).toBe(NotificationType.PERMISSION);
|
|
expect(sound.filename).toBe("access-please.mp3");
|
|
expect(sound.phrase).toBe("Access please!");
|
|
expect(sound.volume).toBe(0.9);
|
|
});
|
|
|
|
it("CONNECTION sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.CONNECTION];
|
|
expect(sound.type).toBe(NotificationType.CONNECTION);
|
|
expect(sound.filename).toBe("connected.mp3");
|
|
expect(sound.phrase).toBe("Connected!");
|
|
expect(sound.volume).toBe(0.7);
|
|
});
|
|
|
|
it("TASK_START sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.TASK_START];
|
|
expect(sound.type).toBe(NotificationType.TASK_START);
|
|
expect(sound.filename).toBe("working-on-it.mp3");
|
|
expect(sound.phrase).toBe("Working on it!");
|
|
expect(sound.volume).toBe(0.6);
|
|
});
|
|
|
|
it("ACHIEVEMENT sound has correct properties", () => {
|
|
const sound = NOTIFICATION_SOUNDS[NotificationType.ACHIEVEMENT];
|
|
expect(sound.type).toBe(NotificationType.ACHIEVEMENT);
|
|
expect(sound.filename).toBe("achievement.mp3");
|
|
expect(sound.phrase).toBe("Achievement Get~!");
|
|
expect(sound.volume).toBe(0.8);
|
|
});
|
|
|
|
it("all volumes are within valid range (0-1)", () => {
|
|
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
|
if (sound.volume !== undefined) {
|
|
expect(sound.volume).toBeGreaterThanOrEqual(0);
|
|
expect(sound.volume).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("NotificationSound interface", () => {
|
|
it("can create a valid notification sound object", () => {
|
|
const sound: NotificationSound = {
|
|
type: NotificationType.SUCCESS,
|
|
filename: "test-sound.mp3",
|
|
phrase: "Test phrase",
|
|
volume: 0.5,
|
|
};
|
|
|
|
expect(sound.type).toBe(NotificationType.SUCCESS);
|
|
expect(sound.filename).toBe("test-sound.mp3");
|
|
expect(sound.phrase).toBe("Test phrase");
|
|
expect(sound.volume).toBe(0.5);
|
|
});
|
|
|
|
it("volume is optional", () => {
|
|
const sound: NotificationSound = {
|
|
type: NotificationType.ERROR,
|
|
filename: "error.mp3",
|
|
phrase: "Error occurred",
|
|
};
|
|
|
|
expect(sound.volume).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("SoundPlayer class", () => {
|
|
beforeEach(() => {
|
|
// Mock Audio constructor
|
|
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original Audio
|
|
globalThis.Audio = OriginalAudio;
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("can import soundPlayer singleton", async () => {
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
expect(soundPlayer).toBeDefined();
|
|
});
|
|
|
|
it("setEnabled changes enabled state", async () => {
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
|
|
soundPlayer.setEnabled(true);
|
|
expect(soundPlayer.isEnabled()).toBe(true);
|
|
|
|
soundPlayer.setEnabled(false);
|
|
expect(soundPlayer.isEnabled()).toBe(false);
|
|
});
|
|
|
|
it("starts disabled by default", async () => {
|
|
// Need to reimport to get fresh instance behavior
|
|
// But since it's a singleton, we just test the method
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
|
|
// Reset to default state
|
|
soundPlayer.setEnabled(false);
|
|
expect(soundPlayer.isEnabled()).toBe(false);
|
|
});
|
|
|
|
it("setGlobalVolume clamps values to 0-1 range", async () => {
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
|
|
// Test that it doesn't throw on edge cases
|
|
soundPlayer.setGlobalVolume(0);
|
|
soundPlayer.setGlobalVolume(1);
|
|
soundPlayer.setGlobalVolume(0.5);
|
|
|
|
// Test clamping below 0
|
|
soundPlayer.setGlobalVolume(-0.5);
|
|
|
|
// Test clamping above 1
|
|
soundPlayer.setGlobalVolume(1.5);
|
|
});
|
|
|
|
it("play returns early when disabled", async () => {
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
|
|
soundPlayer.setEnabled(false);
|
|
|
|
// Should not throw when disabled
|
|
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("play attempts to play when enabled", async () => {
|
|
const { soundPlayer } = await import("./soundPlayer");
|
|
|
|
soundPlayer.setEnabled(true);
|
|
|
|
// Should not throw
|
|
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("NotificationManager class", () => {
|
|
beforeEach(() => {
|
|
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.Audio = OriginalAudio;
|
|
});
|
|
|
|
it("can import notificationManager singleton", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(notificationManager).toBeDefined();
|
|
});
|
|
|
|
it("has notifySuccess method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notifySuccess).toBe("function");
|
|
});
|
|
|
|
it("has notifyError method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notifyError).toBe("function");
|
|
});
|
|
|
|
it("has notifyPermission method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notifyPermission).toBe("function");
|
|
});
|
|
|
|
it("has notifyConnection method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notifyConnection).toBe("function");
|
|
});
|
|
|
|
it("has notifyTaskStart method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notifyTaskStart).toBe("function");
|
|
});
|
|
|
|
it("has notify method", async () => {
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
|
}));
|
|
|
|
const { notificationManager } = await import("./notificationManager");
|
|
expect(typeof notificationManager.notify).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("notification sounds file paths", () => {
|
|
it("all sound files have valid paths", () => {
|
|
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
|
// Check that filename doesn't contain path traversal
|
|
expect(sound.filename).not.toContain("..");
|
|
expect(sound.filename).not.toContain("/");
|
|
expect(sound.filename).not.toContain("\\");
|
|
});
|
|
});
|
|
|
|
it("sound filenames are unique", () => {
|
|
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
|
const uniqueFilenames = new Set(filenames);
|
|
expect(uniqueFilenames.size).toBe(filenames.length);
|
|
});
|
|
|
|
it("phrases are unique", () => {
|
|
const phrases = Object.values(NOTIFICATION_SOUNDS).map((s) => s.phrase);
|
|
const uniquePhrases = new Set(phrases);
|
|
expect(uniquePhrases.size).toBe(phrases.length);
|
|
});
|
|
});
|
|
});
|