diff --git a/app/MarriageApp.jsx b/app/MarriageApp.jsx index de228f6..79fd14a 100644 --- a/app/MarriageApp.jsx +++ b/app/MarriageApp.jsx @@ -188,6 +188,10 @@ export default function MarriageApp({ initialCountry = null }) { const [showConfetti, setShowConfetti] = useState(false); const [externalIncomes, setExternalIncomes] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); + const [guidedSetup, setGuidedSetup] = useState(true); + const resultsPanel = useRef(null); + const focusEditor = useRef(false); + const inputPanel = useRef(null); const didAutoCalc = useRef(false); const calculationId = useRef(0); const activeRequest = useRef(null); @@ -204,9 +208,21 @@ export default function MarriageApp({ initialCountry = null }) { const resolvedCountry = decoded.current?.countryId || hashCountry || initialCountry || DEFAULT_COUNTRY; setCountryId(resolvedCountry); setIsEmbedded(window.self !== window.top); + setGuidedSetup(!decoded.current); setMounted(true); }, [initialCountry]); + useEffect(() => { + if (loading) resultsPanel.current?.focus(); + }, [loading]); + + useEffect(() => { + if (!guidedSetup && focusEditor.current) { + inputPanel.current?.querySelector("button, input")?.focus(); + focusEditor.current = false; + } + }, [guidedSetup]); + // Fire the tool_engaged conversion event after the user has been on // the page long enough to count as genuinely engaged. Matches the // 15-second threshold and event shape used by policyengine-app-v2's @@ -353,6 +369,7 @@ export default function MarriageApp({ initialCountry = null }) { } async function handleCalculate(data) { + setGuidedSetup(false); // Retry the submitted values, never a partially edited form or mutable // object retained by the caller. Country changes clear this snapshot. const snapshot = { countryId, data: structuredClone(data) }; @@ -439,13 +456,19 @@ export default function MarriageApp({ initialCountry = null }) { -
-
diff --git a/app/components/USChildcareInputs.jsx b/app/components/USChildcareInputs.jsx index 7034d10..33bc31c 100644 --- a/app/components/USChildcareInputs.jsx +++ b/app/components/USChildcareInputs.jsx @@ -77,16 +77,18 @@ function NumberField({ id, label, value, onChange, max, step = "any" }) { export default function USChildcareInputs({ regionCode, childEntries, updateChild, ccdfSlotAvailable, onCcdfSlotAvailableChange, childcareCounty, onCountyChange, childcareWorkHours, onWorkHoursChange, - childcareActivityEligible, onActivityEligibleChange, error, + childcareActivityEligible, onActivityEligibleChange, error, hideChildcare = false, + progressive = false, }) { const state = stateCode(regionCode); const counties = childcareCounties(regionCode); const providers = childcareMetadata.states[state]?.providers || []; const paidCare = childEntries.some((child) => Number(child.childcareCost) > 0); + const showAssistance = !hideChildcare && (!progressive || paidCare || Boolean(childcareCounty)); const providerLabel = (index) => state === "AR" ? "Care type" : `Provider type${providers.length > 1 ? ` ${index + 1}` : ""}`; return (
-
Work, childcare and early learning
+
{hideChildcare ? "Work" : "Work, childcare and early learning"}

Weekly work hours default to 40 for each adult. Adjust these to match your work schedule. They stay fixed across the income grid and may affect other benefits as well as childcare assistance.

onWorkHoursChange({ ...childcareWorkHours, spouse })} />
- -

Assumes a funded place is available if you otherwise qualify for childcare assistance, rather than being on a waiting list. Without a funded slot, you still pay the childcare costs entered.

- {(childEntries.length > 0 || childcareCounty) && ( + {showAssistance && } + {showAssistance &&

Assumes a funded place is available if you otherwise qualify for childcare assistance, rather than being on a waiting list. Without a funded slot, you still pay the childcare costs entered.

} + {!hideChildcare && (childEntries.length > 0 || childcareCounty) && (
- {(childEntries.length > 0 || childcareCounty) &&
+ {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); });