diff --git a/src/shared/dictionaries/transform.test.ts b/src/shared/dictionaries/transform.test.ts new file mode 100644 index 00000000..968ddd35 --- /dev/null +++ b/src/shared/dictionaries/transform.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { transformDictionaries } from "./transform.js"; +import type { IRawDictionaries } from "./types.js"; + +function makeRaw(overrides: Partial = {}): IRawDictionaries { + return { + regions: [], + countries: [], + cities: [], + airports: [], + ...overrides, + }; +} + +describe("transformDictionaries — filtering", () => { + it("drops cities whose title.ru is purely ASCII (garbage-data guard)", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва", en: "Moscow" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "BAD", title: { ru: "Moscow", en: "Moscow" }, country_code: "RU", has_afl_flights: true, location: { lat: 0, lon: 0 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево", en: "SVO" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.cities.map((c) => c.code)).toEqual(["MOW"]); + }); + + it("drops airports with has_afl_flights=false", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + 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: false, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.airports.map((a) => a.code)).toEqual(["SVO"]); + }); + + it("drops airports whose title.ru is purely ASCII", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "SVO" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.airports).toHaveLength(0); + }); + + it("drops cities that end up with no afl airports", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "NOW", title: { ru: "Никогорск" }, country_code: "RU", has_afl_flights: true, location: { lat: 0, lon: 0 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.cities.map((c) => c.code)).toEqual(["MOW"]); + }); + + it("partitions city codes into ruCityCodes and otherCityCodes", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "PAR", title: { ru: "Париж" }, country_code: "FR", has_afl_flights: true, location: { lat: 48, lon: 2 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "CDG", city_code: "PAR", title: { ru: "Шарль-де-Голль" }, has_afl_flights: true, location: { lat: 48, lon: 2 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect([...d.ruCityCodes]).toEqual(["MOW"]); + expect([...d.otherCityCodes]).toEqual(["PAR"]); + }); +}); diff --git a/src/shared/dictionaries/transform.ts b/src/shared/dictionaries/transform.ts new file mode 100644 index 00000000..a3aef17b --- /dev/null +++ b/src/shared/dictionaries/transform.ts @@ -0,0 +1,136 @@ +/** + * Pure transform from raw dictionary responses to consumer-facing shapes. + * + * Ports the rules from Angular's DictionariesService.handleLoading: + * - drop cities whose title.ru is purely ASCII (garbage-data guard) + * - drop airports without afl flights or with ASCII title.ru + * - attach airports to their city (sorted by localized title) + * - drop cities that end up with no afl airports + * - enrich city.name, city.countryName from localized titles + * - build lookup maps keyed by uppercase code + * - partition cities into RU vs non-RU sets + * - flatten regions (Russia first, Australia filtered out) + */ + +import type { + IAirport, + ICity, + ICountry, + IDictionaries, + IRegion, + IRawAirport, + IRawDictionaries, + IRawRegion, +} from "./types.js"; + +const ASCII_ONLY = /^[a-zA-Z.,:; ]+$/; +const AUSTRALIA_REGION_ID = 500373; +const RUSSIA_REGION_ID = 500374; + +export function transformDictionaries( + raw: IRawDictionaries, + lang: string, +): IDictionaries { + const citiesAfterAsciiFilter = raw.cities.filter( + (c) => !ASCII_ONLY.test(c.title["ru"] ?? ""), + ); + + const airportsFiltered = raw.airports.filter( + (a) => + a.has_afl_flights === true && + !ASCII_ONLY.test(a.title["ru"] ?? ""), + ); + + const countriesByCode = new Map( + raw.countries.map((c) => [c.code, c]), + ); + + const citiesEnriched: ICity[] = citiesAfterAsciiFilter.map((c) => { + const airports = airportsFiltered + .filter((a) => a.city_code === c.code) + .map((a) => shapeAirport(a, lang)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const country = countriesByCode.get(c.country_code); + + return { + code: c.code, + name: c.title[lang] ?? c.code, + location: c.location ?? { lat: 0, lon: 0 }, + country_code: c.country_code, + countryName: country?.title[lang] ?? "", + has_afl_flights: airports.length > 0, + airports, + }; + }); + + const cities = citiesEnriched.filter((c) => c.has_afl_flights); + + const airports = cities.flatMap((c) => c.airports); + + const cityByCode = new Map( + cities.map((c) => [c.code.toUpperCase(), c]), + ); + const airportByCode = new Map( + airports.map((a) => [a.code.toUpperCase(), a]), + ); + + const ruCityCodes = new Set(); + const otherCityCodes = new Set(); + for (const c of cities) { + if (c.country_code === "RU") ruCityCodes.add(c.code); + else otherCityCodes.add(c.code); + } + + const countries: ICountry[] = raw.countries.map((c) => ({ + code: c.code, + name: c.title[lang] ?? c.code, + world_region_id: c.world_region_id, + })); + + const regions = buildRegions(raw.regions, countries, lang); + + return { + regions, + countries, + cities, + airports, + cityByCode, + airportByCode, + ruCityCodes, + otherCityCodes, + }; +} + +function shapeAirport(a: IRawAirport, lang: string): IAirport { + return { + code: a.code, + name: a.title[lang] ?? a.code, + city_code: a.city_code, + location: a.location ?? { lat: 0, lon: 0 }, + has_afl_flights: a.has_afl_flights, + }; +} + +function buildRegions( + raw: IRawRegion[], + countries: ICountry[], + lang: string, +): IRegion[] { + const filtered = raw.filter((r) => r.world_region_id !== AUSTRALIA_REGION_ID); + + const sorted = [...filtered].sort((a, b) => + (a.title[lang] ?? "").localeCompare(b.title[lang] ?? ""), + ); + const ruIdx = sorted.findIndex((r) => r.world_region_id === RUSSIA_REGION_ID); + if (ruIdx > 0) { + const [ru] = sorted.splice(ruIdx, 1); + if (ru) sorted.unshift(ru); + } + + return sorted.map((r) => ({ + id: r.world_region_id, + name: r.title[lang] ?? "", + countries: countries.filter((c) => c.world_region_id === r.world_region_id), + })); +}