diff --git a/src/ui/city-autocomplete/buildCountryCityRows.test.ts b/src/ui/city-autocomplete/buildCountryCityRows.test.ts new file mode 100644 index 00000000..319960ba --- /dev/null +++ b/src/ui/city-autocomplete/buildCountryCityRows.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest"; +import { buildCountryCityRows } from "./buildCountryCityRows.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; + +function makeRaw(overrides: Partial = {}): IRawDictionaries { + return { regions: [], countries: [], cities: [], airports: [], ...overrides }; +} + +function dict(raw: Partial): IDictionaries { + return transformDictionaries(makeRaw(raw), "ru"); +} + +describe("buildCountryCityRows — empty and missing", () => { + it("returns [] for missing region id", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + }); + expect(buildCountryCityRows(d, 99999)).toEqual([]); + }); + + it("returns [] when region has no countries", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + }); + expect(buildCountryCityRows(d, 500374)).toEqual([]); + }); +}); + +describe("buildCountryCityRows — RU pinning", () => { + it("pins RU country first within its region, others alphabetical", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + countries: [ + { code: "BY", title: { ru: "Беларусь" }, world_region_id: 500374 }, + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "AM", title: { ru: "Армения" }, world_region_id: 500374 }, + ], + cities: [ + { code: "BYN", title: { ru: "Минск" }, country_code: "BY", has_afl_flights: true, location: { lat: 53, lon: 27 } }, + { code: "YER", title: { ru: "Ереван" }, country_code: "AM", has_afl_flights: true, location: { lat: 40, lon: 44 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + airports: [ + { code: "MSQ", city_code: "BYN", title: { ru: "Минск аэропорт" }, has_afl_flights: true, location: { lat: 53, lon: 27 } }, + { code: "EVN", city_code: "YER", title: { ru: "Звартноц" }, has_afl_flights: true, location: { lat: 40, lon: 44 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + const countryOrder = rows + .map((r) => r.countryName) + .filter((n): n is string => Boolean(n)); + expect(countryOrder[0]).toBe("Россия"); + expect(countryOrder).toEqual(["Россия", "Армения", "Беларусь"]); + }); +}); + +describe("buildCountryCityRows — MOW pinning inside RU", () => { + it("pins MOW first among RU cities", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "KZN", title: { ru: "Казань" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 49 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "KZN", city_code: "KZN", title: { ru: "Казань аэропорт" }, has_afl_flights: true, location: { lat: 55, lon: 49 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]!.city1!.code).toBe("MOW"); + }); +}); + +describe("buildCountryCityRows — pairing", () => { + it("pairs two single-airport cities on the same row", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(1); + expect(rows[0]!.city1!.code).toBe("AAA"); + expect(rows[0]!.city2!.code).toBe("BBB"); + expect(rows[0]!.city1Airports).toBeUndefined(); + }); + + it("puts a multi-airport city on its own row with city1Airports populated", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "ZZZ", title: { ru: "Город Я" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "DME", city_code: "MOW", title: { ru: "Домодедово" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "ZZZ", city_code: "ZZZ", title: { ru: "Аэропорт Я" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(2); + expect(rows[0]!.city1!.code).toBe("MOW"); + expect(rows[0]!.city2).toBeNull(); + expect(rows[0]!.city1Airports?.length).toBe(2); + expect(rows[1]!.city1!.code).toBe("ZZZ"); + expect(rows[1]!.city2).toBeNull(); + }); + + it("handles odd-count cities by leaving city2 null on last row", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", title: { ru: "Гор В" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", city_code: "CCC", title: { ru: "Аэропорт В" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(2); + expect(rows[1]!.city2).toBeNull(); + }); +}); + +describe("buildCountryCityRows — countryName placement", () => { + it("sets countryName only on the first row of each country block", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "BY", title: { ru: "Беларусь" }, world_region_id: 500374 }, + ], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", title: { ru: "Гор В" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + { code: "DDD", title: { ru: "Гор Д" }, country_code: "BY", has_afl_flights: true, location: { lat: 4, lon: 4 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", city_code: "CCC", title: { ru: "Аэропорт В" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + { code: "DDD", city_code: "DDD", title: { ru: "Аэропорт Д" }, has_afl_flights: true, location: { lat: 4, lon: 4 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows[0]!.countryName).toBe("Россия"); + expect(rows[1]!.countryName).toBeUndefined(); + expect(rows[2]!.countryName).toBe("Беларусь"); + }); +}); diff --git a/src/ui/city-autocomplete/buildCountryCityRows.ts b/src/ui/city-autocomplete/buildCountryCityRows.ts new file mode 100644 index 00000000..e9d3c7f0 --- /dev/null +++ b/src/ui/city-autocomplete/buildCountryCityRows.ts @@ -0,0 +1,84 @@ +/** + * Build rows for the regional-picker country/city grid. + * + * Matches Angular `DictionariesService.handleLoading` logic: + * 1. Countries in the region: alphabetical by localized name, RU first. + * 2. Cities within a country: alphabetical, excluding MOW initially; + * MOW re-inserted at front for RU country. + * 3. Row packing: city with >1 airport gets its own row (airports below); + * others paired into city1 + city2 columns. + * 4. `countryName` set only on the first row of each country's block. + */ + +import type { + IAirport, + ICity, + IDictionaries, +} from "@/shared/dictionaries/index.js"; + +export interface ICountryCityRow { + countryName?: string; + city1: ICity | null; + city2: ICity | null; + city1Airports?: IAirport[]; +} + +export function buildCountryCityRows( + dictionaries: IDictionaries, + regionId: number, +): ICountryCityRow[] { + const region = dictionaries.regions.find((r) => r.id === regionId); + if (!region || region.countries.length === 0) return []; + + const countries = [...region.countries].sort((a, b) => + a.name.localeCompare(b.name), + ); + const ruIdx = countries.findIndex((c) => c.code === "RU"); + if (ruIdx > 0) { + const [ru] = countries.splice(ruIdx, 1); + if (ru) countries.unshift(ru); + } + + const rows: ICountryCityRow[] = []; + + for (const country of countries) { + const cities = dictionaries.cities + .filter((c) => c.country_code === country.code && c.code !== "MOW") + .sort((a, b) => a.name.localeCompare(b.name)); + + if (country.code === "RU") { + const mow = dictionaries.cities.find((c) => c.code === "MOW"); + if (mow) cities.unshift(mow); + } + + if (cities.length === 0) continue; + + let firstRow = true; + let i = 0; + while (i < cities.length) { + const city1 = cities[i]!; + const multiAirport = city1.airports.length > 1; + + if (multiAirport) { + rows.push({ + ...(firstRow ? { countryName: country.name } : {}), + city1, + city2: null, + city1Airports: city1.airports, + }); + i += 1; + } else { + const city2 = cities[i + 1] ?? null; + rows.push({ + ...(firstRow ? { countryName: country.name } : {}), + city1, + city2, + }); + i += 2; + } + firstRow = false; + } + } + + return rows; +}