Add buildCountryCityRows helper for regional picker grid

This commit is contained in:
2026-04-17 15:02:04 +03:00
parent aa7433b50b
commit 6820a11e83
2 changed files with 262 additions and 0 deletions
@@ -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> = {}): IRawDictionaries {
return { regions: [], countries: [], cities: [], airports: [], ...overrides };
}
function dict(raw: Partial<IRawDictionaries>): 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("Беларусь");
});
});
@@ -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;
}