Back to Blog

108 Unit Tests in 6 Hours: Reliable AI Coding on One Repo

Three developers, one tester, local AI agents, one repo, six hours. What kept 108 tests green after a restart, and what a hackathon PoC does not prove.

Jakub Czechowski

Builds websites and e-commerce at JC Web Studio, runs StackCompass – a publication on content architecture and stack decisions – and co-organizes CMS Conf, a conference on content systems, as well as WordCamp.

/ / 17 min read

An AI agent under time pressure produces a fragile prototype by default. Give it a brief and fifteen minutes, and it returns a React dashboard that renders cards, handles a click, and survives a two-minute demo. The same dashboard falls over on the first surname typed without diacritics, the first phone number with spaces in odd places, or the first date that Excel stored as a five-digit serial. Generating that takes minutes. Keeping three people merging into one repository all afternoon without breaking each other takes structure.

In Skills as Workflow Infrastructure for Technical Work I argued that repeatable procedures beat ad-hoc prompting because they turn engineering judgment into steps you can check. On 28 August 2026 my team put that claim under a six-hour hackathon clock. Three developers wrote code with local AI agents in their IDEs and terminals, a fourth person tested every build by hand and reported what broke, and all of it landed on a single main branch. The brief came from a nationwide foundation supporting people on the autism spectrum: a free advisory helpline funded by a public disability fund, staffed by lawyers, educators, and psychologists, handling around 4,000 consultations a year.

Their tooling was one shared Excel file. Whoever picked up the phone had to establish, mid-call, whether the caller had rung before, who advised them, and about what. The spreadsheet did not tolerate parallel editing, searching it took too long, and the data falls under GDPR Article 9, so the foundation never hands out real records. We worked on a blank field structure and synthetic data from the first minute, and the target was blunt: a caller’s history on screen in under five seconds, several specialists writing at once, and an export the funder would accept.

By 16:03 the repository had 67 commits, 16 merges, and 108 passing Vitest tests across 15 files. The suite runs in about half a second. What follows is what the git log shows, including the hour spent on a different challenge, because the real sequence is more useful than the tidy one.

The First Hour Went to a Different Challenge

At 09:58 the first commit added a plan, an architecture sketch, and demo screen specs. At 10:09 the second commit was a booking system with a waitlist cascade, a phone-notification preview, and 24-hour cancellation rules. That was the challenge we had picked at the start, and two more feature commits followed. Then the organisers asked whether we would take another one instead: the helpline brief had no team. We said yes. At 11:25 the commit message reads chore: remove all repository files.

WHAT THE GIT LOG SHOWS — 28 AUGUST 2026 09:58 plan + arch docs screens spec 10:09 booking system (first challenge) 11:25 switch & wipe repo reset to 0 11:50 UI shell + types 7,230 lines, 0 tests 12:40 first test suite export & RODO filters 13:36 +7 test files matching, storage, Excel 15:11 / 15:47 audit log & plural geographic stats 16:03 108 tests green 67 commits, 16 merges First challenge (dropped) UI first (0 tests) Test suite gating all merges

Abandoning the booking system cost the 51 minutes it took to build and nothing else. Three commits, a working waitlist, a notification preview, all deleted without a discussion. Agent speed makes that kind of switch affordable. It does nothing for the reliability of whatever you build next, and the rest of the day was about that second problem.

At 11:50 a fresh commit brought in 7,230 lines: the React shell, a search bar, a caller card, a disambiguation view, an Excel importer, an export modal, a local storage layer, and the TypeScript types. The UI came first. The pressure to have something on screen after the switch was real, and I am not going to rewrite it into a test-driven story. What matters is what happened next.

Search That Tolerates How People Type During a Call

The core interaction is a specialist typing while a caller talks. Nobody in that situation types Żółtowska-Bąk with every diacritic, and nobody reads out a phone number with the same spacing twice. So the search does not try to be clever. normalizeText lowercases the string, replaces ł explicitly because Unicode decomposition leaves that letter alone, strips the remaining combining marks after NFD, and drops spaces, hyphens, and parentheses. searchCallers then checks the normalized query against the normalized full name, the reversed full name (surname first, the way the form is filled in), the phone number, the city, and the voivodeship, by containment.

