Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/__tests__/note-editor-notify.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Part of React Advanced Odontogram - https://github.com/ZoliQua/React-Advanced-Odontogram
// Created by Zoltan Dul (https://github.com/ZoliQua) 2025-2026

// The per-tooth note is part of the export payload, so saving or deleting one
// from the note-editor popover must fire notifyStateChange() like any other
// tooth edit — otherwise the localStorage autosave and hosts persisting the
// chart through onStateChange miss it until the next unrelated edit.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { render, cleanup, fireEvent, waitFor } from "@testing-library/react";
import App from "../App";
import {
__resetChartStateForTest,
getStatusChart,
onStateChange,
rebuildGrid,
setNotesEnabled,
setNumberingSystem,
} from "../odontogram";

vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 });

// Same jsdom stubs as tier2-rewire.test.tsx: a real initOdontogram() needs them.
function installDomStubs() {
window.matchMedia = ((q: string) => ({
matches: false, media: q, onchange: null,
addEventListener() {}, removeEventListener() {},
addListener() {}, removeListener() {}, dispatchEvent() { return false; },
})) as unknown as typeof window.matchMedia;
if (typeof (globalThis as { ResizeObserver?: unknown }).ResizeObserver === "undefined") {
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
observe() {} unobserve() {} disconnect() {}
};
}
}

beforeEach(() => {
installDomStubs();
cleanup();
document.body.innerHTML = "";
__resetChartStateForTest();
setNumberingSystem("FDI");
});

afterEach(() => {
setNotesEnabled(false);
cleanup();
});

async function waitForGrid() {
await waitFor(() => {
const grid = document.getElementById("toothGrid");
expect(grid && grid.childElementCount).toBeGreaterThan(0);
});
}

function openNoteEditor(toothNo: number): { textarea: HTMLTextAreaElement; save: HTMLButtonElement; del: HTMLButtonElement } {
const tile = document.querySelector(`.tooth-tile.side-view[data-tooth="${toothNo}"]`) as HTMLElement;
fireEvent.dblClick(tile);
const popover = document.querySelector(".odon-note-popover") as HTMLElement;
expect(popover).toBeTruthy();
const [save, del] = Array.from(popover.querySelectorAll<HTMLButtonElement>(".odon-note-actions button"));
return { textarea: popover.querySelector("textarea") as HTMLTextAreaElement, save: save!, del: del! };
}

function notifiesFor(action: () => void): number {
const spy = vi.fn();
const unsub = onStateChange(spy);
action();
unsub();
return spy.mock.calls.length;
}

describe("note editor: save/delete notify onStateChange", () => {
it("saving a new note notifies once and lands in the payload", async () => {
render(createElement(App, { language: "en", enableNotes: true }));
await waitForGrid();

const { textarea, save } = openNoteEditor(11);
textarea.value = " sensitive to cold ";
expect(notifiesFor(() => fireEvent.click(save))).toBe(1);
expect(getStatusChart().teeth[11].note).toBe("sensitive to cold");
expect(document.querySelector(".odon-note-popover")).toBeNull();
});

it("re-saving the same note does not notify", async () => {
render(createElement(App, { language: "en", enableNotes: true }));
await waitForGrid();

const first = openNoteEditor(11);
first.textarea.value = "same";
fireEvent.click(first.save);

const second = openNoteEditor(11);
expect(second.textarea.value).toBe("same");
expect(notifiesFor(() => fireEvent.click(second.save))).toBe(0);
});

it("deleting a note notifies once and removes it from the payload", async () => {
render(createElement(App, { language: "en", enableNotes: true }));
await waitForGrid();

const first = openNoteEditor(11);
first.textarea.value = "to be removed";
fireEvent.click(first.save);

const second = openNoteEditor(11);
expect(notifiesFor(() => fireEvent.click(second.del))).toBe(1);
expect(getStatusChart().teeth[11].note).toBeUndefined();

// Deleting an already-empty note is a no-op.
const third = openNoteEditor(11);
expect(notifiesFor(() => fireEvent.click(third.del))).toBe(0);
});

it("the label badge survives a grid rebuild", async () => {
render(createElement(App, { language: "en", enableNotes: true }));
await waitForGrid();

const { textarea, save } = openNoteEditor(11);
textarea.value = "keep me";
fireEvent.click(save);
const badge = () => document.querySelectorAll(".tooth-label-cell .tooth-note-icon").length;
expect(badge()).toBe(1);

await rebuildGrid();
await waitForGrid();
expect(badge()).toBe(1);
});
});
114 changes: 114 additions & 0 deletions src/__tests__/session-settings-setters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Part of React Advanced Odontogram - https://github.com/ZoliQua/React-Advanced-Odontogram
// Created by Zoltan Dul (https://github.com/ZoliQua) 2025-2026

// The remaining session-setting setters must follow the same convention as
// setPulpDetailLevel/setSurfaceNotation and the fillings setters
// (filling-settings-setters.test.ts): idempotent (early-return when the value
// is unchanged) AND call notifyStateChange(), so a host that persists the
// doctor's preferences through onStateChange sees a toggle made in the
// settings modal even when no tooth is edited afterwards.
import { describe, it, expect, afterEach } from "vitest";
import {
setNotesEnabled,
getNotesEnabled,
setIcdasEnabled,
getIcdasEnabled,
setCariesDepthEnabled,
getCariesDepthEnabled,
setSecondaryCariesMode,
getSecondaryCariesMode,
setRootCariesMode,
getRootCariesMode,
setRadiographicDepthMode,
getRadiographicDepthMode,
setWearDetailLevel,
getWearDetailLevel,
setDiscolorationDetailLevel,
getDiscolorationDetailLevel,
setNumberingSystem,
onStateChange,
} from "../odontogram";
import { numberingSystem } from "../state/numbering";

