Wróć do bloga

108 testów w 6 godzin: niezawodny kod z AI na jednym repo

Trzech programistów, tester, agenci AI i jedno repo w sześć godzin. Co utrzymało 108 testów na zielono po restarcie o 11:25 i czego ten PoC nie dowodzi.

Jakub Czechowski

Buduje strony i sklepy internetowe w JC Web Studio, prowadzi StackCompass – publikację o architekturze treści i doborze technologii – a także współorganizuje CMS Conf, konferencję o systemach treści, oraz WordCamp.

/ / 16 min czytania

Agent AI pod presją czasu domyślnie produkuje kruchy prototyp. Daj mu brief i kwadrans, a odda panel w React, który renderuje karty, obsługuje kliknięcie i wystarcza na dwuminutowe demo. Ten sam panel wywraca się na pierwszym nazwisku wpisanym bez polskich znaków, pierwszym numerze telefonu ze spacjami w dziwnych miejscach albo pierwszej dacie, którą Excel zapisał jako pięciocyfrową liczbę. Taki panel powstaje w minuty. Żeby trzy osoby przez całe popołudnie mergowały ten kod do jednego repozytorium i nie psuły sobie nawzajem zmian, potrzeba struktury.

W tekście Skille AI jako infrastruktura pracy technicznej pisałem, że powtarzalne procedury wygrywają z promptowaniem ad hoc, bo zamieniają inżynierski osąd w kroki, które da się sprawdzić. 28 sierpnia 2026 roku sprawdziliśmy to na hackathonie. Trzech programistów pisało kod z lokalnymi agentami AI w IDE i terminalu, czwarta osoba testowała ręcznie każdy build i zgłaszała, co się posypało, a wszystko lądowało na jednym branchu main. Brief przyszedł od ogólnopolskiej fundacji wspierającej osoby w spektrum autyzmu: bezpłatna linia poradnicza finansowana z publicznego funduszu rehabilitacji, obsadzona przez prawniczki, pedagożki i psycholożki, około 4000 porad rocznie.

Ich narzędziem był jeden wspólny plik Excela. Kto odebrał telefon, musiał w trakcie rozmowy ustalić, czy dzwoniący kontaktował się wcześniej, kto mu doradzał i w jakiej sprawie. Wspólny plik w Excelu blokował się przy równoległej edycji, szukanie w nim trwało za długo, a dane podlegają pod art. 9 RODO, więc fundacja nigdy nie udostępnia prawdziwych rekordów. Od pierwszej minuty pracowaliśmy na pustej strukturze pól i danych syntetycznych. Potrzebowaliśmy historii dzwoniącego na ekranie w mniej niż pięć sekund, kilku osób piszących jednocześnie i eksportu, który przyjmie grantodawca.

O 16:03 repozytorium miało 67 commitów, 16 merge’y i 108 przechodzących testów Vitest w 15 plikach. Cały zestaw wykonuje się w około pół sekundy. Poniżej opisuję to, co pokazuje git log, łącznie z godziną spędzoną nad innym wyzwaniem.

Pierwsza godzina minęła na innym wyzwaniu

O 09:58 pierwszy commit dodał plan, szkic architektury i specyfikację ekranów demo. O 10:09 drugi commit wprowadził system rezerwacji wizyt z kaskadą listy oczekujących, podglądem powiadomień telefonicznych i regułą odwołania na 24 godziny przed terminem. To było wyzwanie, które wybraliśmy na starcie, i weszły do niego jeszcze dwa commity z funkcjami. Potem organizatorzy zapytali, czy nie weźmiemy innego: brief linii poradniczej nie miał żadnego zespołu. Zgodziliśmy się. O 11:25 komunikat commita brzmi chore: remove all repository files.

CO POKAZUJE GIT LOG — 28 SIERPNIA 2026 09:58 plan + architektura specyfikacja demo 10:09 system rezerwacji (pierwsze zadanie) 11:25 zmiana wyzwania repo wyczyszczone 11:50 powłoka UI + typy 7230 linii, 0 testów 12:40 pierwsze testy eksport i filtry RODO 13:36 +7 plików testów matching, storage, Excel 15:11 / 15:47 audit log i odmiana statystyki geograficzne 16:03 108 testów zielonych 67 commitów, 16 merge’y Pierwsze zadanie (porzucone) UI najpierw (0 testów) Zestaw testów bramkujący każdy merge