Search uses strict substring containment on normalized strings instead of fuzzy scoring. This returns every plausible person, and when more than one caller matches, the interface shows disambiguation cards side by side with the number of past consultations on each. The specialist picks. The system does not guess which Nowak is on the line, because a wrong guess attaches somebody’s medical history to a stranger.

Tests Arrived at 12:40; from 13:36 Merges Stopped Hurting

Between 11:50 and 12:40 three people pushed to main every few minutes: field mapping to the official spreadsheet template, attachments, the Excel importer, a fix so that two people with the same surname both survive import. Every merge was a small negotiation about which version of the types was current.

The first test files landed at 12:40, alongside a commit titled, in Polish, “consistent PFRON export: shared filters, no fabricated data, full anonymization”. The export tests assert that an anonymized row contains no first name, surname, phone, city, or notes, that empty fields stay empty instead of receiving invented defaults, that anonymizeFreeText removes phone numbers, national ID numbers, e-mail addresses, and inflected forms of names from free text, and that CSV cells beginning with =, +, -, or @ get an apostrophe prefix. Twenty tests, the largest suite in the project, on the one artifact that leaves the building.

At 13:36 one commit added seven more test files: fuzzy matching, storage and search, the Excel migrator, date handling, admin operations, file utilities, and a consistency check over the 71 synthetic records. From that point the merge prompt to a local agent became routine: adapt this branch to the current types on main, resolve the diff, run npm test, do not push until it is green. If the agent broke a signature or invented an import, Vitest reported it in half a second on the developer’s machine. This is the pattern from AI Quality Gates Beat Checklists in Content Pipelines applied to code: a gate the agent cannot talk its way past.

The fourth team member ran the app after each merge and filed what the tests could not see: a duplicated search result, a date picker with a fixed width that cut off its content, badges overlapping on the consultation timeline, an export button visible to users without the admin role. Twelve of the 67 commits are fix: commits, and most of them trace back to those reports.

THREE DEVELOPERS, ONE TESTER, ONE MAIN BRANCH Dev 1: import, export Dev 2: UI, handoff, tests Dev 3: search, reports LOCAL AI AGENTS (IDE / CLI) • Adapt branch to types on main • Resolve merge diffs • npm test green before push SHARED MAIN (09:58 → 16:03) • 67 commits, 16 merge commits • 108 Vitest tests in 15 files • Suite runs in ~0.5 s Tester (Manual QA) checks every live build ⚡ Bug reports → 12 fix: commits

Fuzzy Matching That Refuses to Guess

We restricted fuzzy matching to mapping free-text spreadsheet cells to controlled vocabularies during import. A voivodeship column filled in by hand over several years contains Mazowieckie, mazoweckie, woj. mazowieckie, and worse. matchDictionary runs a fixed pipeline against the 16 official names: exact match after normalization, then a keyword hint, then containment, then Levenshtein distance with a threshold that depends on the length of the reference value.

export type MatchConfidence = "exact" | "auto" | "uncertain" | "none";

export interface DictionaryMatch {
  value: string;
  confidence: MatchConfidence;
}

When two dictionary values sit at the same distance from the input, matchDictionary downgrades the result to uncertain and routes it to the review screen, even when the distance is small enough for an automatic match. The suite pins this behaviour on both sides of the line:

it("should auto-match slightly misspelled voivodeships", () => {
  const match = matchDictionary("mazoweckie", VOIVODESHIPS);
  expect(match.value).toBe("mazowieckie");
  expect(match.confidence).toBe("auto");
});

it("should return none confidence for completely unrelated text", () => {
  const match = matchDictionary("qwertyuiop xyz 123", VOIVODESHIPS);
  expect(match.confidence).toBe("none");
});
matchDictionary(): FIXED PIPELINE, FOUR OUTCOMES "mazoweckie" normalizeText() Pipeline (16 Voivodeships) 1. exact → keyword → containment 2. Levenshtein (threshold by length) exact / auto distance ≤ 1 (short) … ≤ 3 (long) → accepted uncertain → review screen above auto threshold or tie → human confirms none nothing within reach → cell left empty 🛡️ DUPLICATE CALLERS RULE: Levenshtein distance ≤ 2 (DUPLICATE_NAME_MAX_DISTANCE) Emits DuplicateReview. Specialist confirms merge or separate — system NEVER merges people automatically.