const resetDefaults = () => {
setNotesEnabled(false);
setIcdasEnabled(false);
setCariesDepthEnabled(true);
setSecondaryCariesMode("standard");
setRootCariesMode("simple");
setRadiographicDepthMode("off");
setWearDetailLevel("complex");
setDiscolorationDetailLevel("complex");
setNumberingSystem("FDI");
};

/** Runs `fn` with a subscriber attached and reports whether it fired. */
function fires(fn: () => void): boolean {
let fired = false;
const unsub = onStateChange(() => { fired = true; });
try { fn(); } finally { unsub(); }
return fired;
}

describe("session-setting setters: idempotent + notifyStateChange", () => {
afterEach(resetDefaults);

it("setNotesEnabled", () => {
expect(fires(() => setNotesEnabled(true))).toBe(true);
expect(getNotesEnabled()).toBe(true);
expect(fires(() => setNotesEnabled(true))).toBe(false);
// Truthy coercion: a truthy non-boolean is sanitized to true.
expect(fires(() => setNotesEnabled(1 as unknown as boolean))).toBe(false);
});

it("setIcdasEnabled", () => {
expect(fires(() => setIcdasEnabled(true))).toBe(true);
expect(getIcdasEnabled()).toBe(true);
expect(fires(() => setIcdasEnabled(true))).toBe(false);
});

it("setCariesDepthEnabled", () => {
expect(fires(() => setCariesDepthEnabled(false))).toBe(true);
expect(getCariesDepthEnabled()).toBe(false);
expect(fires(() => setCariesDepthEnabled(false))).toBe(false);
});

it("setSecondaryCariesMode", () => {
expect(fires(() => setSecondaryCariesMode("full"))).toBe(true);
expect(getSecondaryCariesMode()).toBe("full");
expect(fires(() => setSecondaryCariesMode("full"))).toBe(false);
// An invalid value sanitizes to "standard" — a real change from "full".
expect(fires(() => setSecondaryCariesMode("bogus" as "simple"))).toBe(true);
expect(getSecondaryCariesMode()).toBe("standard");
});

it("setRootCariesMode", () => {
expect(fires(() => setRootCariesMode("severity"))).toBe(true);
expect(getRootCariesMode()).toBe("severity");
expect(fires(() => setRootCariesMode("severity"))).toBe(false);
});

it("setRadiographicDepthMode", () => {
expect(fires(() => setRadiographicDepthMode("detailed"))).toBe(true);
expect(getRadiographicDepthMode()).toBe("detailed");
expect(fires(() => setRadiographicDepthMode("detailed"))).toBe(false);
});

it("setWearDetailLevel", () => {
expect(fires(() => setWearDetailLevel("simple"))).toBe(true);
expect(getWearDetailLevel()).toBe("simple");
expect(fires(() => setWearDetailLevel("simple"))).toBe(false);
});

it("setDiscolorationDetailLevel", () => {
expect(fires(() => setDiscolorationDetailLevel("simple"))).toBe(true);
expect(getDiscolorationDetailLevel()).toBe("simple");
expect(fires(() => setDiscolorationDetailLevel("simple"))).toBe(false);
});

it("setNumberingSystem", () => {
expect(fires(() => setNumberingSystem("UNIVERSAL"))).toBe(true);
expect(numberingSystem).toBe("UNIVERSAL");
expect(fires(() => setNumberingSystem("UNIVERSAL"))).toBe(false);
});
});
14 changes: 7 additions & 7 deletions src/__tests__/tooth-details-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// predicates, that `#extractionPlanRow` reparents to the correct container per
// state, and that the title `#btnResetTooth` resets the active tooth.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { createElement, type ComponentProps } from "react";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { OdontogramProvider, ToothControlsSurface } from "../App";
import {
Expand All @@ -37,8 +37,8 @@ vi.mock("../odontogram", async (importOriginal) => {
};
});

function renderControls() {
return render(createElement(OdontogramProvider, { language: "en" }, createElement(ToothControlsSurface)));
function renderControls(props: Partial<ComponentProps<typeof OdontogramProvider>> = {}) {
return render(createElement(OdontogramProvider, { language: "en", ...props }, createElement(ToothControlsSurface)));
}

beforeEach(() => {
Expand Down Expand Up @@ -116,13 +116,13 @@ describe("PR 3f: <ToothDetailsCard/> renders declaratively", () => {
expect(document.getElementById("discolorationToggleLabel")?.classList.contains("hidden")).toBe(true);

// Simple: the toggles are shown, the selects hidden (level flips them, never
// mutating the stored value).
// mutating the stored value). The level goes in as a provider prop: the
// provider's mount effect pushes its props into the module, and since the
// setters now notify, that push re-renders the card.
cleanup();
setWearDetailLevel("simple");
setDiscolorationDetailLevel("simple");
__setToothStateForTest(11, { toothSelection: "tooth-base" });
__setSelectionForTest([11]);
renderControls();
renderControls({ wearDetailLevel: "simple", discolorationDetailLevel: "simple" });
expect(document.getElementById("wearEdgeSelectLabel")?.classList.contains("hidden")).toBe(true);
expect(document.getElementById("wearEdgeToggleLabel")?.classList.contains("hidden")).toBe(false);
expect(document.getElementById("discolorationSelectLabel")?.classList.contains("hidden")).toBe(true);
Expand Down
Loading
Loading