Porzucenie systemu rezerwacji kosztowało nas 51 minut budowy i nic więcej. Trzy commity, działająca lista oczekujących i podgląd powiadomień zniknęły bez dyskusji. Tempo agentów sprawia, że taka zmiana jest tania. Ta zmiana nic nie mówi o niezawodności kolejnego systemu. Reszta dnia dotyczyła właśnie tej niezawodności.

O 11:50 świeży commit wniósł 7230 linii: powłokę React, pasek wyszukiwania, kartę dzwoniącego, widok rozstrzygania tożsamości, importer z Excela, modal eksportu, warstwę lokalnego storage i typy TypeScript. UI weszło pierwsze, bo presja, żeby po zmianie wyzwania mieć cokolwiek na ekranie, była realna. Testów w tym commicie nie było. Nie udaję, że to był test-driven development.

Wyszukiwarka toleruje to, jak ludzie piszą w trakcie rozmowy

Specjalistka pisze, gdy dzwoniący mówi. Nikt wtedy nie wpisuje Żółtowska-Bąk ze wszystkimi znakami. Nikt nie dyktuje numeru telefonu dwa razy z tymi samymi spacjami. normalizeText zmniejsza litery i jawnie podmienia ł, bo dekompozycja Unicode tej litery nie rozbija. Po NFD zdejmuje pozostałe znaki diakrytyczne i wycina spacje, myślniki oraz nawiasy. searchCallers sprawdza, czy to znormalizowane zapytanie zawiera się w znormalizowanym imieniu i nazwisku, w kolejności odwrotnej (nazwisko pierwsze, tak jak w formularzu), w numerze telefonu, miejscowości i województwie.

Wyszukiwarka szuka ścisłego zawierania podciągu na znormalizowanym tekście, bez scoringu rozmytego. Zwraca każdą osobę, która pasuje. Gdy pasuje więcej niż jedna, obok siebie pojawiają się karty z liczbą wcześniejszych porad przy każdej. Wybiera specjalistka. System nie zgaduje, który Nowak jest na linii. Zły strzał podpiąłby czyjąś historię medyczną pod obcą osobę.

Testy weszły o 12:40. Od 13:36 merge przestał boleć

Między 11:50 a 12:40 trzy osoby pushowały na main co kilka minut. Wchodziło mapowanie pól na oficjalny wzorzec arkusza, załączniki, importer z Excela i poprawka, po której dwie osoby o tym samym nazwisku obie zostają po imporcie. Każdy merge był negocjacją, która wersja typów jest aktualna.

Pierwsze pliki testów wylądowały o 12:40, razem z commitem „spójny eksport PFRON: wspólne filtry, bez fabrykowanych danych, pełna anonimizacja”. Testy eksportu sprawdzają, że zanonimizowany wiersz nie zawiera imienia, nazwiska, telefonu, miejscowości ani uwag. Puste pola zostają puste, zamiast dostać wymyślone wartości. anonymizeFreeText usuwa z wolnego tekstu numery telefonów, PESEL-e, adresy e-mail i odmienione formy nazwisk. Komórki CSV zaczynające się od =, +, - lub @ dostają apostrof na początku. Dwadzieścia testów, największy zestaw w projekcie, siedzi na eksporcie: to jedyny plik, który wychodzi z aplikacji do grantodawcy.

O 13:36 jeden commit dodał siedem kolejnych plików testów: dopasowanie rozmyte, storage i wyszukiwanie, migrator z Excela, obsługę dat, operacje administratora, narzędzia plikowe i kontrolę spójności 71 syntetycznych rekordów. Od tego momentu prompt do lokalnego agenta przy merge’u stał się rutyną. Brzmiał: dostosuj ten branch do aktualnych typów na main, rozwiąż diff, uruchom npm test, nie pushuj, dopóki nie jest zielono. Jeśli agent zepsuł sygnaturę albo wymyślił import, Vitest zgłaszał to w pół sekundy na maszynie programisty. To ten sam wzorzec co w tekście Bramki jakości AI wygrywają z listami kontrolnymi w potoku contentu, tylko na kodzie. Agent nie obejdzie bramki argumentacją.