The same restraint applies to people. During import, a new caller whose normalized name sits within Levenshtein distance 2 of an existing caller (DUPLICATE_NAME_MAX_DISTANCE = 2) is never merged automatically. The importer emits a DuplicateReview with both names and the row numbers, and the specialist chooses merge or separate before anything is written. In the admin panel, duplicate detection is stricter still: it flags pairs only on an identical phone number or an identical first and last name, and merging is a manual action.

The brief said it in one sentence: if the system is not sure two entries are the same person, show the candidates and do not link them. That sentence was the most important architectural constraint of the day, and no amount of generation speed would have produced it.

Excel Import Finds the Header and Drops Nothing Silently

The foundation stored its consultation history in a spreadsheet whose layout drifted across years. The importer does not assume a header row at line one; it scans for the row containing known column keywords, because the official template puts a description row above the headers. Dates arrive as Excel serial numbers (days since 1900, converted through the Unix epoch offset), as dd.mm.yyyy or dd/mm/yyyy text with an optional time, or as whatever new Date() can still parse. Rows that fail validation are not dropped silently; they come back as SkippedRow entries with the row number and the reasons, such as a missing surname or an empty consultation date.

We tested this against the synthetic set only. The migrator has three tests, date handling has three. The suite pins the serial-date conversion and the merge-or-separate branch; it does not prove the importer handles unexpected layouts. The first real spreadsheet will contain a header nobody anticipated.

What a Six-Hour PoC Does Not Prove

Persistence is localStorage. Concurrency between specialists is a BroadcastChannel: when one tab adds a record, a caller, or a bulk import, other tabs on the same machine reload from storage and show a banner naming who did it. That satisfies the demo criterion of two people adding entries without overwriting each other, and it proves nothing about two machines. The deployment plan written the same day names PostgreSQL with column-level encryption, an audit log, and 2FA as the first two weeks of real work.

The 108 tests cover pure functions: normalization, search, matching, date parsing, export shaping, filters, pluralization, audit log entries. There is no component test and no end-to-end test. The React layer was verified the way most hackathon UIs are, by the person who kept clicking through it. I would not hand it to the foundation without at least the disambiguation and import flows under a browser test.

What AI Accelerated and What the Humans Decided

The agents earned their place on the pattern-heavy work: permutation tables for name variants, the regexes inside anonymizeFreeText, repetitive assertions, and above all the merge chores against a green suite. The same speed produced a working booking system in 51 minutes that we deleted without regret. AI Leverage and the Cost of Developer Ignorance covers what happens when that throwaway ease meets a developer who cannot judge what to keep.

WHO DECIDED WHAT HUMANS (DECISIONS & CONSTRAINTS) ✓ Take switch at 11:25, drop 51 min of code ✓ Containment search, no fuzzy scoring of people ✓ Ambiguous match → review, never auto-merge ✓ Largest test suite on export (Article 9 GDPR risk) ✓ Written boundaries of what the PoC does not prove ✓ Manual QA verification after every merge LOCAL AI AGENTS (SPEED & EXECUTION) ⚡ Working booking system in 51 min (dropped) ⚡ Name permutation & diacritic test tables ⚡ Regexes in anonymizeFreeText (PESEL, phone, email) ⚡ 108 assertion blocks across 15 test files ⚡ Merge diffs resolved under green test suite ⚡ Boilerplate UI components & TypeScript types

The humans made the decisions that shaped the codebase: taking the switch at 11:25 and deleting the first hour, keeping search on containment instead of fuzzy scoring, sending every ambiguous match to a review screen, putting the largest test suite on the export because that is where personal data leaves the system, and writing down what the PoC does not cover.

Three developers, one tester, and local agents on one repository worked because the test suite landed at 12:40 and from 13:36 every merge had to pass it. The suite came after the UI. It came in time. That is the part worth copying.