N400 Calculator

Every rule this calculator applies is checked against the cases below before it ships. Most of them are worked examples USCIS itself published, not ones we made up. Pick a case, work the dates out by hand, and check your answer against what is printed here.

Golden test cases — ground truth

These are not tests you wrote. They are USCIS’s own worked examples, plus boundary cases derived from quoted rule text. If the implementation disagrees with a GOLDEN test, the implementation is wrong. Never edit a GOLDEN test to make it pass. Bring the disagreement to a human.

Every number below was verified numerically on 2026-09-03 before being written here.

Tiers:

  • GOLDEN — USCIS published this answer. Non-negotiable.
  • BOUNDARY — derived directly from quoted thresholds. Very high confidence.
  • DERIVED — our arithmetic from quoted rules. High confidence, but flag disagreement.

G1 — Day counting (GOLDEN) · src P1

“An applicant for naturalization under INA 316 departs the United States on January 1, 2010, and returns January 2, 2011. The applicant has been outside the United States for exactly 1 year (365 days).”

daysAbsent('2010-01-01', '2011-01-02') === 365

Raw difference is 366. The rule (return − departure) − 1 yields 365. ✓ This single test proves the both-endpoints-count-as-present rule. A tool using return − departure fails it.


G2 — Absence ≥ 1 year, rebutting the presumption (GOLDEN) · src P1

Same applicant as G1. USCIS:

“The applicant must wait until at least January 3, 2015, to apply for naturalization, when the 5-year statutory period immediately preceding the application will date back to January 3, 2010. At that time, although the applicant will have been absent … for less than 1 year during the statutory period, the applicant will still have been absent … for more than 6 months (180 days) … and may be eligible … if he or she successfully rebuts the presumption.”

Input:  lprDate far enough back to not bind; path = INA 316
        trip: depart 2010-01-01, return 2011-01-02
        mode = "presumption rebutted"
Expect: earliestFilingDate === '2015-01-03'
        clippedAbsenceInWindow('2010-01-03' .. '2015-01-03') === 364   // <365 ✓ and >180 ✓

Verified: clipped absence = 364. Under 365 (no longer an automatic break) and over 180 (presumption applies, must be rebutted) — exactly as USCIS reasons. ✓


G3 — Absence ≥ 1 year, presumption NOT rebutted (GOLDEN) · src P1

Same applicant. USCIS:

“If the applicant cannot overcome the presumption … the applicant must wait until at least July 6, 2015 … During the 5-year period of July 6, 2010 to July 6, 2015 … the applicant was only absent … between July 6, 2010 and January 2, 2011, a period that is not more than 6 months.”

Input:  same trip; mode = "presumption NOT rebutted"
Expect: earliestFilingDate === '2015-07-06'
        clippedAbsenceInWindow('2010-07-06' .. '2015-07-06') === 180   // exactly at the limit
        clippedAbsenceInWindow('2010-07-05' .. '2015-07-05') === 181   // one day earlier fails

Verified: 180 on 2015-07-06 and 181 on 2015-07-05. The rolling-window search lands on USCIS’s published date to the exact day. ✓

This is the most important test in the suite. It proves the algorithm must be a rolling-window search, not a fixed offset added to the return date. returnDate + 4 years 6 months gives 2015-07-02 — four days early, and wrong.


G4 — Absence > 6 months but < 1 year, calendar formula (GOLDEN, with a caveat) · src P1

“An applicant … subject to a 5-year statutory period … is absent … for 8 months, returning on August 1, 2018 … If the applicant is unable to rebut the presumption, he or she must wait until at least 6 months from reaching the 5-year anniversary of the newly established statutory period … the earliest the applicant may re-apply … is February 1, 2023.”

postBreakCalendarDate(returnDate='2018-08-01', years=5) === '2023-02-01'
// i.e. returnDate + 5 calendar years − 6 calendar months

⚠️ Read this before “fixing” a failure here

USCIS gives no departure date — “8 months” is approximate — so this example cannot pin a day-count answer. Assuming a 2017-12-01 departure (242 days absent), the rolling-window search gives 2023-02-02, one day later than USCIS’s illustrative 2023-02-01. Verified:

Filing date Window starts Clipped absence
2023-02-01 2018-02-01 181 ✗
2023-02-02 2018-02-02 180 ✓

USCIS used a calendar-month shortcut here and an exact day-count in G2/G3. The two methods can disagree by a day or two. That is expected, not a bug.

Therefore: test the calendar formula as an isolated unit (assertion above), and have the composed earliestFilingDate return max(calendarFormula, rollingWindowSearch). Later is always safe. See BUILD-PLAN §4.4.


G5 — The 90-day early filing formula (GOLDEN) · src P7