Czwarta osoba w zespole uruchamiała aplikację po każdym merge’u i zgłaszała to, czego testy nie widzą: zdublowany wynik wyszukiwania, picker dat o sztywnej szerokości ucinający zawartość, nachodzące na siebie badge’e na osi czasu porad, przycisk eksportu widoczny dla użytkowników bez roli admina. Dwanaście z 67 commitów to fix:. Większość z nich wynika z tych zgłoszeń.

TRZECH PROGRAMISTÓW, JEDEN TESTER, JEDEN BRANCH MAIN Dev 1: import, eksport Dev 2: UI, handoff, testy Dev 3: szukanie, raporty LOKALNI AGENCI AI (IDE / CLI) • Dostosuj branch do typów na main • Rozwiąż diff po merge’u • npm test na zielono przed pushem WSPÓLNY MAIN (09:58 → 16:03) • 67 commitów, 16 merge commitów • 108 testów Vitest w 15 plikach • Cały zestaw w ~0,5 s Tester (Ręczne QA) sprawdza każdy build na żywo ⚡ Zgłoszenia QA → 12 commitów fix:

Dopasowanie rozmyte, które nie zgaduje

Dopasowanie rozmyte zastosowaliśmy wyłącznie przy imporcie: mapuje komórki wpisane z ręki na słowniki kontrolowane. Przy wyszukiwaniu dzwoniącego go nie ma. Kolumna z województwem, wypełniana ręcznie przez kilka lat, zawiera Mazowieckie, mazoweckie, woj. mazowieckie i gorsze rzeczy. matchDictionary porównuje wartość z 16 oficjalnymi nazwami, w stałej kolejności: dokładne dopasowanie po normalizacji, podpowiedź po słowie kluczowym, zawieranie, na końcu odległość Levenshteina z progiem zależnym od długości wartości wzorcowej.

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

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

Gdy dwie wartości słownika leżą w tej samej odległości od wejścia, matchDictionary zwraca uncertain i pokazuje wynik na ekranie przeglądu. Dzieje się tak nawet wtedy, gdy odległość mieści się w progu auto. Testy poniżej trzymają inną granicę: lekką literówkę dopasowuje sam, śmieci zostawia jako none.

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(): STAŁY POTOK, CZTERY WYNIKI "mazoweckie" normalizeText() Potok (16 województw) 1. exact → słowo kluczowe → zawieranie 2. Levenshtein (próg wg długości) exact / auto odległość ≤ 1 … ≤ 3 → autouzupełnienie uncertain → ekran przeglądu powyżej progu lub remis → weryfikacja ręczna none brak dopasowania → pole puste 🛡️ ZASADA DLA OSOB: Odległość Levenshteina ≤ 2 (DUPLICATE_NAME_MAX_DISTANCE) Generuje DuplicateReview. Specjalistka wybiera scal/rozdziel — system NIGDY nie łączy osób sam.

Przy duplikatach osób system też nie zgaduje. Przy imporcie nowy dzwoniący, którego znormalizowane nazwisko jest w odległości Levenshteina co najwyżej 2 od nazwiska na istniejącej karcie (DUPLICATE_NAME_MAX_DISTANCE = 2), nigdy nie jest scalany automatycznie. Importer zwraca DuplicateReview z oboma nazwiskami i numerami wierszy. Specjalistka wybiera merge albo separate, zanim cokolwiek zostanie zapisane. W panelu administratora wykrywanie jest surowsze. Oznacza pary tylko przy identycznym telefonie albo identycznym imieniu i nazwisku. Scalenie robi człowiek.

Brief miał jedno zdanie. Jeśli system nie jest pewien, że dwa wpisy to ta sama osoba, pokazuje kandydatów. Nie łączy ich. To było najważniejsze ograniczenie architektoniczne dnia. Szybkość generowania by go nie wymyśliła.

Importer z Excela szuka nagłówka i nie gubi złych wierszy

