Add new styles for home and landing pages
- Created home.css with comprehensive styles for the home page layout, including typography, buttons, and responsive design adjustments. - Created landing.css to style the landing page, focusing on typography, layout, and responsive behavior for various screen sizes.
This commit is contained in:
692
src/App.test.tsx
Normal file
692
src/App.test.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { fixture } from "./habits/test-fixture";
|
||||
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
|
||||
|
||||
// A simulated DOM keeps auth regression checks independent of Discord and the local database.
|
||||
const dom = new Window({ url: "http://localhost:3000/" });
|
||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
|
||||
const account = {
|
||||
id: "test-user",
|
||||
discordId: "123",
|
||||
username: "demo",
|
||||
displayName: "Demo User",
|
||||
avatarUrl: null,
|
||||
timezone: "UTC",
|
||||
};
|
||||
const emptyToday = {
|
||||
date: "2026-09-04",
|
||||
timezone: "UTC",
|
||||
habits: [],
|
||||
due: 0,
|
||||
completed: 0,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const key of [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"HTMLElement",
|
||||
"HTMLInputElement",
|
||||
"Element",
|
||||
"Node",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"IS_REACT_ACT_ENVIRONMENT",
|
||||
]) {
|
||||
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value:
|
||||
key === "window"
|
||||
? dom
|
||||
: key === "IS_REACT_ACT_ENVIRONMENT"
|
||||
? true
|
||||
: (dom as unknown as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
({ createRoot } = await import("react-dom/client"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 401 }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
dom.happyDOM.abort();
|
||||
for (const [key, descriptor] of originalGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
|
||||
async function render(path = "/") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function click(label: string) {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) =>
|
||||
element.textContent?.trim() === label ||
|
||||
element.getAttribute("aria-label") === label,
|
||||
);
|
||||
expect(button).toBeDefined();
|
||||
await act(async () => button!.click());
|
||||
}
|
||||
|
||||
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<CalendarHeatmap
|
||||
label="Reading history"
|
||||
unit="pages"
|
||||
compact
|
||||
emptyColor="#ebedf0"
|
||||
days={[
|
||||
{
|
||||
date: "2026-09-03",
|
||||
value: 0,
|
||||
target: 0,
|
||||
state: "not-due",
|
||||
color: "#f5f5f5",
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
value: 0,
|
||||
target: 20,
|
||||
state: "due",
|
||||
color: "#ebedf0",
|
||||
},
|
||||
{
|
||||
date: "2026-09-05",
|
||||
value: 0,
|
||||
target: 20,
|
||||
state: "future",
|
||||
color: "#dbeafe",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const restDay =
|
||||
container.querySelector<HTMLButtonElement>(".ds-day--not-due")!;
|
||||
expect(restDay.style.backgroundColor).toBe("transparent");
|
||||
expect(restDay.getAttribute("aria-label")).toContain(
|
||||
"Not included in the score",
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
|
||||
).toBe("#ebedf0");
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".ds-day--future")!.style
|
||||
.backgroundColor,
|
||||
).toBe("#ebedf0");
|
||||
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
|
||||
await act(async () => restDay.click());
|
||||
expect(restDay.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(restDay.tabIndex).toBe(0);
|
||||
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain(
|
||||
"Nothing scheduled",
|
||||
);
|
||||
await act(async () =>
|
||||
restDay.dispatchEvent(
|
||||
new dom.KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
bubbles: true,
|
||||
}) as unknown as KeyboardEvent,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".ds-day--due")?.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
describe("home route authentication", () => {
|
||||
test("signed-out visitors see the landing page with Discord and demo content", async () => {
|
||||
await render();
|
||||
expect(container.querySelector("h1")?.textContent).toBe(
|
||||
"A little today.A rhythm for life.",
|
||||
);
|
||||
expect(container.querySelector("#discord")?.textContent).toContain(
|
||||
"Discord integration currently covers sign-in",
|
||||
);
|
||||
expect(container.querySelector("#demo")).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("an unresolved account request never flashes the landing page", async () => {
|
||||
let resolve!: (response: Response) => void;
|
||||
fetchMock.mockResolvedValue(Response.json(emptyToday)).mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
||||
"Loading your account…",
|
||||
);
|
||||
expect(container.querySelector("#landing-title")).toBeNull();
|
||||
await act(async () => resolve(Response.json(account)));
|
||||
expect(container.querySelector("h1")?.textContent).toBe(
|
||||
"Welcome home,Demo User.",
|
||||
);
|
||||
expect(container.querySelector("#landing-title")).toBeNull();
|
||||
});
|
||||
|
||||
test("signed-in users keep Home and signing out reveals the landing page", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(Response.json(account))
|
||||
.mockResolvedValueOnce(Response.json(emptyToday))
|
||||
.mockResolvedValueOnce(Response.json({ ok: true }));
|
||||
await render();
|
||||
expect(container.querySelector("h1")?.textContent).toBe(
|
||||
"Welcome home,Demo User.",
|
||||
);
|
||||
expect(container.textContent).toContain("Demo User");
|
||||
await click("Sign out");
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(container.querySelector("#landing-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("logout failure keeps the signed-in view and shows an error", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(Response.json(account))
|
||||
.mockResolvedValueOnce(Response.json(emptyToday))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
await render();
|
||||
await click("Sign out");
|
||||
expect(container.querySelector("h1")?.textContent).toBe(
|
||||
"Welcome home,Demo User.",
|
||||
);
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Could not sign out",
|
||||
);
|
||||
});
|
||||
|
||||
test("an account error is not treated as signed out and can be retried", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
||||
await render();
|
||||
expect(container.querySelector("#landing-title")).toBeNull();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Could not load your account",
|
||||
);
|
||||
await click("Try again");
|
||||
expect(container.querySelector("#landing-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("OAuth cancellation remains visible on the landing page", async () => {
|
||||
await render("/?auth_error=denied");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Discord sign-in was cancelled",
|
||||
);
|
||||
});
|
||||
|
||||
test("all sign-in calls to action use Discord OAuth with the browser timezone", async () => {
|
||||
const assign = spyOn(dom.location, "assign").mockImplementation(() => {});
|
||||
try {
|
||||
await render();
|
||||
const buttons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.startsWith("Sign in"),
|
||||
);
|
||||
expect(buttons).toHaveLength(3);
|
||||
for (const button of buttons) await act(async () => button.click());
|
||||
expect(assign).toHaveBeenCalledTimes(3);
|
||||
for (const [destination] of assign.mock.calls) {
|
||||
const url = new URL(destination, "http://localhost:3000");
|
||||
expect(url.pathname).toBe("/api/auth/discord");
|
||||
expect(url.searchParams.get("timezone")).toBe(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
assign.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("other routes keep their shell and can navigate back to the public home", async () => {
|
||||
await render("/about");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("About");
|
||||
await click("Home");
|
||||
expect(container.querySelector("#landing-title")).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("the design-system route stays public and independent of account loading", async () => {
|
||||
await render("/design-system/");
|
||||
expect(container.querySelector("#ds-title")).not.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the demo updates completion, switches views, and resets without API writes", async () => {
|
||||
await render();
|
||||
await click("Increase glasses of water");
|
||||
expect(
|
||||
container.querySelector(".ds-preview-heading")?.textContent,
|
||||
).toContain("1 of 2 habits complete");
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Increase glasses of water"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
await click("Combined progress");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Combined habit calendar"]'),
|
||||
).not.toBeNull();
|
||||
const reset = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Reset demo"),
|
||||
)!;
|
||||
await act(async () => reset.click());
|
||||
expect(
|
||||
container.querySelector(".ds-preview-heading")?.textContent,
|
||||
).toContain("0 of 2 habits complete");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Increase glasses of water"]'),
|
||||
).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed-in dashboard with the real habit API", () => {
|
||||
let api: ReturnType<typeof fixture>;
|
||||
beforeEach(() => {
|
||||
api = fixture();
|
||||
fetchMock.mockImplementation((async (
|
||||
input: RequestInfo | URL,
|
||||
options?: RequestInit,
|
||||
) => {
|
||||
const path = String(input).replace(/^\/api/, "");
|
||||
if (path === "/me") return Response.json(account);
|
||||
return api.request(
|
||||
path,
|
||||
options?.method ?? "GET",
|
||||
options?.body ? JSON.parse(String(options.body)) : undefined,
|
||||
);
|
||||
}) as typeof fetch);
|
||||
});
|
||||
afterEach(() => api.close());
|
||||
|
||||
async function input(selector: string, value: string) {
|
||||
const element = container.querySelector<HTMLInputElement>(selector)!;
|
||||
expect(element).not.toBeNull();
|
||||
await act(async () => {
|
||||
const prototype =
|
||||
element.tagName === "SELECT"
|
||||
? dom.HTMLSelectElement.prototype
|
||||
: element.tagName === "TEXTAREA"
|
||||
? dom.HTMLTextAreaElement.prototype
|
||||
: dom.HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
|
||||
element,
|
||||
value,
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new Event(element.tagName === "SELECT" ? "change" : "input", {
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("empty accounts get useful starters without invented history", async () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain(
|
||||
"No habits yet. No catching up to do.",
|
||||
);
|
||||
expect(container.querySelectorAll(".home-starters > button")).toHaveLength(
|
||||
3,
|
||||
);
|
||||
expect(container.querySelector("#rhythm")).toBeNull();
|
||||
expect(container.textContent).not.toContain("Demo history");
|
||||
expect(container.textContent).toContain("Europe/Belgrade");
|
||||
});
|
||||
|
||||
test("uses the Discord avatar and falls back only when it fails", async () => {
|
||||
const avatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png";
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ ...account, avatarUrl }));
|
||||
await render();
|
||||
const avatar =
|
||||
container.querySelector<HTMLImageElement>("img.home-avatar")!;
|
||||
expect(avatar.src).toBe(avatarUrl);
|
||||
expect(avatar.alt).toBe("Demo User’s Discord avatar");
|
||||
await act(async () => avatar.dispatchEvent(new Event("error")));
|
||||
expect(container.querySelector("img.home-avatar")).toBeNull();
|
||||
expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
|
||||
});
|
||||
|
||||
test("a starter is editable and creates a persistent first habit", async () => {
|
||||
await render();
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(".home-starters > button")!
|
||||
.click(),
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#habit-name")!.value,
|
||||
).toBe("Read a little");
|
||||
await input("#habit-name", "Read five pages");
|
||||
await click("Dusk #79618d");
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector("dialog form")!
|
||||
.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
expect(container.querySelector("dialog")).toBeNull();
|
||||
const created = (await api.json("/habits")).habits[0];
|
||||
expect(created.name).toBe("Read five pages");
|
||||
expect(
|
||||
(await api.json(`/habits/${created.id}/calendar-settings`)).mainColor,
|
||||
).toBe("#79618d");
|
||||
expect(container.querySelector(".home-habit")?.textContent).toContain(
|
||||
"Read five pages",
|
||||
);
|
||||
expect(container.textContent).toContain("Your recorded progress");
|
||||
expect(container.textContent).not.toContain("No habits yet");
|
||||
const chart = container.querySelector(`#history-${created.id}`)!;
|
||||
expect(chart.classList.contains("ds-habit-chart")).toBe(true);
|
||||
expect(
|
||||
chart.querySelector<HTMLElement>(".ds-habit-chart-heading h4 > span")!
|
||||
.style.backgroundColor,
|
||||
).toBe("#79618d");
|
||||
});
|
||||
|
||||
test("edit controls prefill settings, cancel safely, and persist validated changes", async () => {
|
||||
const habit = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{
|
||||
name: "Water",
|
||||
method: "count",
|
||||
target: 8,
|
||||
unit: "glasses",
|
||||
carryPartialProgress: true,
|
||||
color: "#426582",
|
||||
schedule: { type: "interval", every: 2, anchor: "2026-09-04" },
|
||||
},
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
await click("Edit Water");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#habit-target")!.value,
|
||||
).toBe("8");
|
||||
expect(
|
||||
container.querySelector<HTMLSelectElement>("#habit-method")!.disabled,
|
||||
).toBe(true);
|
||||
await input("#habit-name", "Not saved");
|
||||
await click("Cancel");
|
||||
expect((await api.json(`/habits/${habit.id}`)).name).toBe("Water");
|
||||
await click("Edit Water");
|
||||
await input("#habit-name", "Drink water");
|
||||
await input("#habit-target", "10");
|
||||
await click("Dusk #79618d");
|
||||
const submit = async () =>
|
||||
act(async () =>
|
||||
container
|
||||
.querySelector("dialog form")!
|
||||
.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
Response.json({ error: "Try saving again" }, { status: 503 }),
|
||||
);
|
||||
await submit();
|
||||
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
|
||||
"Try saving again",
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#habit-name")!.value,
|
||||
).toBe("Drink water");
|
||||
await submit();
|
||||
expect(container.querySelector("dialog")).toBeNull();
|
||||
const updated = await api.json(`/habits/${habit.id}`);
|
||||
expect(updated.name).toBe("Drink water");
|
||||
expect(updated.target).toBe(10);
|
||||
expect(updated.carryPartialProgress).toBe(true);
|
||||
expect(updated.schedule).toEqual(habit.schedule);
|
||||
expect(
|
||||
(await api.json(`/habits/${habit.id}/calendar-settings`)).mainColor,
|
||||
).toBe("#79618d");
|
||||
expect(
|
||||
container.querySelector(`#history-${habit.id} h4`)?.textContent,
|
||||
).toBe("Drink water");
|
||||
});
|
||||
|
||||
test("editing a task habit preserves task IDs and schedules", async () => {
|
||||
const habit = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{
|
||||
name: "Reset",
|
||||
method: "tasks",
|
||||
tasks: [
|
||||
{ name: "Clear desk", schedule: { type: "weekdays", days: [5] } },
|
||||
],
|
||||
},
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
await click("Edit Reset");
|
||||
await input("#habit-name", "Evening reset");
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector("dialog form")!
|
||||
.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
expect(container.querySelector("dialog")).toBeNull();
|
||||
const updated = await api.json(`/habits/${habit.id}`);
|
||||
expect(updated.name).toBe("Evening reset");
|
||||
expect(updated.tasks).toEqual(habit.tasks);
|
||||
});
|
||||
|
||||
test("deletion requires confirmation, supports retry, and handles an empty dashboard", async () => {
|
||||
const habit = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Read", method: "manual" },
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
await click("Delete Read");
|
||||
expect(container.querySelector("dialog")?.textContent).toContain(
|
||||
"not permanently erased",
|
||||
);
|
||||
expect((await api.json("/habits")).habits).toHaveLength(1);
|
||||
await click("Keep habit");
|
||||
expect(container.querySelector("dialog")).toBeNull();
|
||||
await click("Delete Read");
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
Response.json({ error: "Could not delete" }, { status: 503 }),
|
||||
);
|
||||
await click("Delete habit");
|
||||
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
|
||||
"Could not delete",
|
||||
);
|
||||
expect(container.querySelector(`#history-${habit.id}`)).not.toBeNull();
|
||||
await click("Delete habit");
|
||||
expect(container.querySelector("dialog")).toBeNull();
|
||||
expect(container.querySelector(`#history-${habit.id}`)).toBeNull();
|
||||
expect(container.textContent).toContain("No habits yet");
|
||||
expect((await api.json("/habits")).habits).toHaveLength(0);
|
||||
expect((await api.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||
});
|
||||
|
||||
test("all tracking methods save, undo, and exclude unscheduled habits", async () => {
|
||||
const manual = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Read", method: "manual" },
|
||||
201,
|
||||
);
|
||||
const count = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Water", method: "count", target: 8, unit: "glasses" },
|
||||
201,
|
||||
);
|
||||
const tasks = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Reset", method: "tasks", tasks: [{ name: "Clear desk" }] },
|
||||
201,
|
||||
);
|
||||
await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{
|
||||
name: "Sunday walk",
|
||||
method: "manual",
|
||||
schedule: { type: "weekdays", days: [0] },
|
||||
},
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
expect(container.querySelectorAll(".home-habit")).toHaveLength(3);
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
"#rhythm .ds-habit-chart-grid > .ds-habit-chart",
|
||||
),
|
||||
).toHaveLength(4);
|
||||
expect(container.querySelector("#history-habit")).toBeNull();
|
||||
expect(
|
||||
container.querySelectorAll("#rhythm .ds-calendar--compact"),
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelector(".home-task-details")?.hasAttribute("open"),
|
||||
).toBe(false);
|
||||
expect(container.querySelector(".home-off-day")?.textContent).toContain(
|
||||
"Sunday walk",
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLInputElement>(`#habit-${manual.id}`)!
|
||||
.closest("article")!
|
||||
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
|
||||
.click(),
|
||||
);
|
||||
expect((await api.json("/today")).completed).toBe(1);
|
||||
await click("Increase Water (glasses)");
|
||||
expect((await api.json(`/habits/${count.id}/days/2026-09-04`)).value).toBe(
|
||||
1,
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector(`#habit-${tasks.id}`)!
|
||||
.closest("article")!
|
||||
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
|
||||
.click(),
|
||||
);
|
||||
expect((await api.json("/today")).completed).toBe(2);
|
||||
await click("Remaining 1");
|
||||
expect(container.querySelectorAll(".home-habit")).toHaveLength(1);
|
||||
await click("All today 3");
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector(`#habit-${manual.id}`)!
|
||||
.closest("article")!
|
||||
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
|
||||
.click(),
|
||||
);
|
||||
expect((await api.json("/today")).completed).toBe(1);
|
||||
});
|
||||
|
||||
test("all-complete and no-schedule days are different from having no habits", async () => {
|
||||
const habit = await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Read", method: "manual" },
|
||||
201,
|
||||
);
|
||||
await api.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", {
|
||||
done: true,
|
||||
});
|
||||
await render();
|
||||
expect(container.textContent).toContain(
|
||||
"Everything scheduled for today is complete",
|
||||
);
|
||||
await click("Remaining 0");
|
||||
expect(container.textContent).toContain("You’re all caught up.");
|
||||
await api.json(`/habits/${habit.id}`, "PATCH", {
|
||||
schedule: { type: "weekdays", days: [0] },
|
||||
});
|
||||
await act(async () => window.dispatchEvent(new Event("focus")));
|
||||
expect(container.textContent).toContain("Nothing is scheduled today");
|
||||
expect(container.textContent).not.toContain("No habits yet");
|
||||
});
|
||||
|
||||
test("failed logging preserves progress and offers a refresh", async () => {
|
||||
await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Water", method: "count", target: 8, unit: "glasses" },
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
Response.json({ error: "Could not save progress" }, { status: 503 }),
|
||||
);
|
||||
await click("Increase Water (glasses)");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Could not save progress",
|
||||
);
|
||||
expect((await api.json("/today")).habits[0].value).toBe(0);
|
||||
await click("Try again");
|
||||
await click("Increase Water (glasses)");
|
||||
expect((await api.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
|
||||
test("a failed initial load is not shown as an empty account", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(Response.json(account))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }));
|
||||
await render();
|
||||
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("No habits yet");
|
||||
await click("Try again");
|
||||
expect(container.textContent).toContain("No habits yet");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user