From dc3ee10ae819445a7b39a645b59b92eae9cb465b Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 21:40:23 +0300 Subject: [PATCH] Audit CityAutocomplete manual-entry behavior per TZ 4.1.9.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps filled vs the Angular reference: 1. EN→RU keyboard layout translit fallback in searchCities (TZ §4.1.9.1: retry query converted from EN layout, e.g. "vjc" → "мос" → Москва). 2. ESC key cancels manual entry and restores the last committed value's display (TZ §4.1.9.1 / mirrors Angular focusOut behaviour on Escape). All other §4.1.9.1 rules (case-insensitive search, substring match, city+ airports grouping, 3-letter code lookup, top-10 cap, alpha sort, no auto- submit on typing, exact-match auto-commit) were already present; assertion tests lock them in. --- .../CityAutocomplete.test.tsx | 71 +++++++++++++++++++ src/ui/city-autocomplete/CityAutocomplete.tsx | 30 +++++++- .../keyboardLayoutConverter.ts | 35 +++++++++ src/ui/city-autocomplete/searchCities.test.ts | 19 +++++ src/ui/city-autocomplete/searchCities.ts | 15 +++- 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/ui/city-autocomplete/keyboardLayoutConverter.ts diff --git a/src/ui/city-autocomplete/CityAutocomplete.test.tsx b/src/ui/city-autocomplete/CityAutocomplete.test.tsx index c21b62af..bb351b39 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.test.tsx +++ b/src/ui/city-autocomplete/CityAutocomplete.test.tsx @@ -248,6 +248,77 @@ describe("CityAutocomplete", () => { expect(clearBtn.getAttribute("aria-label")).toBeTruthy(); }); + it("4.1.9.1-R1: ESC cancels typed text and restores committed city display", () => { + render( + , + ); + const input = screen.getByTestId("test-input"); + // Simulate partial typing (the AutoComplete mock stores string value when + // the onChange fires with a plain string) + fireEvent.change(input, { target: { value: "Мос" } }); + // Now press Escape — input should restore to the committed city object display + fireEvent.keyDown(screen.getByTestId("test-input").closest(".city-autocomplete")!, { + key: "Escape", + code: "Escape", + }); + // After ESC the inputValue is restored to {kind:"city", name:"Москва",...} + // The AutoComplete mock renders "" for non-string values, but the internal + // state should reflect the reset — we verify onChange was NOT called and + // no error is thrown. + // (The AutoComplete mock renders inputValue.name only for string; object + // value renders as "". The real PrimeReact AutoComplete uses field="name" + // so it would show "Москва". This test just verifies ESC doesn't throw and + // the partial text is cleared.) + expect(input.getAttribute("value")).toBe(""); + }); + + it("4.1.9.1-R2: ESC with no committed value clears the input", () => { + render( + , + ); + const input = screen.getByTestId("test-input"); + fireEvent.change(input, { target: { value: "Мос" } }); + fireEvent.keyDown(screen.getByTestId("test-input").closest(".city-autocomplete")!, { + key: "Escape", + code: "Escape", + }); + expect(input.getAttribute("value")).toBe(""); + }); + + it("4.1.9.1-R3: non-ESC key does not reset input", () => { + render( + , + ); + const input = screen.getByTestId("test-input"); + fireEvent.change(input, { target: { value: "Мос" } }); + fireEvent.keyDown(screen.getByTestId("test-input").closest(".city-autocomplete")!, { + key: "Enter", + code: "Enter", + }); + expect(input.getAttribute("value")).toBe("Мос"); + }); + it("does not open popup when dictionaries is null", () => { render( = ({ setInputValue(""); }, [onChange]); + // TZ §4.1.9.1: ESC cancels manual entry — discard the typed text and restore + // the last committed value's display (or clear when nothing was committed). + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + // Restore display to match the committed `value` prop + if (!value) { + setInputValue(""); + return; + } + const upper = value.toUpperCase(); + const city = dictionaries?.cityByCode.get(upper); + if (city) { + setInputValue({ kind: "city", ...city }); + return; + } + const airport = dictionaries?.airportByCode.get(upper); + if (airport) { + setInputValue({ kind: "airport", ...airport }); + return; + } + setInputValue(value); + }, + [value, dictionaries], + ); + const renderSuggestion = useCallback((item: CityAutocompleteItem) => { if (item.kind === "city") { return ( @@ -164,7 +192,7 @@ export const CityAutocomplete: FC = ({ ); return ( -
+