Historia porad fundacji leżała w arkuszu, którego układ dryfował przez lata. Importer nie zakłada, że nagłówki są w pierwszym wierszu. Szuka wiersza ze znanymi słowami kluczowymi kolumn, bo oficjalny wzorzec wstawia nad nagłówkami wiersz z opisem. Daty przychodzą w trzech kształtach: liczby seryjne Excela (dni od 1900 roku, przeliczane przez przesunięcie epoki Unix), tekst dd.mm.rrrr albo dd/mm/rrrr z opcjonalną godziną, albo cokolwiek, co new Date() jeszcze sparsuje. Wiersze, które nie przejdą walidacji, wracają jako SkippedRow z numerem wiersza i powodami, na przykład brakiem nazwiska albo pustą datą porady. Nic nie znika po cichu.

Testowaliśmy to wyłącznie na zestawie syntetycznym. Migrator ma trzy testy, obsługa dat trzy. Testy sprawdzają konwersję dat seryjnych i decyzję scal/rozdziel. Nie dowodzą, że importer zniesie nietypowy układ tabeli. Pierwszy prawdziwy arkusz będzie miał nagłówek, którego nikt nie przewidział.

Czego sześciogodzinny PoC nie dowodzi

Trwałość danych to localStorage. Współbieżność między zakładkami na jednej maszynie to BroadcastChannel. Gdy jedna zakładka dodaje poradę, kontakt albo import zbiorczy, pozostałe przeładowują dane ze storage i pokazują baner, kto to zrobił. Na demo dwie osoby dodają wpisy i nie nadpisują się nawzajem. O dwóch maszynach to nic nie mówi. Plan wdrożenia spisany tego samego dnia na pierwsze dwa tygodnie prawdziwej pracy stawia PostgreSQL z szyfrowaniem kolumnowym, rejestr audytu i 2FA.

108 testów pokrywa czyste funkcje: normalizację, wyszukiwanie, dopasowanie, parsowanie dat, kształt eksportu, filtry, odmianę liczebników, wpisy audytu. Nie ma ani jednego testu komponentu ani testu end-to-end. Warstwę React sprawdzała czwarta osoba, klikając bez przerwy, jak przy większości interfejsów na hackathonie. Nie oddałbym tego fundacji bez testu w przeglądarce na rozstrzyganiu tożsamości i na imporcie.

Co AI przyspieszyło, a co zdecydowali ludzie

Agenci przyspieszyli pracę na wzorcach: tabele permutacji wariantów nazwisk, regexy w anonymizeFreeText, powtarzalne asercje. Przede wszystkim merge względem zielonego zestawu. W tym samym tempie w 51 minut powstał działający system rezerwacji, który skasowaliśmy bez żalu. Tekst Dźwignia AI i koszt ignorancji dewelopera opisuje, co się dzieje, gdy ta łatwość wyrzucania kodu trafia na programistę, który nie umie ocenić, co zostawić.

KTO O CZYM ZDECYDOWAŁ LUDZIE (DECYZJE I WARUNKI) ✓ Zmiana zadania o 11:25, 51 min kodu do kosza ✓ Szukanie przez zawieranie, bez scoringu osób ✓ Remis / niepewność → ekran, nigdy auto-scalenie ✓ Największe testy na eksporcie (art. 9 RODO) ✓ Spisana granica: czego ten PoC nie dowodzi ✓ Ręczne QA testujące każdy scalony build LOKALNI AGENCI AI (TEMPO I WYKONANIE) ⚡ Gotowy system rezerwacji w 51 min (usunięty) ⚡ Tabele permutacji nazwisk i polskich znaków ⚡ Regexy w anonymizeFreeText (PESEL/telefon/email) ⚡ 108 asercji w 15 plikach testowych ⚡ Rozwiązywanie diffów z bramką zielonych testów ⚡ Powłoka UI, modale i typy TypeScript

Ludzie podjęli decyzje, które ukształtowały kod. Przyjęli zmianę wyzwania o 11:25 i skasowali pierwszą godzinę. Wyszukiwanie idzie przez zawieranie, bez scoringu osób. Każde niejednoznaczne dopasowanie idzie na ekran przeglądu. Największy zestaw testów siedzi na eksporcie, bo tamtędy dane osobowe wychodzą z systemu. Spisaliśmy, czego PoC nie pokrywa.

Trzech programistów, tester i lokalni agenci na jednym repozytorium zadziałali, bo o 12:40 wszedł zestaw testów i od 13:36 każdy merge musiał przez niego przejść. Zestaw wszedł po UI. Wszedł na czas. To warto skopiować.