+ {!hideWork && <>
{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.
@@ -97,6 +98,7 @@ export default function USChildcareInputs({
value={childcareWorkHours.spouse} onChange={(spouse) => onWorkHoursChange({ ...childcareWorkHours, spouse })} />
+ >}
{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) && (
diff --git a/app/globals.css b/app/globals.css
index 400dc83..d0a4f27 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -24,7 +24,7 @@ body {
text-rendering: optimizeLegibility;
}
-.app {
+.app, .household-edit-dialog {
/* Brand palette — PolicyEngine teal (@policyengine/ui-kit) */
--pe-teal-50: #E6FFFA;
--pe-teal-100: #B2F5EA;
@@ -131,7 +131,6 @@ body {
--color-error-text: var(--color-danger);
/* Spacing & radii */
- --sidebar-width: 340px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
@@ -146,6 +145,9 @@ body {
--site-header-height: 58px;
--content-max-width: 1280px;
+}
+
+.app {
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -314,120 +316,7 @@ body {
box-shadow: 0 1px 2px rgba(15,23,42,0.08);
}
-/* ===========================================================
- 3-PANEL LAYOUT: sidebar | table | heatmap
- =========================================================== */
-.app-layout {
- display: grid;
- grid-template-columns: var(--sidebar-width) 1fr;
- gap: 1.5rem;
- align-items: start;
-}
-
-.app-sidebar {
- position: sticky;
- top: 1rem;
-}
-
-.app-main {
- min-width: 0;
-}
-
-.main-placeholder {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 380px;
- padding: 2rem 2.5rem;
- background: var(--color-surface);
- border-radius: var(--radius-xl);
- box-shadow: var(--shadow-card);
- color: var(--color-text-muted);
- font-size: 0.95rem;
-}
-
-.main-placeholder--intro {
- justify-content: flex-start;
- align-items: flex-start;
- background:
- linear-gradient(135deg, rgba(49, 151, 149, 0.04), rgba(49, 151, 149, 0) 45%),
- var(--color-surface);
- border: 1px solid var(--color-border-light);
-}
-
-.intro-card {
- max-width: 560px;
-}
-
-.intro-card h2 {
- font-size: 1.4rem;
- font-weight: 700;
- letter-spacing: -0.01em;
- color: var(--pe-gray-900);
- margin-bottom: 0.75rem;
- line-height: 1.2;
-}
-
-.intro-card p {
- color: var(--color-text-muted);
- font-size: 0.95rem;
- line-height: 1.55;
- margin-bottom: 1rem;
-}
-
-.intro-highlights {
- list-style: none;
- padding: 0;
- margin: 0 0 1.25rem;
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
-}
-
-.intro-highlights li {
- position: relative;
- padding-left: 1.5rem;
- color: var(--color-text);
- font-size: 0.92rem;
-}
-
-.intro-highlights li::before {
- content: "";
- position: absolute;
- left: 0;
- top: 0.55rem;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--color-primary);
- opacity: 0.85;
-}
-
-.intro-cta {
- color: var(--color-heading);
- font-weight: 500;
- margin-bottom: 0 !important;
-}
-
-.intro-cta strong {
- color: var(--color-primary-hover);
- font-weight: 700;
-}
-
-/* ===========================================================
- SIDEBAR FORM
- =========================================================== */
-.sidebar-form {
- background: var(--color-surface);
- border-radius: var(--radius-xl);
- padding: 1.25rem;
- border: 1px solid var(--color-border-light);
- box-shadow: var(--shadow-card);
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
+/* Shared form controls */
.sf-assumptions {
border: 1px solid var(--color-primary-200);
border-radius: var(--radius-md);
@@ -1469,95 +1358,18 @@ body {
font-style: italic;
}
-/* ===========================================================
- COLLAPSIBLE SIDEBAR (mobile only)
- =========================================================== */
-.sidebar-toggle {
- display: none; /* hidden on desktop */
-}
-
-.sidebar-collapsible {
- display: contents; /* transparent wrapper on desktop */
-}
-
-/* ===========================================================
- RESPONSIVE
- =========================================================== */
-@media (max-width: 1024px) {
- .app {
- --sidebar-width: 300px;
- }
-}
-
+/* Responsive results and shared controls */
@media (max-width: 768px) {
- .app {
- padding: 0.75rem 1rem;
- }
-
.app-header h1 {
font-size: 1.3rem;
}
- .app-layout {
- grid-template-columns: 1fr;
- }
-
- .app-sidebar {
- position: static;
- }
-
- /* Collapsible sidebar */
- .sidebar-toggle {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- padding: 0.6rem 0.85rem;
- font-size: 0.95rem;
- color: var(--color-text);
- cursor: pointer;
- font-weight: 600;
- }
-
- .sidebar-toggle-summary {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .sidebar-toggle-arrow {
- font-size: 0.72rem;
- color: var(--color-text-muted);
- flex-shrink: 0;
- margin-left: 0.5rem;
- }
-
- .sidebar-collapsible {
- display: block;
- }
-
- .app-sidebar.has-results .sidebar-collapsible {
- display: none;
- }
-
- .app-sidebar.has-results.sidebar-open .sidebar-collapsible {
- display: block;
- margin-top: 0.6rem;
- }
-
.headline-banner {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
- .sidebar-form {
- flex-direction: column;
- }
-
.sf-person {
min-width: 0;
}
@@ -2084,177 +1896,118 @@ body footer .grid > div {
.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); }
-}
-
+/* One setup journey, followed by a results workspace with focused edits. */
+.app { background: var(--background); color: var(--foreground); font-family: var(--font-sans); }
+.app-shell { max-width: 1180px; }
+.app-header { padding-bottom: 1.25rem; margin-bottom: 0; }
+.header-text { display: block; }
+.header-text h1 { font-size: 1.65rem; color: var(--foreground); }
+.header-text p { margin-top: 0.35rem; font-size: 0.95rem; color: var(--muted-foreground); }
+.setup-workspace { width: 100%; max-width: 42rem; margin: 1rem auto 3rem; }
+.journey-form { display: flex; flex-direction: column; gap: 1.75rem; color: var(--foreground); font-family: var(--font-sans); scroll-margin-top: 74px; }
+.journey-progress p { color: var(--muted-foreground); font-size: 0.8rem; margin-bottom: 0.75rem; }
+.journey-progress ol { display: flex; list-style: none; gap: 0.75rem; padding: 0; }
+.journey-progress li { flex: 1; min-width: 0; border-top: 2px solid var(--border); color: var(--muted-foreground); }
+.journey-progress button { width: 100%; text-align: left; padding-top: 0.65rem; font-size: 0.8rem; cursor: pointer; }
+.journey-progress button:disabled { cursor: default; }
+.journey-progress li[aria-current="step"], .journey-progress li.is-complete { border-color: var(--primary); }
+.journey-progress li[aria-current="step"] { color: var(--primary); font-weight: 600; }
+.journey-step { display: flex; flex-direction: column; gap: 1.25rem; animation: journey-enter 140ms ease-out; }
+.journey-step-title { color: var(--foreground); font-size: 1.7rem; font-weight: 600; line-height: 1.25; letter-spacing: -0.025em; scroll-margin-top: 74px; }
+.journey-step-title:focus, .results-workspace-content:focus { outline: none; }
+.journey-description { color: var(--muted-foreground); font-size: 0.95rem; line-height: 1.6; margin-top: -0.65rem; }
+.journey-note { color: var(--muted-foreground); font-size: 0.825rem; line-height: 1.6; }
+.journey-people { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.75rem; }
+.journey-form .sf-person, .journey-form .sf-extra, .journey-form .sf-group { padding: 0; margin: 0; border: 0; border-radius: 0; background: transparent; }
+.journey-form .sf-person::before { display: none; }
+.journey-form .sf-person-title, .journey-form .sf-extra-title, .journey-form .sf-group-title, .journey-form .sf-children-label { color: var(--foreground); font-size: 1rem; font-weight: 600; text-transform: none; letter-spacing: 0; border: 0; }
+.journey-form .sf-field label { color: var(--muted-foreground); font-size: 0.825rem; text-transform: none; letter-spacing: 0; font-weight: 500; }
+.journey-form .sf-field input { color: var(--foreground); background: var(--background); border-color: var(--border); font-size: 1rem; min-height: 44px; }
+.journey-form .sf-field input:focus { border-color: var(--ring); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent); }
+.journey-form .sf-checks { flex-direction: column; align-items: flex-start; gap: 1rem; margin-top: 0.65rem; }
+.journey-form .sf-toggle { white-space: normal; font-size: 0.85rem; min-height: 28px; }
+.journey-form .sf-toggle-track { flex-shrink: 0; }
+.journey-form .sf-children { padding-top: 1.25rem; border-color: var(--border); }
+.journey-form .sf-child { background: var(--background); border-color: var(--border); padding: 0.65rem; border-radius: var(--radius-md); }
+.journey-form .sf-child input { min-height: 36px; width: 52px; }
+.journey-form .btn-add-child, .journey-form .sf-child-rm { width: 36px; height: 36px; }
+.journey-form .sf-input-note { font-size: 0.8rem; line-height: 1.55; }
+.journey-form .sf-assumptions { background: transparent; border-color: var(--border); }
+.journey-form .sf-assumptions-summary { text-transform: none; letter-spacing: 0; font-weight: 500; }
+.journey-form .sf-assumptions-body { border-color: var(--border); }
+.journey-comparison { border: 0; display: grid; gap: 0.75rem; }
+.journey-comparison legend { color: var(--foreground); font-size: 0.95rem; font-weight: 500; margin-bottom: 0.85rem; }
+.journey-choice { display: flex; align-items: flex-start; gap: 0.8rem; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 1rem; cursor: pointer; transition: border-color 120ms ease, background 120ms ease; }
+.journey-choice input { accent-color: var(--primary); margin-top: 0.3rem; flex-shrink: 0; width: 1rem; height: 1rem; }
+.journey-choice strong { display: block; font-size: 0.95rem; font-weight: 600; color: var(--foreground); }
+.journey-choice span span { display: block; font-size: 0.825rem; line-height: 1.55; color: var(--muted-foreground); margin-top: 0.2rem; }
+.journey-choice.is-selected { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 4%, var(--background)); }
+.journey-choice:focus-within { outline: 2px solid var(--ring); outline-offset: 2px; }
+.journey-actions { display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding-top: 1.25rem; border-top: 1px solid var(--border); }
+.journey-actions .journey-next { width: auto; min-width: 10rem; min-height: 44px; margin: 0; background: var(--primary); color: var(--primary-foreground); box-shadow: none; }
+.journey-actions .journey-next:hover { background: var(--color-teal-700); }
+.journey-back, .journey-cancel { padding: 0.65rem 1rem; border-radius: var(--radius-md); cursor: pointer; min-height: 44px; font-size: 0.9rem; color: var(--muted-foreground); }
+.journey-back { margin-right: auto; border: 1px solid var(--border); }
+.journey-back:hover, .journey-cancel:hover, .household-edit-link:hover { color: var(--primary); background: var(--muted); }
+.journey-back:focus-visible, .journey-cancel:focus-visible, .journey-progress button:focus-visible, .household-edit-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
+.journey-loading { display: flex; align-items: center; justify-content: center; gap: 0.6rem; min-height: 300px; color: var(--muted-foreground); }
+.household-summary { padding: 1.25rem 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin-bottom: 1.75rem; }
+.household-summary-primary { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; }
+.household-summary-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
+.household-summary-heading h2 { font-size: 0.8rem; font-weight: 500; color: var(--muted-foreground); }
+.household-summary-item p { font-size: 0.9rem; color: var(--foreground); }
+.household-summary-item .household-summary-secondary { font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.2rem; }
+.household-edit-link { color: var(--primary); font-size: 0.8rem; cursor: pointer; border-radius: var(--radius-sm); padding: 0.4rem 0.55rem; margin-right: -0.55rem; text-decoration: underline; text-underline-offset: 3px; }
+.household-summary-details { margin-top: 1rem; color: var(--muted-foreground); font-size: 0.8rem; }
+.household-summary-details summary { cursor: pointer; width: fit-content; padding: 0.3rem 0; }
+.household-summary-details dl { margin-top: 0.65rem; }
+.household-detail-row { display: grid; grid-template-columns: 11rem 1fr auto; gap: 1rem; align-items: baseline; border-top: 1px solid var(--border); padding: 0.5rem 0; }
+.household-detail-row dt { font-weight: 500; color: var(--foreground); }
+.household-detail-row dd { min-width: 0; }
+.results-workspace, .results-workspace-content { min-width: 0; }
+.results-workspace { scroll-margin-top: 74px; }
+.results-workspace .results { box-shadow: none; border: 0; border-radius: 0; background: transparent; }
+.impact-drivers { padding: 1.5rem; border-bottom: 1px solid var(--border); }
+.impact-drivers h3 { font-size: 1rem; font-weight: 600; color: var(--foreground); }
+.impact-drivers > p { font-size: 0.825rem; color: var(--muted-foreground); margin-top: 0.2rem; }
+.impact-drivers-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.5rem; list-style: none; padding: 0; margin-top: 1rem; }
+.impact-driver { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem 0.75rem; align-content: start; }
+.impact-driver-label { font-size: 0.875rem; font-weight: 500; }
+.impact-driver-value { font-size: 0.95rem; font-variant-numeric: tabular-nums; }
+.impact-driver-value.positive { color: var(--color-positive); }
+.impact-driver-value.negative { color: var(--color-negative); }
+.impact-driver-description { grid-column: 1 / -1; color: var(--muted-foreground); font-size: 0.8rem; }
+/* Keep modal layers above shared navigation (1000), with selects above editors. */
+.sf-select-content { z-index: 1102; }
+[data-slot="dialog-overlay"]:has(+ .household-edit-dialog) { z-index: 1100; }
+.household-edit-dialog { z-index: 1101; width: calc(100% - 2rem); max-width: 44rem !important; max-height: calc(100dvh - 2rem); overflow-y: auto; padding: 2rem; gap: 0.5rem; background: var(--background); color: var(--foreground); font-family: var(--font-sans); }
+.household-edit-title { font-size: 0.8rem; font-weight: 500; color: var(--muted-foreground); }
+.household-edit-description { font-size: 0.8rem; line-height: 1.5; }
+.household-edit-dialog .journey-form { margin-top: 1.25rem; }
+.household-edit-dialog .journey-step-title { font-size: 1.5rem; scroll-margin-top: 1rem; }
+@keyframes journey-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; }
+ .app-shell, .app.is-embedded .app-shell { padding: 1.25rem 1rem; }
+ .header-text h1 { font-size: 1.5rem; }
+ .header-text p { font-size: 0.875rem; }
+ .setup-workspace { margin-top: 0.5rem; }
+ .journey-progress ol { display: none; }
+ .journey-progress p { margin-bottom: 0; }
+ .journey-step-title { font-size: 1.45rem; }
+ .journey-people { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
+ .journey-form .sf-row { flex-direction: row; }
+ .journey-form .sf-year { flex-basis: 105px; }
+ .journey-actions .journey-next { min-width: 0; flex: 1; }
+ .household-summary-primary { grid-template-columns: 1fr; gap: 0.65rem; }
+ .household-detail-row { grid-template-columns: 1fr auto; gap: 0.1rem 0.75rem; }
+ .household-detail-row dt { grid-column: 1; }
+ .household-detail-row dd { grid-column: 1; }
+ .household-detail-row button { grid-column: 2; grid-row: 1 / span 2; }
+ .household-edit-dialog { padding: 1.5rem 1rem; }
+ .impact-drivers { padding: 1.25rem 0; }
+ .impact-drivers-list { grid-template-columns: 1fr; gap: 1rem; }
}
-
@media (prefers-reduced-motion: reduce) {
- .wizard-step { animation: none; }
- .wizard-progress li { transition: none; }
+ .journey-step { animation: none; }
+ .journey-choice, .journey-form button, .journey-form input { transition: none; }
}
diff --git a/lib/inputSections.js b/lib/inputSections.js
new file mode 100644
index 0000000..4834c1f
--- /dev/null
+++ b/lib/inputSections.js
@@ -0,0 +1,20 @@
+const SECTIONS = {
+ comparison: { id: "comparison", label: "Comparison", title: "What would you like to compare?", description: "Choose your location and the households to compare." },
+ household: { id: "household", label: "Household", title: "Who is in your household?", description: "Enter each adult’s annual wages before tax and add your children." },
+ circumstances: { id: "circumstances", label: "Health and care", title: "Do any of these apply?", description: "These details can affect taxes and benefit eligibility. Leave off anything that does not apply." },
+ work: { id: "work", label: "Work", title: "How many hours do you work?", description: "Enter your usual weekly hours, including zero if you do not work." },
+ childcare: { id: "childcare", label: "Childcare", title: "Do you pay for childcare?", description: "Enter annual costs before subsidies. Leave costs at zero if you do not pay for care." },
+ housing: { id: "housing", label: "Housing", title: "What are your housing costs?", description: "Your housing situation affects Universal Credit and resources after rent." },
+ finances: { id: "finances", label: "Other income and savings", title: "Do you have other income or savings?", description: "Enter annual income and your combined savings. Leave amounts at zero where they do not apply." },
+};
+
+export function getInputSections(country, data = {}) {
+ const sections = [SECTIONS.comparison, SECTIONS.household, SECTIONS.circumstances];
+ if (country.id === "us") sections.push(SECTIONS.work);
+ if ((data.children || []).length || Number(data.childcareCosts) > 0 || data.childcareCounty) sections.push(SECTIONS.childcare);
+ if (country.hasHousing) sections.push(SECTIONS.housing);
+ if (country.hasCapital || country.hasSelfEmployment || country.hasPensionIncome) sections.push(SECTIONS.finances);
+ return sections;
+}
+
+export function getInputSection(id) { return SECTIONS[id]; }
diff --git a/lib/resultDrivers.js b/lib/resultDrivers.js
new file mode 100644
index 0000000..0a4c967
--- /dev/null
+++ b/lib/resultDrivers.js
@@ -0,0 +1,51 @@
+import { computeTableData } from "./utils";
+
+const SUMMARY_DRIVER_KINDS = {
+ "Market income": "income",
+ Earnings: "income",
+ "Healthcare costs": "costs",
+ "Childcare costs": "costs",
+ "Housing costs": "costs",
+};
+
+function description(kind, effect) {
+ const increasesResources = effect > 0;
+ if (kind === "taxes") return increasesResources ? "Lower taxes" : "Higher taxes";
+ if (kind === "costs") return increasesResources ? "Lower costs" : "Higher costs";
+ if (kind === "credits") return increasesResources ? "More refundable credits" : "Fewer refundable credits";
+ if (kind === "benefits") return increasesResources ? "More benefit income" : "Less benefit income";
+ return increasesResources ? "More income" : "Less income";
+}
+
+// Reuse the table's program breakdown and residual rows so neither aggregate
+// totals nor state-credit aggregates are counted alongside their components.
+// rawDelta expresses the effect on resources: higher taxes/costs are negative.
+export function financialChangeDrivers(results, countryId, limit = 3) {
+ const options = { countryId, showHealth: false };
+ const drivers = ["benefits", "credits", "taxes"].flatMap(kind =>
+ computeTableData(results, kind, {
+ ...options,
+ // Financial benefits exclude services in both countries. UK currently
+ // has no separate healthcare series; using this branch keeps it explicit.
+ ...(kind === "benefits" ? { countryId: "us" } : {}),
+ })
+ .filter(row => !row.isTotal)
+ .map(row => ({ ...row, kind })),
+ );
+
+ for (const row of computeTableData(results, "summary", options)) {
+ const kind = SUMMARY_DRIVER_KINDS[row.program];
+ if (kind) drivers.push({ ...row, kind });
+ }
+
+ return drivers
+ .filter(row => Number.isFinite(row.rawDelta) && Math.round(row.rawDelta) !== 0)
+ .sort((a, b) => Math.abs(b.rawDelta) - Math.abs(a.rawDelta))
+ .slice(0, limit)
+ .map(row => ({
+ key: `${row.kind}:${row.program}`,
+ label: row.program,
+ effect: row.rawDelta,
+ description: description(row.kind, row.rawDelta),
+ }));
+}
diff --git a/tests/childcare-ui.test.jsx b/tests/childcare-ui.test.jsx
index f314e84..4415b5c 100644
--- a/tests/childcare-ui.test.jsx
+++ b/tests/childcare-ui.test.jsx
@@ -22,7 +22,7 @@ const baseData = {
ccdfSlotAvailable: true, childcareCounty: metadata.counties.CA[0].value,
childcareWorkHours: { head: 40, spouse: 0 }, includeHeadStart: true,
};
-const props = { country: COUNTRIES.us, countryId: "us", loading: false, onCalculate: vi.fn() };
+const props = { section: "childcare", country: COUNTRIES.us, countryId: "us", loading: false, onCalculate: vi.fn() };
const scrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeAll(() => { HTMLElement.prototype.scrollIntoView = vi.fn(); });
afterAll(() => { HTMLElement.prototype.scrollIntoView = scrollIntoView; });
@@ -34,96 +34,59 @@ beforeEach(() => {
});
afterEach(cleanup);
-function openDetails() { fireEvent.click(screen.getByText("More details")); }
+function openDetails() {}
async function choose(label, option) {
fireEvent.keyDown(screen.getByRole("combobox", { name: label }), { key: "ArrowDown" });
fireEvent.click(await screen.findByRole("option", { name: option }));
}
-it("uses one SSI disability control per person while keeping the existing API contract", () => {
+it("uses one SSI disability control per adult and preserves the existing API contract", () => {
const onCalculate = vi.fn();
- const onInputChange = vi.fn();
- render(
);
- openDetails();
+ render(
);
fireEvent.click(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }));
- fireEvent.click(screen.getByRole("checkbox", { name: "Child 1 meets SSI disability criteria" }));
expect(screen.queryByRole("checkbox", { name: "You disabled" })).toBeNull();
- expect(onInputChange).toHaveBeenCalled();
- fireEvent.click(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }));
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(onCalculate.mock.calls[0][0]).toMatchObject({ disabilityStatus: { head: true }, children: [{ isDisabled: true }] });
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ expect(onCalculate.mock.calls[0][0]).toMatchObject({ disabilityStatus: { head: true }, children: [{ isDisabled: false }], childcareWorkHours: { spouse: 0 } });
});
-it("keeps all childcare and SSI-specific controls out of the UK form", () => {
- render(
);
- openDetails();
+it("keeps US-specific controls out of UK circumstances", () => {
+ render(
);
expect(screen.getByRole("checkbox", { name: "You disabled" })).toBeTruthy();
- expect(screen.queryByText("Include Head Start service values")).toBeNull();
expect(screen.queryByRole("checkbox", { name: /SSI disability/ })).toBeNull();
+ expect(screen.queryByLabelText("Your work hours / week")).toBeNull();
});
-it("calculates with no children or costs and validates assistance inputs once paid care is entered", async () => {
+it("validates assistance inputs once paid care is entered", async () => {
const onCalculate = vi.fn();
- render(
);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate).toHaveBeenCalledOnce();
- openDetails();
- expect(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }).checked).toBe(true);
- fireEvent.click(screen.getByRole("button", { name: "Add child" }));
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(onCalculate).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole("checkbox", { name: "Assume a funded childcare slot" })).toBeNull();
fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "12000" } });
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(screen.getByRole("alert").textContent).toContain("Choose a county");
await choose("Childcare county", metadata.counties.CA[0].label);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(screen.getByRole("alert").textContent).toContain("provider type");
await choose("Child 1 provider type", metadata.states.CA.providers[0].options[0].label);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(onCalculate).toHaveBeenCalledTimes(3);
- expect(onCalculate.mock.calls[2][0]).toMatchObject({
- ccdfSlotAvailable: true, childcareCounty: metadata.counties.CA[0].value,
- childcareWorkHours: { head: 40, spouse: 40 },
- children: [{ childcareCost: 12000, childcareHoursPerDay: 8, childcareDaysPerWeek: 5, childcareDaysPerMonth: 22 }],
- });
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ expect(onCalculate).toHaveBeenCalledTimes(2);
+ expect(onCalculate.mock.calls[1][0]).toMatchObject({ ccdfSlotAvailable: true, childcareCounty: metadata.counties.CA[0].value, childcareWorkHours: { head: 40, spouse: 40 }, children: [{ childcareCost: 12000, childcareHoursPerDay: 8, childcareDaysPerWeek: 5, childcareDaysPerMonth: 22 }] });
});
-it("clears stale results when a child's care or enrollment changes and permits zero adult work hours", () => {
- const onInputChange = vi.fn();
+it("holds childcare edits in a draft until saving and preserves zero adult work hours", () => {
const onCalculate = vi.fn();
- render(
);
- openDetails();
+ render(
);
fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "10000" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Child 1 enrolled in Head Start or Early Head Start" }));
- expect(onInputChange).toHaveBeenCalledTimes(2);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(onCalculate).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0]).toMatchObject({ childcareWorkHours: { spouse: 0 }, children: [{ isHeadStartEnrolled: true }] });
});
-it("clears county and irrelevant provider selections when switching states", async () => {
- render(
);
- openDetails();
- await choose("State", "Colorado");
- expect(screen.getByRole("combobox", { name: "Childcare county" }).textContent).toBe("Choose a county");
- expect(screen.queryByRole("combobox", { name: "Child 1 provider type" })).toBeNull();
-});
-
-it("resets US childcare options when switching countries", () => {
- const { rerender } = render(
);
- rerender(
);
- rerender(
);
- openDetails();
- expect(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }).checked).toBe(true);
- expect(screen.queryByRole("checkbox", { name: "Include Head Start service values" })).toBeNull();
-});
-
it("explains separate Head Start service values without an accounting toggle", () => {
- const onCalculate = vi.fn();
- render(
);
- openDetails();
+ render(
);
expect(screen.queryByRole("checkbox", { name: "Include Head Start service values" })).toBeNull();
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(onCalculate.mock.calls[0][0]).toMatchObject({ ccdfSlotAvailable: true });
expect(screen.getByText(/service values appear separately from financial resources/)).toBeTruthy();
});
@@ -179,26 +142,23 @@ it.each(["MD", "MA"])("requires an explicit provider selection in %s instead of
});
it("labels Arkansas's schedule selection as care type", () => {
- render(
);
+ render(
);
openDetails();
expect(screen.getByRole("combobox", { name: "Child 1 care type" }).textContent).toBe("Choose a care type");
});
-it("shows Nevada's activity confirmation only in Nevada and clears it when leaving the state", async () => {
+it("shows Nevada activity confirmation only for funded paid care", () => {
const onCalculate = vi.fn();
- const onInputChange = vi.fn();
- render(
);
- openDetails();
+ render(
);
const option = screen.getByRole("checkbox", { name: "Meets childcare work or activity requirements" });
expect(option.checked).toBe(false);
fireEvent.click(option);
- expect(onInputChange).toHaveBeenCalledOnce();
- await choose("State", "California");
+ expect(option.checked).toBe(true);
+ fireEvent.click(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }));
expect(screen.queryByRole("checkbox", { name: "Meets childcare work or activity requirements" })).toBeNull();
- await choose("State", "Nevada");
- expect(screen.getByRole("checkbox", { name: "Meets childcare work or activity requirements" }).checked).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ expect(onCalculate.mock.calls[0][0].childcareActivityEligible).toBe(true);
});
it("round-trips Nevada's activity confirmation without applying it to other states", () => {
@@ -224,7 +184,7 @@ it("accepts editable monthly attendance independently of weekly attendance", ()
openDetails();
fireEvent.change(screen.getByLabelText("Child 1 annual childcare price ($)"), { target: { value: "10000" } });
fireEvent.change(screen.getByLabelText("Child 1 care days / month"), { target: { value: "17" } });
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0].children[0]).toMatchObject({ childcareDaysPerWeek: 5, childcareDaysPerMonth: 17 });
});
@@ -237,13 +197,12 @@ it("keeps paid costs, county, and work hours visible and preserved without a fun
fireEvent.click(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }));
expect(screen.getByRole("combobox", { name: "Childcare county" }).textContent).toBe(metadata.counties.CA[0].label);
expect(screen.getByLabelText("Child 1 annual childcare price ($)").value).toBe("10000");
- expect(screen.getByLabelText("Partner’s work hours / week").value).toBe("0");
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(screen.queryByLabelText("Partner’s work hours / week")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0]).toMatchObject({
ccdfSlotAvailable: false, childcareCounty: baseData.childcareCounty,
children: [{ childcareCost: 10000 }], childcareWorkHours: { spouse: 0 },
});
- expect(screen.getByText(/may affect other benefits as well as childcare assistance/)).toBeTruthy();
});
it("allows paid care with no county or provider when a funded slot is unavailable", () => {
@@ -252,16 +211,14 @@ it("allows paid care with no county or provider when a funded slot is unavailabl
expect(childCareFormError(data)).toBeNull();
const onCalculate = vi.fn();
render(
);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate).toHaveBeenCalledOnce();
});
-it("keeps a selected county and nondefault hours visible after all children are removed", () => {
- render(
);
- openDetails();
- fireEvent.click(screen.getByRole("button", { name: "Remove child 1" }));
+it("keeps a retained county editable without children", () => {
+ render(
);
expect(screen.getByRole("combobox", { name: "Childcare county" }).textContent).toBe(metadata.counties.CA[0].label);
- expect(screen.getByLabelText("Partner’s work hours / week").value).toBe("0");
+ expect(screen.queryByLabelText("Child 1 annual childcare price ($)")).toBeNull();
});
it("limits New York City county choices and validation to the five boroughs", async () => {
@@ -272,7 +229,7 @@ it("limits New York City county choices and validation to the five boroughs", as
const data = { ...baseData, regionCode: "NYC", childcareCounty: "ALBANY_COUNTY_NY", ccdfSlotAvailable: false };
expect(childCareFormError(data)).toContain("Choose a county");
expect(childCareFormError({ ...data, childcareCounty: "KINGS_COUNTY_NY" })).toBeNull();
- render(
);
+ render(
);
openDetails();
fireEvent.keyDown(screen.getByRole("combobox", { name: "Childcare county" }), { key: "ArrowDown" });
await screen.findByRole("option", { name: counties[0].label });
@@ -293,18 +250,15 @@ it("shares an unavailable funded slot while preserving care costs and accepts ol
});
-it("exposes both adults' work hours without children or paid care and preserves zero hours on submission", () => {
+it("exposes work hours independently of childcare and preserves zero on submission", () => {
const onCalculate = vi.fn();
- const onInputChange = vi.fn();
- render(
);
- openDetails();
+ render(
);
expect(screen.getByLabelText("Your work hours / week").value).toBe("40");
expect(screen.getByLabelText("Partner’s work hours / week").value).toBe("40");
- expect(screen.getByText(/may affect other benefits as well as childcare assistance/)).toBeTruthy();
expect(screen.queryByLabelText("Child 1 annual childcare price ($)")).toBeNull();
fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "0" } });
fireEvent.change(screen.getByLabelText("Partner’s work hours / week"), { target: { value: "0" } });
- expect(onInputChange).toHaveBeenCalledTimes(2);
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ expect(onCalculate).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0]).toMatchObject({ children: [], childcareWorkHours: { head: 0, spouse: 0 }, ccdfSlotAvailable: true });
});
diff --git a/tests/design.test.jsx b/tests/design.test.jsx
index 1f52f77..c65f380 100644
--- a/tests/design.test.jsx
+++ b/tests/design.test.jsx
@@ -141,59 +141,33 @@ describe("MetricCards", () => {
});
});
-// ---------- Country toggle & year select ----------
+// ---------- Focused form design ----------
-describe("Country toggle", () => {
- const countries = {
- us: { id: "us", name: "United States" },
- uk: { id: "uk", name: "United Kingdom" },
- };
- const country = {
- id: "us",
- currencySymbol: "$",
- defaultYear: "2026",
- availableYears: ["2024", "2025", "2026"],
- defaultAge: 40,
- defaultRegion: "CA",
- regions: [{ code: "CA", name: "California" }],
- regionLabel: "State",
- hasDisability: false,
- hasPregnancy: false,
- hasESI: false,
- };
-
- it("renders country toggle buttons", async () => {
+describe("Shared form controls", () => {
+ it("uses the shared country dropdown and commits only after saving", async () => {
const { default: InputForm } = await import("../app/components/InputForm.jsx");
- render(
{}} onCalculate={() => {}} loading={false} />);
-
- const toggle = document.querySelector(".country-toggle");
- expect(toggle).toBeTruthy();
- const buttons = toggle.querySelectorAll("button");
- expect(buttons.length).toBe(2);
- expect(buttons[0].textContent).toBe("United States");
- expect(buttons[1].textContent).toBe("United Kingdom");
- });
-
- it("active country has active class", async () => {
- const { default: InputForm } = await import("../app/components/InputForm.jsx");
- render( {}} onCalculate={() => {}} loading={false} />);
-
- const buttons = document.querySelectorAll(".country-toggle button");
- expect(buttons[0].className).toContain("active");
- expect(buttons[1].className).not.toContain("active");
+ const onCountryChange = vi.fn();
+ const onCalculate = vi.fn();
+ render( );
+ const country = screen.getByRole("combobox", { name: "Country" });
+ expect(country.getAttribute("data-slot")).toBe("select-trigger");
+ expect(country.textContent).toBe("United States");
+ fireEvent.keyDown(country, { key: "ArrowDown" });
+ fireEvent.click(await screen.findByRole("option", { name: "United Kingdom" }));
+ expect(onCountryChange).toHaveBeenCalledWith("uk");
+ expect(onCalculate).not.toHaveBeenCalled();
});
- it("uses the shared year dropdown and supports keyboard selection without submitting", async () => {
+ it("supports keyboard year selection without submitting", async () => {
const { default: InputForm } = await import("../app/components/InputForm.jsx");
const onCalculate = vi.fn();
- render( {}} onCalculate={onCalculate} loading={false} />);
-
+ const country = { ...COUNTRIES.us, availableYears: ["2024", "2025", "2026"] };
+ render( );
const yearSelect = screen.getByRole("combobox", { name: "Year" });
expect(yearSelect.getAttribute("data-slot")).toBe("select-trigger");
expect(yearSelect.textContent).toBe("2026");
fireEvent.keyDown(yearSelect, { key: "ArrowDown" });
const selectedOption = await screen.findByRole("option", { name: "2026" });
- expect(screen.getAllByRole("option")).toHaveLength(3);
await waitFor(() => expect(document.activeElement).toBe(selectedOption));
fireEvent.keyDown(selectedOption, { key: "ArrowUp" });
const previousYear = screen.getByRole("option", { name: "2025" });
@@ -201,49 +175,36 @@ describe("Country toggle", () => {
fireEvent.keyDown(previousYear, { key: "Enter" });
expect(yearSelect.textContent).toBe("2025");
expect(onCalculate).not.toHaveBeenCalled();
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0].year).toBe("2025");
});
- it("uses shared dropdowns for UK region, fiscal year, tenure, and rental market area", async () => {
+ it("uses UK fiscal years and shared housing controls in their own section", async () => {
const { default: InputForm } = await import("../app/components/InputForm.jsx");
const onCalculate = vi.fn();
- render( );
- fireEvent.click(screen.getByText("More details"));
- for (const name of [COUNTRIES.uk.regionLabel, "Year", "Tenure"]) {
- expect(screen.getByRole("combobox", { name }).getAttribute("data-slot")).toBe("select-trigger");
- }
+ const { unmount } = render( );
+ for (const name of ["UK nation", "Year"]) expect(screen.getByRole("combobox", { name }).getAttribute("data-slot")).toBe("select-trigger");
expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026-27");
+ unmount();
+ render( );
+ expect(screen.getByRole("combobox", { name: "Tenure" }).getAttribute("data-slot")).toBe("select-trigger");
fireEvent.keyDown(screen.getByRole("combobox", { name: "Tenure" }), { key: "ArrowDown" });
fireEvent.click(await screen.findByRole("option", { name: "Rented privately" }));
const area = screen.getByRole("combobox", { name: "Rental market area" });
expect(area.getAttribute("data-slot")).toBe("select-trigger");
fireEvent.keyDown(area, { key: "ArrowDown" });
fireEvent.click(await screen.findByRole("option", { name: UK_BRMAS[0].name }));
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(onCalculate.mock.calls[0][0]).toMatchObject({
- tenureType: "RENT_PRIVATELY", brma: UK_BRMAS[0].code, year: "2026",
- });
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ expect(onCalculate.mock.calls[0][0]).toMatchObject({ tenureType: "RENT_PRIVATELY", brma: UK_BRMAS[0].code, year: "2026" });
});
- it("renders a collapsed assumptions summary", async () => {
+ it("offers full assumptions at the final setup question", async () => {
const { default: InputForm } = await import("../app/components/InputForm.jsx");
- const assumptionCountry = {
- ...country,
- hasDisability: true,
- hasPregnancy: true,
- hasESI: true,
- };
- render( {}} onCalculate={() => {}} loading={false} />);
-
+ render( {}} />);
+ for (let i = 0; i < 3; i++) fireEvent.click(screen.getByRole("button", { name: "Continue", exact: true }));
const details = document.querySelector(".sf-assumptions");
- expect(details).toBeTruthy();
expect(details.open).toBe(false);
-
- const summary = within(details).getByText("Assumptions");
- expect(summary).toBeTruthy();
- fireEvent.click(summary);
-
+ fireEvent.click(within(details).getByText("Assumptions"));
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();
diff --git a/tests/guided-entry.test.jsx b/tests/guided-entry.test.jsx
index 5a6d8c1..15f1572 100644
--- a/tests/guided-entry.test.jsx
+++ b/tests/guided-entry.test.jsx
@@ -2,21 +2,21 @@
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 MarriageApp, { encodeToHash } 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.
+// Exercise the real shared form, dialog, and summary without network requests.
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 }) => (
+ default: ({ results, headIncome, spouseIncome, onCellClick }) => (
Calculated amount: {results.amount}
Submitted earnings: {headIncome} and {spouseIncome}
+ onCellClick(30000, 25000)}>Explore earnings of 30000 and 25000
),
}));
@@ -32,104 +32,103 @@ beforeEach(() => {
});
afterEach(cleanup);
-function progress() {
- return screen.getByRole("navigation", { name: "Setup progress" });
-}
-function continueSetup() {
+function next() {
fireEvent.click(screen.getByRole("button", { name: "Continue", exact: true }));
}
+function reachCalculation() {
+ for (let step = 0; step < 10; step += 1) {
+ if (screen.queryByRole("button", { name: "Calculate", exact: true })) return;
+ next();
+ }
+ throw new Error("Setup never reached its final calculation step.");
+}
function changeInput(name, value) {
const field = screen.getByLabelText(name, { exact: true });
fireEvent.change(field, { target: { value } });
fireEvent.blur(field);
}
-function reachDetails() {
- continueSetup();
- continueSetup();
+function summary() {
+ return screen.getByRole("region", { name: "Your household" });
+}
+async function edit(section = "household") {
+ fireEvent.click(screen.getByRole("button", { name: `Edit ${section}`, exact: true }));
+ return screen.findByRole("dialog", { name: "Edit your household" });
+}
+async function sharedResults(hash = "#country=us®ion=CA&head=15000&spouse=10000&year=2026&living=cohabiting") {
+ window.history.replaceState(null, "", hash);
+ render( );
+ await screen.findByRole("region", { name: "Calculation results" });
}
-it("starts a new visit with comparison, household, and details, without sending an early calculation", () => {
+it("keeps a new visit focused and sends no calculation while the user answers questions", () => {
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.getByRole("heading", { name: "What would you like to compare?" })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
- expect(getCategorizedPrograms).not.toHaveBeenCalled();
-
- continueSetup();
+ expect(screen.queryByRole("button", { name: "Use full form" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Calculate", exact: true })).toBeNull();
+ next();
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(screen.queryByRole("combobox", { name: "Year" })).toBeNull();
+ reachCalculation();
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 () => {
+it("retains answers through Back, then replaces setup with results and a household summary", async () => {
render( );
- continueSetup();
+ next();
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();
+ next();
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" }));
+ reachCalculation();
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");
+ const request = getCategorizedPrograms.mock.calls[0];
+ expect(request.slice(0, 4)).toEqual(["us", "CA", 21000, 9000]);
+ expect(request[4]).toEqual([expect.objectContaining({ age: 3, isDisabled: false })]);
+ expect(request[8]).toBe(37);
+ expect(request[12].livingArrangement).toBe("cohabiting");
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
- expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("21,000");
+ expect(screen.queryByRole("complementary")).toBeNull();
+ expect(summary().textContent).toContain("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) => {
+ ["#country=us®ion=CA&head=15000&spouse=10000&c=3:0&year=2026&living=cohabiting", "us", "cohabiting"],
+ ["#region=CA&head=15000&spouse=10000&c=3:0&year=2026", "us", "separate"],
+ ["#country=uk®ion=ENGLAND&head=15000&spouse=10000&year=2026", "uk", "separate"],
+])("opens shared results without mounting setup or the editing form: %s", async (hash, countryId, livingArrangement) => {
window.history.replaceState(null, "", hash);
render( );
await screen.findByRole("region", { name: "Calculation results" });
expect(screen.queryByRole("navigation", { name: "Setup progress" })).toBeNull();
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ expect(summary().textContent).toContain("15,000");
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ expect(getCategorizedPrograms.mock.calls[0][0]).toBe(countryId);
+ expect(getCategorizedPrograms.mock.calls[0][12].livingArrangement).toBe(livingArrangement);
+ await edit();
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", () => {
+it("uses a country-only UK link for setup with fiscal-year labels", () => {
window.history.replaceState(null, "", "#country=uk");
render( );
- expect(progress()).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "What would you like to compare?" })).toBeTruthy();
expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026-27");
- expect(screen.queryByRole("combobox", { name: "Unmarried living arrangement" })).toBeNull();
- continueSetup();
+ expect(screen.queryByRole("radio", { name: /^Living together/ })).toBeNull();
+ next();
expect(screen.getByRole("spinbutton", { name: "You age" }).value).toBe("35");
expect(getCategorizedPrograms).not.toHaveBeenCalled();
});
@@ -137,45 +136,149 @@ it("uses a country-only UK link to start guided setup with fiscal-year labels",
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) => {
+])("falls back to setup for an incomplete or malformed hash: %s", (hash) => {
window.history.replaceState(null, "", hash);
render( );
- expect(progress()).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "What would you like to compare?" })).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.each(["Cancel", "Escape"])("discards an earnings draft on %s and retains the committed results, summary, and URL", async (dismissal) => {
+ await sharedResults();
+ const hash = window.location.hash;
+ const committedSummary = summary().textContent;
+ const dialog = await edit();
+ changeInput("You income", "99000");
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ if (dismissal === "Cancel") fireEvent.click(within(dialog).getByRole("button", { name: "Cancel", exact: true }));
+ else fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: "Edit household", exact: true })));
+ expect(screen.getByText("Submitted earnings: 15000 and 10000")).toBeTruthy();
+ expect(summary().textContent).toBe(committedSummary);
+ expect(window.location.hash).toBe(hash);
+ await edit();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("15,000");
});
-it("shows a failed guided calculation in the editor and retries the same submitted household", async () => {
+it("discards a country change when the comparison editor is canceled", async () => {
+ await sharedResults();
+ const hash = window.location.hash;
+ const committedSummary = summary().textContent;
+ const dialog = await edit("comparison");
+ fireEvent.keyDown(within(dialog).getByRole("combobox", { name: "Country" }), { key: "ArrowDown" });
+ fireEvent.click(await screen.findByRole("option", { name: "United Kingdom" }));
+ expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026-27");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel", exact: true }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ expect(window.location.hash).toBe(hash);
+ expect(summary().textContent).toBe(committedSummary);
+ expect(screen.getByText("Submitted earnings: 15000 and 10000")).toBeTruthy();
+ await edit("comparison");
+ expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026");
+ expect(screen.getByRole("radio", { name: /^Living together/ })).toBeTruthy();
+});
+
+it.each([
+ ["us", "CA", "United Kingdom", "uk", "ENGLAND"],
+ ["uk", "ENGLAND", "United States", "us", "CA"],
+])("saves a country change from %s without silently changing either adult's age or earnings", async (oldCountry, oldRegion, choice, newCountry, newRegion) => {
+ await sharedResults(`#country=${oldCountry}®ion=${oldRegion}&head=15000&spouse=10000&ha=63&sa=61&year=2026`);
+ const dialog = await edit("comparison");
+ fireEvent.keyDown(within(dialog).getByRole("combobox", { name: "Country" }), { key: "ArrowDown" });
+ fireEvent.click(await screen.findByRole("option", { name: choice }));
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save changes", exact: true }));
+ await waitFor(() => expect(getCategorizedPrograms).toHaveBeenCalledTimes(2));
+ const request = getCategorizedPrograms.mock.calls[1];
+ expect(request.slice(0, 4)).toEqual([newCountry, newRegion, 15000, 10000]);
+ expect(request.slice(8, 10)).toEqual([63, 61]);
+ const hash = new URLSearchParams(window.location.hash.slice(1));
+ expect(hash.get("country")).toBe(newCountry);
+ expect(hash.get("ha")).toBe("63");
+ expect(hash.get("sa")).toBe("61");
+ await edit();
+ expect(screen.getByLabelText("You age").value).toBe("63");
+ expect(screen.getByLabelText("Your partner age").value).toBe("61");
+});
+
+it("saves changed earnings while preserving childcare, disability, and work answers", async () => {
+ const initial = {
+ regionCode: "CA", year: "2026", headIncome: 15000, spouseIncome: 10000,
+ headAge: 40, spouseAge: 39, livingArrangement: "cohabiting",
+ children: [{ age: 3, isDisabled: true, childcareCost: 4500, isHeadStartEnrolled: true,
+ childcareHoursPerDay: 6, childcareDaysPerWeek: 3, childcareDaysPerMonth: 12 }],
+ disabilityStatus: { head: true, spouse: false }, esiStatus: { head: true, spouse: false },
+ ccdfSlotAvailable: false, childcareWorkHours: { head: 25, spouse: 30 },
+ };
+ await sharedResults(`#${encodeToHash("us", initial, false)}`);
+ const first = getCategorizedPrograms.mock.calls[0];
+ const dialog = await edit();
+ expect(screen.queryByRole("combobox", { name: "Year" })).toBeNull();
+ changeInput("You income", "22000");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save changes", exact: true }));
+ await screen.findByText("Submitted earnings: 22000 and 10000");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("region", { name: "Comparison results" })));
+ expect(getCategorizedPrograms).toHaveBeenCalledTimes(2);
+ const saved = getCategorizedPrograms.mock.calls[1];
+ expect(saved.slice(0, 4)).toEqual(["us", "CA", 22000, 10000]);
+ // The form normalizes legacy omitted fields, but must retain every supplied answer.
+ expect(saved[4][0]).toMatchObject(first[4][0]);
+ expect(saved[5]).toEqual(first[5]);
+ expect(saved[7]).toEqual(first[7]);
+ expect(saved[10]).toEqual(first[10]);
+ expect(saved[12]).toMatchObject({ ccdfSlotAvailable: false, childcareWorkHours: { head: 25, spouse: 30 } });
+ expect(summary().textContent).toContain("22,000");
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("head")).toBe("22000");
+});
+
+it("opens and saves a relevant detail without exposing or changing other household inputs", async () => {
+ await sharedResults();
+ fireEvent.click(screen.getByText("Other household details", { exact: true }));
+ expect(screen.queryByRole("button", { name: "Edit childcare", exact: true })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Edit housing", exact: true })).toBeNull();
+ const dialog = await edit("health and disability");
+ expect(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }).checked).toBe(false);
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ expect(screen.queryByRole("combobox", { name: "Year" })).toBeNull();
+ fireEvent.click(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }));
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save changes", exact: true }));
+ await waitFor(() => expect(getCategorizedPrograms).toHaveBeenCalledTimes(2));
+ expect(getCategorizedPrograms.mock.calls[1].slice(0, 4)).toEqual(["us", "CA", 15000, 10000]);
+ expect(getCategorizedPrograms.mock.calls[1][5]).toEqual({ head: true, spouse: false });
+ expect(summary().textContent).toContain("You: SSI disability");
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("hd")).toBe("1");
+});
+
+it("uses grid-selected earnings in the summary and as the next edit's starting values", async () => {
+ await sharedResults();
+ fireEvent.click(screen.getByRole("button", { name: "Explore earnings of 30000 and 25000" }));
+ expect(summary().textContent).toContain("30,000");
+ expect(summary().textContent).toContain("25,000");
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("head")).toBe("30000");
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ await edit();
+ expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("30,000");
+ expect(screen.getByRole("textbox", { name: "Your partner income" }).value).toBe("25,000");
+});
+
+it("retains the submitted household after a failed setup calculation and retries that snapshot", async () => {
getCategorizedPrograms.mockRejectedValueOnce(new Error("Calculation service unavailable."));
render( );
- reachDetails();
- fireEvent.click(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }));
+ reachCalculation();
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];
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ expect(summary().textContent).toContain("45,000");
+ const first = 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(getCategorizedPrograms.mock.calls[1].slice(0, 12)).toEqual(first.slice(0, 12));
+ expect(getCategorizedPrograms.mock.calls[1][12].signal).not.toBe(first[12].signal);
expect(screen.queryByRole("alert")).toBeNull();
});
diff --git a/tests/guided-form.test.jsx b/tests/guided-form.test.jsx
index 11cbf6b..89d6788 100644
--- a/tests/guided-form.test.jsx
+++ b/tests/guided-form.test.jsx
@@ -1,177 +1,299 @@
/** @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 { afterEach, beforeAll, afterAll, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import InputForm from "../app/components/InputForm.jsx";
+import { getInputSections } from "../lib/inputSections.js";
import { COUNTRIES } from "../lib/countries.js";
+import metadata from "../lib/childcare-metadata.json";
+const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+beforeAll(() => { HTMLElement.prototype.scrollIntoView = vi.fn(); });
+afterAll(() => { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; });
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" }));
+const props = (overrides = {}) => ({ country: COUNTRIES.us, countryId: "us", loading: false, onCalculate: vi.fn(), ...overrides });
+const next = () => fireEvent.click(screen.getByRole("button", { name: "Continue", exact: true }));
+const save = () => fireEvent.click(screen.getByRole("button", { name: "Save changes", exact: true }));
+async function choose(label, option) {
+ fireEvent.keyDown(screen.getByRole("combobox", { name: label }), { key: "ArrowDown" });
+ fireEvent.click(await screen.findByRole("option", { name: option }));
}
-describe("guided household form", () => {
- it("advances form submission through focused steps without calculating early", () => {
+describe("focused household journey", () => {
+ it("uses four childless US questions, with work separate from health and no full form escape", () => {
const formProps = props();
const { container } = render( );
- expect(screen.getByRole("heading", { name: "Choose your comparison" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "What would you like to compare?" })).toBeTruthy();
expect(screen.getByRole("combobox", { name: "Year" }).textContent).toBe("2026");
- expect(screen.getByText(/sharing a home, food and resources/)).toBeTruthy();
+ expect(screen.getByRole("radio", { name: /^Living together/ }).checked).toBe(true);
+ expect(screen.getByText("Step 1 of 4")).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(document.activeElement).toBe(screen.getByRole("heading", { name: "Who is in 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" }));
+ next();
expect(screen.getByRole("checkbox", { name: "You meets SSI disability criteria" }).checked).toBe(false);
+ expect(screen.queryByLabelText("Your work hours / week")).toBeNull();
+ next();
+ expect(screen.queryByRole("checkbox", { name: /SSI disability/ })).toBeNull();
expect(screen.getByLabelText("Your work hours / week").value).toBe("40");
expect(screen.queryByRole("checkbox", { name: "Assume a funded childcare slot" })).toBeNull();
+ expect(screen.queryByText("Use full form")).toBeNull();
+ expect(screen.queryByText("More details")).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,
- });
+ 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( );
+ it("retains answers on Back and reveals an additional childcare question after adding a child", () => {
+ 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();
+ expect(screen.getByText("Step 2 of 5")).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 }] });
+ next(); next(); next();
+ expect(screen.getByRole("heading", { name: "Do you pay for childcare?" })).toBeTruthy();
+ expect(screen.queryByLabelText("Your work hours / week")).toBeNull();
});
- 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();
+ it.each(["", "17", "101", "35.5"])("blocks invalid adult age %j without silently replacing it", (value) => {
+ const formProps = props({ section: "household" });
+ render( );
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();
+ save();
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();
+ save();
+ expect(formProps.onCalculate.mock.calls[0][0].headAge).toBe(18);
});
- it("requires a valid child age while accepting a newborn", () => {
+ it.each(["", "-1", "19", "2.5"])("requires valid whole-year child age %j and accepts a newborn", (value) => {
render( );
next();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
- fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "" } });
+ 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();
+ expect(screen.getByRole("heading", { name: "Do any of these apply?" })).toBeTruthy();
});
- it("focuses childcare validation in the visible final step and allows correction", () => {
- const formProps = props({ initialValues: { children: [{ age: 3 }] } });
+ it("opens exactly one edit topic, preserves unrelated fields, and lets Cancel bypass validation", () => {
+ const formProps = props({ section: "work", onCancel: vi.fn(), initialValues: { headIncome: 18000, headAge: 55, disabilityStatus: { head: true }, children: [{ age: 2 }] } });
render( );
+ expect(screen.queryByLabelText("You income")).toBeNull();
+ expect(screen.queryByLabelText("Child 1 age")).toBeNull();
+ fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "169" } });
+ save();
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(formProps.onCancel).toHaveBeenCalledOnce();
+ fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "0" } });
+ save();
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ headIncome: 18000, headAge: 55, disabilityStatus: { head: true }, children: [{ age: 2 }], childcareWorkHours: { head: 0 } });
+ });
+
+ it("routes a state edit with retained paid care to visible childcare follow-ups", async () => {
+ const formProps = props({ section: "comparison", initialValues: { regionCode: "CA", childcareCounty: metadata.counties.CA[0].value, children: [{ age: 3, childcareCost: 12000 }] } });
+ render( );
+ await choose("State", "Colorado");
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(screen.getByRole("heading", { name: "Do you pay for childcare?" })).toBeTruthy();
+ expect(screen.getByLabelText("Child 1 annual childcare price ($)").value).toBe("12000");
+ expect(screen.getByRole("combobox", { name: "Childcare county" }).textContent).toBe("Choose a county");
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 }] });
+ save();
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ regionCode: "CO", childcareCounty: "", children: [{ childcareCost: 12000 }] });
});
- it("reveals childcare follow-ups only when paid care is entered", () => {
- render( );
- next();
+ it("routes added children to care questions before committing a household edit", () => {
+ const formProps = props({ section: "household", initialValues: { children: [] } });
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
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(formProps.onCalculate).not.toHaveBeenCalled();
+ save();
+ expect(formProps.onCalculate.mock.calls[0][0].children).toHaveLength(1);
+ });
+
+ it("reveals paid-care inputs only after a cost is entered and focuses visible validation", () => {
+ const formProps = props({ section: "childcare", initialValues: { children: [{ age: 3 }] } });
+ render( );
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();
+ save();
+ expect(screen.getByRole("alert").textContent).toContain("Choose a county");
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
});
- 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 } });
+ it("separates UK health, housing and other finances and omits 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");
+ next(); next();
+ expect(screen.getByRole("checkbox", { name: "You disabled" })).toBeTruthy();
+ expect(screen.queryByRole("textbox", { name: "Savings" })).toBeNull();
+ expect(screen.queryByLabelText("Your work hours / week")).toBeNull();
next();
+ expect(screen.getByRole("combobox", { name: "Tenure" }).textContent).toBe("Owned outright");
+ expect(screen.queryByRole("textbox", { name: "You self-employment income" })).toBeNull();
next();
- const input = screen.getByLabelText(label);
- fireEvent.change(input, { target: { value } });
- expect(input.validity.rangeOverflow).toBe(true);
+ expect(screen.getByRole("textbox", { name: "Savings" }).value).toBe("0");
+ expect(screen.getByRole("textbox", { name: "You self-employment income" }).value).toBe("0");
fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- expect(formProps.onCalculate).not.toHaveBeenCalled();
- expect(screen.getByRole("heading", { name: "Add relevant details" })).toBeTruthy();
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ livingArrangement: "separate", rent: 0, savings: 0 });
+ });
+
+ it("preserves supplied adult ages when editing the country", () => {
+ const formProps = props({ section: "comparison", initialValues: { headAge: 63, spouseAge: 61 } });
+ const { rerender } = render( );
+ rerender( );
+ save();
+ expect(formProps.onCalculate.mock.calls[0][0]).toMatchObject({ headAge: 63, spouseAge: 61, regionCode: COUNTRIES.uk.defaultRegion });
});
- it("focuses the current step when returning from the full form", () => {
+ it("uses country age defaults only before household answers have been entered", () => {
const formProps = props();
const { rerender } = render( );
+ rerender( );
next();
- rerender( );
- rerender( );
- expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Describe your household" }));
+ expect(screen.getByLabelText("You age").value).toBe("35");
+ fireEvent.change(screen.getByLabelText("You age"), { target: { value: "63" } });
+ fireEvent.change(screen.getByLabelText("Your partner age"), { target: { value: "61" } });
+ fireEvent.click(screen.getByRole("button", { name: "Back" }));
+ rerender( );
+ next();
+ expect(screen.getByLabelText("You age").value).toBe("63");
+ expect(screen.getByLabelText("Your partner age").value).toBe("61");
});
- 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();
+ it("scrolls to setup progress only after a step change and never scrolls editor mounts or follow-ups", () => {
+ HTMLElement.prototype.scrollIntoView.mockClear();
+ const { container, unmount } = render( );
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
next();
- expect(screen.getByRole("spinbutton", { name: "You age" }).value).toBe("35");
+ expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce();
+ expect(HTMLElement.prototype.scrollIntoView.mock.instances[0]).toBe(container.querySelector("form"));
+ unmount();
+ HTMLElement.prototype.scrollIntoView.mockClear();
+ render( );
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Add child" }));
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 });
+ expect(screen.getByRole("heading", { name: "Do you pay for childcare?" })).toBeTruthy();
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+ });
+
+ it.each(["", "17", "101", "35.5"])("revalidates revisited adult age %j after a progress jump to Calculate", (value) => {
+ const formProps = props();
+ render( );
+ next(); next(); next();
+ const navigation = within(screen.getByRole("navigation", { name: "Setup progress" }));
+ fireEvent.click(navigation.getByRole("button", { name: "Household", exact: true }));
+ fireEvent.change(screen.getByLabelText("You age"), { target: { value } });
+ fireEvent.click(navigation.getByRole("button", { name: "Work", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: "Who is in your household?" })).toBeTruthy();
+ expect(screen.getByLabelText("You age").value).toBe(value);
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ fireEvent.change(screen.getByLabelText("You age"), { target: { value: "63" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Work", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate.mock.calls[0][0].headAge).toBe(63);
+ });
+
+ it("revalidates a revisited child age after jumping straight to Childcare", () => {
+ const formProps = props({ initialValues: { children: [{ age: 3 }] } });
+ render( );
+ next(); next(); next(); next();
+ const navigation = within(screen.getByRole("navigation", { name: "Setup progress" }));
+ fireEvent.click(navigation.getByRole("button", { name: "Household", exact: true }));
+ fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "-1" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Childcare", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Child 1 age").value).toBe("-1");
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ fireEvent.change(screen.getByLabelText("Child 1 age"), { target: { value: "0" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Childcare", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate.mock.calls[0][0].children[0].age).toBe(0);
+ });
+
+ it("rejects blank revisited work hours after jumping to Childcare and accepts explicit zero", () => {
+ const formProps = props({ initialValues: { children: [{ age: 3 }] } });
+ render( );
+ next(); next(); next(); next();
+ const navigation = within(screen.getByRole("navigation", { name: "Setup progress" }));
+ fireEvent.click(navigation.getByRole("button", { name: "Work", exact: true }));
+ fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Childcare", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: "How many hours do you work?" })).toBeTruthy();
+ expect(screen.getByLabelText("Your work hours / week").value).toBe("");
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ fireEvent.change(screen.getByLabelText("Your work hours / week"), { target: { value: "0" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Childcare", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate.mock.calls[0][0].childcareWorkHours.head).toBe(0);
+ });
+
+ it("revalidates both UK adults before committing from Other income and savings", () => {
+ const formProps = props({ country: COUNTRIES.uk, countryId: "uk" });
+ render( );
+ next(); next(); next(); next();
+ const navigation = within(screen.getByRole("navigation", { name: "Setup progress" }));
+ fireEvent.click(navigation.getByRole("button", { name: "Household", exact: true }));
+ fireEvent.change(screen.getByLabelText("Your partner age"), { target: { value: "101" } });
+ fireEvent.click(navigation.getByRole("button", { name: "Other income and savings", exact: true }));
+ fireEvent.click(screen.getByRole("button", { name: "Calculate", exact: true }));
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Your partner age").value).toBe("101");
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ });
+
+ it.each([
+ [{ headAge: 0, children: [] }, "Who is in your household?", "You age", "64", "headAge"],
+ [{ childcareWorkHours: { head: "", spouse: 40 } }, "How many hours do you work?", "Your work hours / week", "0", "childcareWorkHours"],
+ ])("checks hidden raw answers on section Save and preserves return to the edited topic", (initialValues, heading, label, correction, field) => {
+ const formProps = props({ section: "circumstances", initialValues });
+ render( );
+ save();
+ expect(formProps.onCalculate).not.toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: heading })).toBeTruthy();
+ expect(document.activeElement).toBe(screen.getByRole("alert"));
+ fireEvent.click(screen.getByRole("button", { name: "Back", exact: true }));
+ expect(screen.getByRole("heading", { name: "Do any of these apply?" })).toBeTruthy();
+ save();
+ fireEvent.change(screen.getByLabelText(label), { target: { value: correction } });
+ save();
+ expect(formProps.onCalculate).toHaveBeenCalledOnce();
+ expect(formProps.onCalculate.mock.calls[0][0][field]).toEqual(field === "headAge" ? 64 : { head: 0, spouse: 40 });
+ });
+
+ it("keeps retained county and UK care costs discoverable without children", () => {
+ expect(getInputSections(COUNTRIES.us, { childcareCounty: "LOS_ANGELES_COUNTY_CA" }).map((item) => item.id)).toContain("childcare");
+ expect(getInputSections(COUNTRIES.uk, { childcareCosts: 1200 }).map((item) => item.id)).toContain("childcare");
+ expect(getInputSections(COUNTRIES.uk, {}).map((item) => item.id)).toEqual(["comparison", "household", "circumstances", "housing", "finances"]);
});
});
diff --git a/tests/living-arrangement-ui.test.jsx b/tests/living-arrangement-ui.test.jsx
index 424f742..d0c895e 100644
--- a/tests/living-arrangement-ui.test.jsx
+++ b/tests/living-arrangement-ui.test.jsx
@@ -39,9 +39,8 @@ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeAll(() => { HTMLElement.prototype.scrollIntoView = vi.fn(); });
afterAll(() => { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; });
-async function selectLivingSeparately() {
- fireEvent.keyDown(screen.getByRole("combobox", { name: "Unmarried living arrangement" }), { key: "ArrowDown" });
- fireEvent.click(await screen.findByRole("option", { name: "Living separately" }));
+function selectLivingSeparately() {
+ fireEvent.click(screen.getByRole("radio", { name: /^Living separately/ }));
}
beforeEach(() => {
@@ -72,62 +71,53 @@ describe("comparison share links", () => {
expect(new URLSearchParams(hash).get("year")).toBe("2026");
});
- it("hydrates the form and API request from a legacy link", async () => {
+ it("hydrates results and the focused comparison editor from a legacy link", async () => {
getCategorizedPrograms.mockResolvedValue(separateResults);
window.history.replaceState(null, "", "#region=CA&head=20000&spouse=15000");
render( );
await screen.findByRole("heading", { name: "Effect of marrying and combining households" });
- expect(screen.getByLabelText("Unmarried living arrangement").textContent).toBe("Living separately");
- expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("20,000");
+ expect(screen.queryByRole("textbox", { name: "You income" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Edit comparison" }));
+ expect((await screen.findByRole("radio", { name: /^Living separately/ })).checked).toBe(true);
expect(getCategorizedPrograms.mock.calls[0][12].livingArrangement).toBe("separate");
expect(new URLSearchParams(window.location.hash.slice(1)).get("living")).toBe("separate");
});
});
describe("living arrangement form", () => {
- const baseProps = { country: COUNTRIES.us, countryId: "us", onCalculate: vi.fn(), loading: false };
+ const baseProps = { country: COUNTRIES.us, countryId: "us", section: "comparison", onCalculate: vi.fn(), loading: false };
- it("defaults new US visits to living together and sends the selected comparison", async () => {
+ it("defaults a new US comparison to living together and submits the selected baseline", () => {
const onCalculate = vi.fn();
- const onInputChange = vi.fn();
- render( );
- const choice = screen.getByLabelText("Unmarried living arrangement");
- expect(choice.textContent).toBe("Living together");
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ render( );
+ expect(screen.getByRole("radio", { name: /^Living together/ }).checked).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[0][0].livingArrangement).toBe("cohabiting");
- await selectLivingSeparately();
- expect(onInputChange).toHaveBeenCalledOnce();
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
+ selectLivingSeparately();
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onCalculate.mock.calls[1][0].livingArrangement).toBe("separate");
});
it("keeps the UK comparison unchanged", () => {
render( );
- expect(screen.queryByLabelText("Unmarried living arrangement")).toBeNull();
- expect(screen.getByText(/This compares a cohabiting couple with two separate households/)).toBeTruthy();
+ expect(screen.queryByRole("radio", { name: /^Living together/ })).toBeNull();
+ expect(screen.getByText(/UK benefits generally assess couples who live together/)).toBeTruthy();
});
- it("keeps heatmap results when income fields sync, but clears them for subsequent manual edits", () => {
- const onInputChange = vi.fn();
- const props = { ...baseProps, onInputChange };
- const { rerender } = render( );
- rerender( );
- expect(screen.getByRole("textbox", { name: "You income" }).value).toBe("30,000");
- expect(onInputChange).not.toHaveBeenCalled();
- fireEvent.change(screen.getByRole("textbox", { name: "You income" }), { target: { value: "31000" } });
- expect(onInputChange).toHaveBeenCalledOnce();
- });
-
- it("ignores an old response when the user changes the comparison during calculation", async () => {
+ it("ignores an old response after the user saves a different comparison", async () => {
let resolve;
getCategorizedPrograms.mockImplementationOnce(() => new Promise((r) => { resolve = r; }));
+ getCategorizedPrograms.mockResolvedValueOnce(separateResults);
+ window.history.replaceState(null, "", "#country=us®ion=CA&head=20000&spouse=15000&living=cohabiting");
render( );
- fireEvent.click(screen.getByRole("button", { name: "Use full form" }));
- fireEvent.click(screen.getByRole("button", { name: "Calculate" }));
- await selectLivingSeparately();
+ fireEvent.click(await screen.findByRole("button", { name: "Edit comparison" }));
+ selectLivingSeparately();
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ await screen.findByRole("heading", { name: "Effect of marrying and combining households" });
await act(async () => { resolve(cohabitingResults); });
- expect(screen.queryByTestId("metric-net")).toBeNull();
- expect(getHeatmapData).not.toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: "Effect of marrying and combining households" })).toBeTruthy();
+ expect(getCategorizedPrograms.mock.calls[1][12].livingArrangement).toBe("separate");
+ expect(getHeatmapData).toHaveBeenCalledOnce();
});
});
diff --git a/tests/request-recovery.test.jsx b/tests/request-recovery.test.jsx
index 6370bd6..738c626 100644
--- a/tests/request-recovery.test.jsx
+++ b/tests/request-recovery.test.jsx
@@ -1,16 +1,18 @@
/** @vitest-environment jsdom */
import React from "react";
-import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import MarriageApp from "../app/MarriageApp.jsx";
import { getCategorizedPrograms, getHeatmapData } from "../lib/api.js";
let submitted;
-vi.mock("../lib/api.js", () => ({ getCategorizedPrograms: vi.fn(), getHeatmapData: vi.fn() }));
+vi.mock("../lib/api.js", async (importOriginal) => ({
+ ...await importOriginal(), getCategorizedPrograms: vi.fn(), getHeatmapData: vi.fn(),
+}));
vi.mock("../app/components/InputForm.jsx", () => ({
- default: ({ onCalculate, onInputChange, onCountryChange }) =>
-
onCalculate(submitted)}>Submit inputs
-
Change inputs
+ default: ({ onCalculate, onCancel, onCountryChange, section }) =>
+ onCalculate(submitted)}>{section ? "Save changes" : "Submit inputs"}
+ {section && Cancel }
onCountryChange("uk")}>Switch country
,
}));
@@ -27,6 +29,10 @@ const deferred = () => {
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
+async function edit(section = "household") {
+ fireEvent.click(screen.getByRole("button", { name: `Edit ${section}`, exact: true }));
+ return screen.findByRole("dialog", { name: "Edit your household" });
+}
beforeEach(() => {
vi.resetAllMocks();
@@ -74,47 +80,76 @@ it("preserves scalar results after a heatmap failure and retries only the heatma
expect(screen.queryByRole("alert")).toBeNull();
});
-it("aborts a scalar request on input changes and ignores its late result", async () => {
+it("lets an active calculation finish while a draft editor is opened and canceled", async () => {
const pending = deferred();
getCategorizedPrograms.mockReturnValueOnce(pending.promise);
render(
);
fireEvent.click(screen.getByRole("button", { name: "Submit inputs" }));
const signal = getCategorizedPrograms.mock.calls[0][12].signal;
- fireEvent.click(screen.getByRole("button", { name: "Change inputs" }));
- expect(signal.aborted).toBe(true);
+ const dialog = await edit();
+ submitted = { ...submitted, headIncome: 90000 };
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel", exact: true }));
+ expect(signal.aborted).toBe(false);
await act(async () => pending.resolve({ amount: 111 }));
- expect(screen.queryByText("Calculated amount: 111")).toBeNull();
- expect(getHeatmapData).not.toHaveBeenCalled();
+ await screen.findByText("Calculated amount: 111");
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ expect(getHeatmapData.mock.calls[0][6]).toBe(20000);
expect(screen.queryByRole("alert")).toBeNull();
});
-it("cancels a pending heatmap when the country changes without showing an obsolete error", async () => {
+it("does not cancel a heatmap for a discarded country draft", async () => {
+ const pending = deferred();
+ getHeatmapData.mockReturnValueOnce(pending.promise);
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: "Submit inputs" }));
+ await waitFor(() => expect(getHeatmapData).toHaveBeenCalledOnce());
+ const signal = getHeatmapData.mock.calls[0][12].signal;
+ const dialog = await edit("comparison");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Switch country" }));
+ expect(signal.aborted).toBe(false);
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel", exact: true }));
+ await act(async () => pending.resolve({ ready: true }));
+ await screen.findByText("Heatmap ready");
+ expect(screen.getByText("Calculated amount: 35000")).toBeTruthy();
+ expect(getCategorizedPrograms).toHaveBeenCalledOnce();
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("country")).toBe("us");
+});
+
+it("cancels a pending heatmap only when the new country is saved and ignores the obsolete error", async () => {
const pending = deferred();
getHeatmapData.mockReturnValueOnce(pending.promise);
render(
);
fireEvent.click(screen.getByRole("button", { name: "Submit inputs" }));
await waitFor(() => expect(getHeatmapData).toHaveBeenCalledOnce());
const signal = getHeatmapData.mock.calls[0][12].signal;
- fireEvent.click(screen.getByRole("button", { name: "Switch country" }));
+ const dialog = await edit("comparison");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Switch country" }));
+ submitted = { ...submitted, regionCode: "ENGLAND", livingArrangement: "separate" };
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save changes", exact: true }));
expect(signal.aborted).toBe(true);
+ await waitFor(() => expect(getCategorizedPrograms).toHaveBeenCalledTimes(2));
+ expect(getCategorizedPrograms.mock.calls[1].slice(0, 2)).toEqual(["uk", "ENGLAND"]);
await act(async () => pending.reject(new DOMException("Cancelled", "AbortError")));
expect(screen.queryByRole("alert")).toBeNull();
- expect(screen.queryByText("Calculated amount: 35000")).toBeNull();
+ expect(screen.getByText("Calculated amount: 35000")).toBeTruthy();
+ expect(new URLSearchParams(window.location.hash.slice(1)).get("country")).toBe("uk");
});
-it("aborts an earlier submission and does not replace a newer result with its late failure", async () => {
+it("aborts an earlier submission on Save and does not replace a newer result with its late failure", async () => {
const pending = deferred();
getCategorizedPrograms.mockReturnValueOnce(pending.promise);
render(
);
fireEvent.click(screen.getByRole("button", { name: "Submit inputs" }));
const firstSignal = getCategorizedPrograms.mock.calls[0][12].signal;
+ const dialog = await edit();
submitted = { ...submitted, headIncome: 50000 };
- fireEvent.click(screen.getByRole("button", { name: "Submit inputs" }));
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save changes", exact: true }));
expect(firstSignal.aborted).toBe(true);
await screen.findByText("Calculated amount: 35000");
await act(async () => pending.reject(new Error("Old response")));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("Calculated amount: 35000")).toBeTruthy();
+ expect(getCategorizedPrograms.mock.calls[1][2]).toBe(50000);
});
it("cancels the active request when the app unmounts", () => {
diff --git a/tests/result-drivers.test.jsx b/tests/result-drivers.test.jsx
new file mode 100644
index 0000000..13e48fc
--- /dev/null
+++ b/tests/result-drivers.test.jsx
@@ -0,0 +1,127 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import ResultsDisplay from "../app/components/ResultsDisplay.jsx";
+import ImpactDrivers from "../app/components/ImpactDrivers.jsx";
+import { COUNTRIES } from "../lib/countries";
+
+vi.mock("../app/components/Heatmap.jsx", () => ({
+ default: props =>
props.onCellClick(0, 1)}>Select another income combination ,
+}));
+
+afterEach(cleanup);
+
+function scenario({ market = 90000, tax = 0, benefit = 0, credit = 0, healthCost = 0, careCost = 0, rent = 0, health = 0, education = 0, benefitName = "snap", taxName = "income_tax_before_refundable_credits" } = {}) {
+ const financial = market + benefit + credit - tax - healthCost - careCost - rent;
+ return {
+ aggregates: {
+ marketIncome: market,
+ financialResources: financial,
+ householdNetIncome: financial,
+ householdNetIncomeWithHealth: financial + health,
+ householdBenefits: benefit,
+ householdRefundableCredits: credit,
+ householdTaxBeforeCredits: tax,
+ healthcareBenefitValue: health,
+ earlyEducationServiceValue: education,
+ healthCosts: healthCost,
+ childcareCostDeducted: careCost,
+ rentDeducted: rent,
+ },
+ benefits: { [benefitName]: benefit, head_start: education },
+ credits: { eitc: credit },
+ taxes: { [taxName]: tax },
+ stateCredits: {}, stateTaxes: {}, health: { medicaid_cost: health },
+ };
+}
+
+function drivers() {
+ return within(screen.getByRole("region", { name: "What changes" }));
+}
+
+describe("financial change explanations", () => {
+ it("explains tax increases and benefit losses as reduced resources, ranks the largest changes, and excludes service values", () => {
+ const results = {
+ unmarried: scenario({ tax: 10000, benefit: 2000, credit: 1000, healthCost: 4000, health: 50000, education: 30000 }),
+ married: scenario({ tax: 13000, benefit: 500, credit: 1500, healthCost: 5000 }),
+ };
+ render(
);
+ const items = drivers().getAllByRole("listitem");
+ expect(items).toHaveLength(3);
+ expect(items[0].textContent).toContain("-$3,000Higher taxes");
+ expect(items[1].textContent).toBe("SNAP-$1,500Less benefit income");
+ expect(items[2].textContent).toBe("Healthcare costs-$1,000Higher costs");
+ expect(drivers().queryByText(/Medicaid|Head Start|service value/)).toBeNull();
+ expect(drivers().getByText("Largest changes in annual financial resources.")).toBeTruthy();
+ });
+
+ it("shows lower taxes, higher credits, and reduced childcare costs as gains", () => {
+ const results = {
+ unmarried: scenario({ tax: 4000, credit: 500, careCost: 6000 }),
+ married: scenario({ tax: 1000, credit: 2500, careCost: 5000 }),
+ };
+ render(
);
+ expect(drivers().getAllByRole("listitem").map(item => item.textContent)).toEqual([
+ "Income tax before refundable credits+$3,000Lower taxes",
+ "EITC+$2,000More refundable credits",
+ "Childcare costs+$1,000Lower costs",
+ ]);
+ });
+
+ it("omits an empty explanation but keeps offsetting changes when the net effect is zero", () => {
+ const same = scenario({ tax: 5000 });
+ const { rerender } = render(
);
+ expect(screen.queryByRole("region", { name: "What changes" })).toBeNull();
+ rerender(
);
+ const copy = drivers().getAllByRole("listitem").map(item => item.textContent).join(" ");
+ expect(copy).toContain("+$1,000More benefit income");
+ expect(copy).toContain("-$1,000Higher taxes");
+ });
+
+ it("combines separate UK households and explains housing savings using pounds", () => {
+ const results = {
+ headSingle: scenario({ market: 20000, benefit: 5000, rent: 6000, benefitName: "universal_credit", taxName: "income_tax" }),
+ spouseSingle: scenario({ market: 10000, benefit: 2000, rent: 6000, benefitName: "universal_credit", taxName: "income_tax" }),
+ married: scenario({ market: 30000, benefit: 3000, rent: 6000, benefitName: "universal_credit", taxName: "income_tax" }),
+ };
+ render(
);
+ expect(drivers().getAllByRole("listitem").map(item => item.textContent)).toEqual([
+ "Housing costs+£6,000Lower costs",
+ "Universal credit-£4,000Less benefit income",
+ ]);
+ expect(drivers().getByText("Largest changes in annual net income.")).toBeTruthy();
+ });
+
+ it("updates the explanation with the same real grid projection used by the financial headline", async () => {
+ const results = {
+ unmarried: scenario({ tax: 10000 }),
+ married: scenario({ tax: 12000 }),
+ };
+ const series = (married, unmarried) => ({ married: [0, married, 0, 0], unmarried: [0, unmarried, 0, 0] });
+ const heatmapData = {
+ count: 2, maxIncome: 10000,
+ grids: { "net income": [[0, -5000], [0, 0]] },
+ programData: {
+ financial_resources: series(43000, 48000),
+ household_net_income: series(43000, 48000),
+ household_market_income: series(50000, 50000),
+ household_benefits: series(1000, 8000),
+ household_tax_before_refundable_credits: series(8000, 10000),
+ snap: series(1000, 8000),
+ income_tax_before_refundable_credits: series(8000, 10000),
+ },
+ };
+ const onCellClick = vi.fn();
+ render(
);
+ expect(drivers().getAllByRole("listitem")[0].textContent).toContain("-$2,000Higher taxes");
+ fireEvent.click(screen.getByRole("button", { name: "Grid", exact: true }));
+ fireEvent.click(await screen.findByRole("button", { name: "Select another income combination" }));
+ expect(onCellClick).toHaveBeenCalledWith(0, 10000);
+ expect(screen.getByTestId("metric-delta").textContent).toContain("-$5,000");
+ expect(drivers().getAllByRole("listitem").map(item => item.textContent)).toEqual([
+ "SNAP-$7,000Less benefit income",
+ "Income tax before refundable credits+$2,000Lower taxes",
+ ]);
+ });
+});