+ {showAssistance && (childEntries.length > 0 || childcareCounty) &&
Child {index + 1}
updateChild(index, "childcareCost", value)} />
-
- updateChild(index, "childcareHoursPerDay", value)} />
- updateChild(index, "childcareDaysPerWeek", value)} />
-
- updateChild(index, "childcareDaysPerMonth", value)} />
- Monthly attendance defaults to 22 days for year-round care. Adjust it to match your child’s schedule.
- {providers.map((provider, providerIndex) => (
-
-
-
updateChild(index, "childcareProviders", {
- ...child.childcareProviders, [provider.variable]: value === "__choose__" ? "" : value,
- })}
- options={[{ value: "__choose__", label: state === "AR" ? "Choose a care type" : "Choose a provider type" }, ...provider.options]} />
+ {(!progressive || Number(child.childcareCost) > 0) && <>
+
+ updateChild(index, "childcareHoursPerDay", value)} />
+ updateChild(index, "childcareDaysPerWeek", value)} />
- ))}
+ updateChild(index, "childcareDaysPerMonth", value)} />
+ Monthly attendance defaults to 22 days for year-round care. Adjust it to match your child’s schedule.
+ {providers.map((provider, providerIndex) => (
+
+
+ updateChild(index, "childcareProviders", {
+ ...child.childcareProviders, [provider.variable]: value === "__choose__" ? "" : value,
+ })}
+ options={[{ value: "__choose__", label: state === "AR" ? "Choose a care type" : "Choose a provider type" }, ...provider.options]} />
+
+ ))}
+ >}
updateChild(index, "isHeadStartEnrolled", value)} />
))}
)}
- Head Start and Early Head Start service values appear separately from financial resources in the results. Eligibility does not guarantee an available place.
+ {!hideChildcare && Head Start and Early Head Start service values appear separately from financial resources in the results. Eligibility does not guarantee an available place.
}
{error && {error}
}
);
diff --git a/app/globals.css b/app/globals.css
index 48a44f3..400dc83 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2083,3 +2083,178 @@ body footer .grid > div {
.sf-us-childcare .sf-toggle { white-space: normal; align-items: flex-start; }
.sf-us-childcare .sf-toggle-track { margin-top: 0.05rem; flex-shrink: 0; }
.sf-childcare-error { color: var(--color-danger); font-size: 0.8rem; line-height: 1.4; margin-top: 0.5rem; }
+
+/* Guided setup keeps the working surface quiet and the next action clear. */
+.app-layout--wizard {
+ grid-template-columns: minmax(0, 44rem);
+ justify-content: center;
+ padding: 1rem 0 2rem;
+}
+
+.app-layout--wizard .app-sidebar { position: static; min-width: 0; }
+.app-layout--wizard .sidebar-collapsible { display: block; margin-top: 0; }
+.app-main[hidden] { display: none; }
+.app-main:focus { outline: none; }
+
+.sidebar-form.wizard-form {
+ scroll-margin-top: calc(var(--site-header-height) + 1rem);
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ gap: 1.75rem;
+ font-family: var(--font-sans);
+}
+
+.wizard-progress ol {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ list-style: none;
+ padding: 0;
+ gap: 1rem;
+}
+
+.wizard-progress li {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding-bottom: 0.9rem;
+ border-bottom: 2px solid var(--border);
+ color: var(--muted-foreground);
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: color 150ms ease, border-color 150ms ease;
+}
+
+.wizard-progress li[aria-current="step"] {
+ color: var(--primary);
+ border-color: var(--primary);
+ font-weight: 600;
+}
+
+.wizard-progress li.is-complete { border-color: var(--primary); }
+
+.wizard-step-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 1.6rem;
+ height: 1.6rem;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ font-size: 0.75rem;
+}
+
+[aria-current="step"] .wizard-step-number {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: var(--primary-foreground);
+}
+
+.wizard-step {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ animation: wizard-step-enter 140ms ease-out;
+}
+
+.wizard-step-title {
+ color: var(--foreground);
+ font-size: 1.65rem;
+ letter-spacing: -0.025em;
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+.wizard-step-title:focus { outline: none; }
+.wizard-description { color: var(--muted-foreground); font-size: 0.95rem; line-height: 1.6; }
+.wizard-comparison-note { border-left: 2px solid var(--primary); padding-left: 1rem; color: var(--muted-foreground); font-size: 0.875rem; line-height: 1.6; }
+.wizard-default-note { color: var(--muted-foreground); font-size: 0.8rem; line-height: 1.55; }
+
+.wizard-household,
+.wizard-adult-details {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1.5rem;
+}
+
+.wizard-form .sf-person,
+.wizard-form .sf-extra,
+.wizard-form .sf-group {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ margin: 0;
+}
+
+.wizard-form .sf-person::before { display: none; }
+.wizard-form .sf-person-title,
+.wizard-form .sf-extra-title,
+.wizard-form .sf-group-title,
+.wizard-form .sf-children-label {
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--foreground);
+ border: 0;
+}
+
+.wizard-form .sf-field label { text-transform: none; letter-spacing: 0; font-size: 0.8rem; font-weight: 500; color: var(--muted-foreground); }
+.wizard-form .sf-field input { font-size: 1rem; }
+.wizard-form .sf-toggle { font-size: 0.85rem; white-space: normal; }
+.wizard-form .sf-toggle-track { flex-shrink: 0; }
+.wizard-form .sf-checks { gap: 0.85rem; margin-top: 0.65rem; }
+.wizard-form .sf-us-childcare { padding-top: 1.25rem; border-top: 1px solid var(--border); }
+.wizard-form .sf-input-note { font-size: 0.8rem; line-height: 1.55; }
+.wizard-form .sf-children { padding-top: 1.25rem; border-top: 1px solid var(--border); }
+.wizard-form .sf-child { background: var(--background); }
+.wizard-form .sf-assumptions { background: transparent; border-color: var(--border); }
+.wizard-form .sf-assumptions-summary { text-transform: none; letter-spacing: 0; font-weight: 500; }
+.wizard-form .country-toggle { max-width: 28rem; }
+
+.wizard-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 1rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--border);
+}
+
+.wizard-actions .btn-calc { width: auto; min-width: 10rem; margin: 0; background: var(--primary); color: var(--primary-foreground); box-shadow: none; }
+.wizard-actions .btn-calc:hover { background: var(--color-teal-700); transform: none; }
+.wizard-back { margin-right: auto; padding: 0.75rem 1rem; border: 1px solid var(--border); border-radius: var(--radius-md); color: var(--muted-foreground); background: var(--background); cursor: pointer; font-size: 0.875rem; }
+.wizard-skip { align-self: center; color: var(--muted-foreground); font-size: 0.8rem; padding: 0.5rem 0.75rem; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
+.wizard-back:hover, .wizard-skip:hover { color: var(--primary); }
+.wizard-back:focus-visible, .wizard-skip:focus-visible, .editor-heading button:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
+
+.editor-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; padding: 0 0.25rem; gap: 1rem; }
+.editor-heading h2 { font-size: 0.85rem; font-weight: 600; color: var(--muted-foreground); }
+.editor-heading button { font-size: 0.75rem; color: var(--primary); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
+
+@keyframes wizard-step-enter {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@media (max-width: 600px) {
+ .app-layout--wizard { padding-top: 0.5rem; }
+ .wizard-progress ol { gap: 0.5rem; }
+ .wizard-progress li { gap: 0.35rem; font-size: 0.75rem; }
+ .wizard-step-number { width: 1.35rem; height: 1.35rem; }
+ .wizard-step-title { font-size: 1.4rem; }
+ .wizard-household, .wizard-adult-details { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
+ .wizard-form .sf-row { flex-direction: row; }
+ .wizard-form .sf-year { flex-basis: 105px; }
+ .wizard-actions .btn-calc { min-width: 0; flex: 1; }
+ .wizard-back { flex: 0 0 auto; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .wizard-step { animation: none; }
+ .wizard-progress li { transition: none; }
+}
diff --git a/tests/design.test.jsx b/tests/design.test.jsx
index c4ba772..1f52f77 100644
--- a/tests/design.test.jsx
+++ b/tests/design.test.jsx
@@ -247,6 +247,7 @@ describe("Country toggle", () => {
const note = within(details).getByRole("note", { name: "Model assumptions" });
expect(within(note).getByText(/wages and salaries only/i)).toBeTruthy();
expect(within(note).getByText(/all children are assigned to you/i)).toBeTruthy();
- expect(within(note).getByText(/healthcare benefits are excluded from the analysis/i)).toBeTruthy();
+ expect(within(note).getByText(/employer-sponsored insurance/i)).toBeTruthy();
+ expect(within(note).queryByText(/healthcare benefits are excluded from the analysis/i)).toBeNull();
});
});
diff --git a/tests/guided-entry.test.jsx b/tests/guided-entry.test.jsx
new file mode 100644
index 0000000..5a6d8c1
--- /dev/null
+++ b/tests/guided-entry.test.jsx
@@ -0,0 +1,181 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
+import MarriageApp from "../app/MarriageApp.jsx";
+import { getCategorizedPrograms, getHeatmapData } from "../lib/api.js";
+
+// Keep the real form's model-independent helpers, while preventing calculations
+// from reaching either country API during these entry-flow regressions.
+vi.mock("../lib/api.js", async (importOriginal) => ({
+ ...await importOriginal(),
+ getCategorizedPrograms: vi.fn(),
+ getHeatmapData: vi.fn(),
+}));
+vi.mock("../app/components/ResultsDisplay.jsx", () => ({
+ default: ({ results, headIncome, spouseIncome }) => (
+
+ Calculated amount: {results.amount}
+ Submitted earnings: {headIncome} and {spouseIncome}
+
+ ),
+}));
+
+const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+beforeAll(() => { HTMLElement.prototype.scrollIntoView = vi.fn(); });
+afterAll(() => { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; });
+beforeEach(() => {
+ vi.clearAllMocks();
+ window.history.replaceState(null, "", "/");
+ getCategorizedPrograms.mockResolvedValue({ amount: 35000 });
+ getHeatmapData.mockResolvedValue(null);
+});
+afterEach(cleanup);
+
+function progress() {
+ return screen.getByRole("navigation", { name: "Setup progress" });
+}
+function continueSetup() {
+ fireEvent.click(screen.getByRole("button", { name: "Continue", exact: true }));
+}
+function changeInput(name, value) {
+ const field = screen.getByLabelText(name, { exact: true });
+ fireEvent.change(field, { target: { value } });
+ fireEvent.blur(field);
+}
+function reachDetails() {
+ continueSetup();
+ continueSetup();
+}
+
+it("starts a new visit with comparison, household, and details, without sending an early calculation", () => {
+ render(
);
+ expect(progress().textContent).toMatch(/Comparison.*Household.*Details/);
+ expect(within(progress()).getByText("Comparison").closest("li").getAttribute("aria-current")).toBe("step");
+ expect(screen.queryByRole("button", { name: "Calculate", exact: true })).toBeNull();
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+
+ continueSetup();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("45,000");
+ expect(screen.getByRole("button", { name: "Add child" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Calculate", exact: true })).toBeNull();
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+
+ continueSetup();
+ expect(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Calculate", exact: true })).toBeTruthy();
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+ expect(getHeatmapData).not.toHaveBeenCalled();
+});
+
+it("submits the guided household once and keeps it editable after results and subsequent input changes", async () => {
+ render(
);
+ continueSetup();
+ changeInput("You income", "21000");
+ changeInput("Your partner income", "9000");
+ changeInput("You age", "37");
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
+ changeInput("Child 1 age", "3");
+
+ // Revisiting a preceding step must not remount the household fields.
+ fireEvent.click(screen.getByRole("button", { name: "Back", exact: true }));
+ continueSetup();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("21,000");
+ expect(screen.getByLabelText("Child 1 age").value).toBe("3");
+ continueSetup();
+ fireEvent.click(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+
+ await screen.findByRole("region", { name: "Calculation results" });
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ const firstRequest = getCategorizedPrograms.mock.calls[0];
+ expect(firstRequest.slice(0, 4)).toEqual(["us", "CA", 21000, 9000]);
+ expect(firstRequest[4]).toEqual([expect.objectContaining({ age: 3, isDisabled: false })]);
+ expect(firstRequest[5]).toEqual({ head: true, spouse: false });
+ expect(firstRequest[8]).toBe(37);
+ expect(firstRequest[12].livingArrangement).toBe("cohabiting");
+ expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("21,000");
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("head")).toBe("21000");
+
+ changeInput("You income", "22000");
+ expect(screen.queryByRole("region", { name: "Calculation results" })).toBeNull();
+ expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ await screen.findByText("Submitted earnings: 22000 and 9000");
+ expect(getCategorizedPrograms).toHaveBeenCalledTimes(2);
+ expect(getCategorizedPrograms.mock.calls[1][4]).toEqual(firstRequest[4]);
+ expect(getCategorizedPrograms.mock.calls[1][5]).toEqual(firstRequest[5]);
+});
+
+it.each([
+ "#country=us®ion=CA&head=15000&spouse=10000&c=3:0&year=2026&living=cohabiting",
+ "#region=CA&head=15000&spouse=10000&c=3:0&year=2026",
+])("opens a shared calculation directly in the editor: %s", async (hash) => {
+ window.history.replaceState(null, "", hash);
+ render(
);
+ await screen.findByRole("region", { name: "Calculation results" });
+ expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("15,000");
+ expect(screen.getByRole("textbox", { name: "Your partner income" }).value).toBe("10,000");
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ expect(getCategorizedPrograms.mock.calls[0][12].livingArrangement).toBe(hash.includes("living=cohabiting") ? "cohabiting" : "separate");
+});
+
+it("uses a country-only UK link to start guided setup with fiscal-year labels", () => {
+ window.history.replaceState(null, "", "#country=uk");
+ render(
);
+ expect(progress()).toBeTruthy();
+ expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026-27");
+ expect(screen.queryByRole("combobox", { name: "Unmarried living arrangement" })).toBeNull();
+ continueSetup();
+ expect(screen.getByRole("spinbutton", { name: "You age" }).value).toBe("35");
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+});
+
+it.each([
+ "#country=us&head=15000",
+ "#country=us®ion=CA&head=15000&care=not-json",
+])("falls back to guided setup for an incomplete or malformed calculation hash: %s", (hash) => {
+ window.history.replaceState(null, "", hash);
+ render(
);
+ expect(progress()).toBeTruthy();
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+ expect(getHeatmapData).not.toHaveBeenCalled();
+});
+
+it("can leave guided setup for the full editor without discarding entered inputs", async () => {
+ render(
);
+ continueSetup();
+ changeInput("You income", "32000");
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
+ changeInput("Child 1 age", "8");
+ fireEvent.click(screen.getByRole("button", { name: "Use full form", exact: true }));
+ expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("32,000");
+ expect(getCategorizedPrograms).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ await screen.findByRole("region", { name: "Calculation results" });
+ expect(getCategorizedPrograms.mock.calls[0][2]).toBe(32000);
+ expect(getCategorizedPrograms.mock.calls[0][4]).toEqual([expect.objectContaining({ age: 8 })]);
+});
+
+it("shows a failed guided calculation in the editor and retries the same submitted household", async () => {
+ getCategorizedPrograms.mockRejectedValueOnce(new Error("Calculation service unavailable."));
+ render(
);
+ reachDetails();
+ fireEvent.click(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect((await screen.findByRole("alert")).textContent).toContain("Calculation service unavailable.");
+ expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("45,000");
+ const firstRequest = getCategorizedPrograms.mock.calls[0];
+ fireEvent.click(screen.getByRole("button", { name: "Retry calculation", exact: true }));
+ await screen.findByRole("region", { name: "Calculation results" });
+ await waitFor(() => expect(getHeatmapData).toHaveBeenCalledOnce());
+ expect(getCategorizedPrograms).toHaveBeenCalledTimes(2);
+ expect(getCategorizedPrograms.mock.calls[1].slice(0, 12)).toEqual(firstRequest.slice(0, 12));
+ expect(getCategorizedPrograms.mock.calls[1][12].signal).not.toBe(firstRequest[12].signal);
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/tests/guided-form.test.jsx b/tests/guided-form.test.jsx
new file mode 100644
index 0000000..11cbf6b
--- /dev/null
+++ b/tests/guided-form.test.jsx
@@ -0,0 +1,177 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import InputForm from "../app/components/InputForm.jsx";
+import { COUNTRIES } from "../lib/countries.js";
+
+afterEach(cleanup);
+
+function props(overrides = {}) {
+ return { country: COUNTRIES.us, countryId: "us", mode: "wizard", loading: false, onCalculate: vi.fn(), ...overrides };
+}
+
+function next() {
+ fireEvent.click(screen.getByRole("button", { name: "Continue" }));
+}
+
+describe("guided household form", () => {
+ it("advances form submission through focused steps without calculating early", () => {
+ const formProps = props();
+ const { container } = render(
);
+ expect(screen.getByRole("heading", { name: "Choose your comparison" })).toBeTruthy();
+ expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026");
+ expect(screen.getByText(/sharing a home, food and resources/)).toBeTruthy();
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ // Enter uses the form's submit event, just like the Continue button.
+ fireEvent.submit(container.querySelector("form"));
+ expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Describe your household" }));
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("45,000");
+ expect(screen.getByRole("spinbutton", { name: "You age" }).value).toBe("40");
+ expect(screen.getByText(/No children included/)).toBeTruthy();
+ fireEvent.submit(container.querySelector("form"));
+ expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Add relevant details" }));
+ expect(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }).checked).toBe(false);
+ expect(screen.getByLabelText("Your work hours / week").value).toBe("40");
+ expect(screen.queryByRole("checkbox", { name: "Assume a funded childcare slot" })).toBeNull();
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "0" } });
+ fireEvent.submit(container.querySelector("form"));
+ expect(formProps.onCalculate).toHaveBeenCalledOnce();
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({
+ livingArrangement: "cohabiting", headIncome: 45000, spouseIncome: 45000,
+ headAge: 40, spouseAge: 40, children: [], childcareWorkHours: { head: 0, spouse: 40 }, ccdfSlotAvailable: true,
+ });
+ });
+
+ it("preserves adult and child values through Back and a switch to the full form", () => {
+ const formProps = props({ onExitWizard: vi.fn() });
+ const { rerender } = render(
);
+ next();
+ fireEvent.change(screen.getByRole("textbox", { name: "You income" }), { target: { value: "18000" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
+ fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "0" } });
+ fireEvent.click(screen.getByRole("checkbox", { name: "Child 1 meets SSI disability criteria" }));
+ expect(screen.getByText(/All children are assumed to be both adults’ children/)).toBeTruthy();
+ expect(screen.getByText(/more than half the cost of keeping up the home/)).toBeTruthy();
+ next();
+ fireEvent.click(screen.getByRole("button", { name: "Back" }));
+ expect(screen.getByLabelText("Child 1 age").value).toBe("0");
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("18000");
+ fireEvent.click(screen.getByRole("button", { name: "Use full form" }));
+ expect(formProps.onExitWizard).toHaveBeenCalledOnce();
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ rerender(
);
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("18000");
+ fireEvent.click(screen.getByText("More details"));
+ expect(screen.getByLabelText("Child 1 age").value).toBe("0");
+ expect(screen.getByRole("checkbox", { name: "Child 1 meets SSI disability criteria" }).checked).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ headIncome: 18000, children: [{ age: 0, isDisabled: true }] });
+ });
+
+ it.each(["", "17", "101", "35.5"])("blocks an invalid adult age %j on Continue and Enter submission", (value) => {
+ const formProps = props();
+ const { container } = render(
);
+ next();
+ fireEvent.change(screen.getByRole("spinbutton", { name: "You age" }), { target: { value } });
+ fireEvent.blur(screen.getByRole("spinbutton", { name: "You age" }));
+ fireEvent.submit(container.querySelector("form"));
+ expect(screen.getByRole("heading", { name: "Describe your household" })).toBeTruthy();
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByRole("spinbutton", { name: "You age" }), { target: { value: "18" } });
+ next();
+ expect(screen.getByRole("heading", { name: "Add relevant details" })).toBeTruthy();
+ });
+
+ it("requires a valid child age while accepting a newborn", () => {
+ render(
);
+ next();
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
+ fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "" } });
+ next();
+ expect(screen.getByRole("alert").textContent).toContain("children 0–18");
+ fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "0" } });
+ next();
+ expect(screen.getByRole("heading", { name: "Add relevant details" })).toBeTruthy();
+ });
+
+ it("focuses childcare validation in the visible final step and allows correction", () => {
+ const formProps = props({ initialValues: { children: [{ age: 3 }] } });
+ render(
);
+ next();
+ next();
+ fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "10000" } });
+ fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(screen.getByRole("alert").textContent).toContain("Choose a county");
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ ccdfSlotAvailable: false, children: [{ childcareCost: 10000 }] });
+ });
+
+ it("reveals childcare follow-ups only when paid care is entered", () => {
+ render(
);
+ next();
+ next();
+ expect(screen.getByLabelText("Child 1 annual childcare price ($)").value).toBe("0");
+ expect(screen.getByRole("checkbox", { name: "Child 1 enrolled in Head Start or Early Head Start" })).toBeTruthy();
+ expect(screen.queryByLabelText("Child 1 care hours / day")).toBeNull();
+ expect(screen.queryByRole("combobox", { name: "Childcare county" })).toBeNull();
+ expect(screen.queryByRole("checkbox", { name: "Assume a funded childcare slot" })).toBeNull();
+ fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "10000" } });
+ expect(screen.getByLabelText("Child 1 care hours / day").value).toBe("8");
+ expect(screen.getByRole("combobox", { name: "Childcare county" })).toBeTruthy();
+ expect(screen.getByRole("combobox", { name: "Child 1 provider type" })).toBeTruthy();
+ expect(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }).checked).toBe(true);
+ fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "0" } });
+ expect(screen.queryByLabelText("Child 1 care hours / day")).toBeNull();
+ expect(screen.queryByRole("combobox", { name: "Childcare county" })).toBeNull();
+ });
+
+ it.each([
+ ["Your work hours / week", "169"],
+ ["Child 1 care hours / day", "25"],
+ ["Child 1 care days / month", "32"],
+ ])("retains native numeric constraints for %s on final submission", (label, value) => {
+ const formProps = props({ initialValues: { children: [{ age: 3, childcareCost: 10000 }], ccdfSlotAvailable: false } });
+ render(
);
+ next();
+ next();
+ const input = screen.getByLabelText(label);
+ fireEvent.change(input, { target: { value } });
+ expect(input.validity.rangeOverflow).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: "Add relevant details" })).toBeTruthy();
+ });
+
+ it("focuses the current step when returning from the full form", () => {
+ const formProps = props();
+ const { rerender } = render(
);
+ next();
+ rerender(
);
+ rerender(
);
+ expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Describe your household" }));
+ });
+
+ it("shows the UK comparison and relevant defaults without US-only questions", () => {
+ const formProps = props({ country: COUNTRIES.uk, countryId: "uk" });
+ render(
);
+ expect(screen.getByText(/UK benefits generally assess couples who live together/)).toBeTruthy();
+ expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026-27");
+ expect(screen.queryByRole("combobox", { name: "Unmarried living arrangement" })).toBeNull();
+ next();
+ expect(screen.getByRole("spinbutton", { name: "You age" }).value).toBe("35");
+ next();
+ expect(screen.getByRole("combobox", { name: "Tenure" }).textContent).toBe("Owned outright");
+ expect(screen.getByRole("textbox", { name: "Savings" }).value).toBe("0");
+ expect(screen.queryByRole("textbox", { name: "Annual childcare costs" })).toBeNull();
+ expect(screen.queryByLabelText("Your work hours / week")).toBeNull();
+ expect(screen.queryByRole("checkbox", { name: /SSI disability/ })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ livingArrangement: "separate", rent: 0, savings: 0 });
+ });
+});
diff --git a/tests/living-arrangement-ui.test.jsx b/tests/living-arrangement-ui.test.jsx
index 4224a8d..424f742 100644
--- a/tests/living-arrangement-ui.test.jsx
+++ b/tests/living-arrangement-ui.test.jsx
@@ -122,6 +122,7 @@ describe("living arrangement form", () => {
let resolve;
getCategorizedPrograms.mockImplementationOnce(() => new Promise((r) => { resolve = r; }));
render(
);
+ fireEvent.click(screen.getByRole("button", { name: "Use full form" }));
fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
await selectLivingSeparately();
await act(async () => { resolve(cohabitingResults); });