“USCIS calculates the early filing period by counting back 90 days from the day before the alien would have first satisfied the continuous residence requirement for naturalization. For example, if the alien would satisfy the five-year continuous residence requirement for the first time on June 10, 2010 USCIS will begin to calculate the 90-day early filing period from June 9, 2010. In such a case, the earliest that the alien is allowed to file would be March 12, 2010 (90 calendar days earlier).”

earliestFilingByResidence(anniversary='2010-06-10') === '2010-03-12'

Verified: toDay('2010-06-10') − 90 → 2010-03-12. ✓

Off-by-one trap. USCIS’s prose says “counting back 90 days from the day before”. Reading that as anniversary − 1 − 90 gives 2010-03-11, which is wrong. The phrasing describes an inclusive 90-day span ending the day before the anniversary (2010-03-12 … 2010-06-09 = 90 days). The correct implementation is plain anniversary − 90.

G5b — filing early is not being eligible early

P7: “Although an alien may file early … the alien is not eligible for naturalization until he or she has reached the required five-year period of continuous residence.”

result.earliestFilingDate    === '2010-03-12'
result.eligibilityDate       === '2010-06-10'   // must be surfaced separately

Both dates must appear in the UI. A user shown only “2010-03-12” will believe that is when they become a citizen.

G5c — physical presence is NOT advanced by the 90 days

The 90-day provision is written against continuous residence only (P7). Physical presence must be satisfied as of the filing date.

// 5-yr path, applicant is 20 days short of 913 on the early filing date
Expect: earliestFilingDate is pushed later until 913 days are met — NOT the residence-only date

B1–B6 — Threshold boundaries (BOUNDARY) · src P1 summary table

Rule text: “6 months or less” → no presumption. “More than 6 months but less than 1 year” → presumption. “1 year or more (365 days or more)” → not eligible.

# daysAbsent presumption? eligible?
B1 179 no yes
B2 180 no yes
B3 181 yes yes (if rebutted)
B4 364 yes yes (if rebutted)
B5 365 n/a NO — automatic break
B6 366 n/a NO — automatic break

B2/B3 and B4/B5 are the off-by-one traps. Test both sides of each.


D1 — Happy path, no trips (DERIVED) · src P4

Input:  lprDate = '2020-01-01', path = INA 316, trips = []
Expect: fiveYearAnniversary === '2025-01-01'
        earliestFilingDate  === '2024-10-03'    // anniversary − 90 days
        physicalPresenceDays over the window ≈ 1826, well over 913 ✓

Verified: 2025-01-01 − 90 days = 2024-10-03.


D2–D6 — Trip edge cases (DERIVED)

# Case Expected
D2 depart 2024-01-01, return 2024-01-10 daysAbsent = 8 (not 9, not 10)
D3 depart and return the same day daysAbsent = 0, never negative
D4 return before departure validation error, not a negative number
D5 trip entirely before the statutory window contributes 0 to the window
D6 trip straddling the window start clipped — covered by G3
D7 two trips overlapping in time (bad user input) detect and warn; do not double-count
D8 trip still open (departed, not yet returned) handle or explicitly reject; do not silently treat as 0

P1–P3 — Physical presence (BOUNDARY/DERIVED) · src P2

# Case Expected
P1 5-yr window, 912 days present fails the 913-day requirement
P2 5-yr window, 913 days present passes (rule is “at least 913”)
P3 INA 319(a) 3-yr path requires 18 months (548 days) — USCIS-published in 12 USCIS-PM G.3 (SOURCES P8); the “label as derived” instruction that used to be here was wrong

S1–S3 — Scope guards (must not silently produce a number)

# Input Expected behaviour
S1 any absence ≥ 365 days Updated 2026-09-08 (Task 8, REVIEW F7) — deliberate narrowing, not drift. Show the automatic-break outcome (AUTOMATIC_BREAK_RISK warning, the N-470 caveat, a link to USCIS) only when the trip still has days inside the statutory period being tested at the shipped filing date. If continuous residence already restarted on the return date and a later statutory period clears without that trip in view, the tool now correctly shows no red banner above a normal answer — e.g. a 366-day trip from 2010-01-01 to 2011-01-02 on a green card dated 2000-01-01 shows earliestFilingDate: 2026-09-08, alreadyFilable: true, and no AUTOMATIC_BREAK_RISK warning (verified live against computeEligibility), because residence restarted in 2011 and the answer is unaffected by the old trip. When the trip does still touch the shipped window (e.g. the same trip with an LPR date of 2006-06-01), the warning still fires as before — see engine.test.js, [DERIVED][F7/F9] input sanity and warning relevance.
S2 INA 319(a) selected, marriage < 3 years before candidate filing date Block: marital-union requirement not met (SOURCES §2.1)
S3 user indicates military service / N-470 / removal order Show “this tool does not cover your case” and link to USCIS. Do not guess

How to run these

Put the date engine in a pure, dependency-free module with no DOM and no framework imports, so these run in plain node --test. The engine must be testable without a browser. If you cannot run the suite from the command line, the architecture is wrong — fix the architecture, not the tests.