diff --git a/src/features/flights-map/filterRoutes.test.ts b/src/features/flights-map/filterRoutes.test.ts new file mode 100644 index 00000000..6a104d48 --- /dev/null +++ b/src/features/flights-map/filterRoutes.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { filterRoutes } from "./filterRoutes.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IFlightRoute, IFlightsMapFilterState } from "./types.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; + +const raw: IRawDictionaries = { + regions: [], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "US", title: { ru: "США" }, world_region_id: 1 }, + ], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "AER", title: { ru: "Сочи" }, country_code: "RU", has_afl_flights: true, location: { lat: 43, lon: 39 } }, + { code: "NYC", title: { ru: "Нью-Йорк" }, country_code: "US", has_afl_flights: true, location: { lat: 40, lon: -74 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "JFK", city_code: "NYC", title: { ru: "Джон Кеннеди" }, has_afl_flights: true, location: { lat: 40, lon: -74 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "AER", city_code: "AER", title: { ru: "Сочи" }, has_afl_flights: true, location: { lat: 43, lon: 39 } }, + ], +}; + +const d: IDictionaries = transformDictionaries(raw, "ru"); + +function filter( + overrides: Partial = {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +describe("filterRoutes — empty and passthrough", () => { + it("returns [] for no routes", () => { + expect(filterRoutes([], filter(), d)).toEqual([]); + }); + + it("returns routes unchanged when no toggles are active", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "NYC"], isDirect: true }, + ]; + expect(filterRoutes(routes, filter(), d)).toEqual(routes); + }); +}); + +describe("filterRoutes — domestic", () => { + it("keeps only routes where every city is RU", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "NYC"], isDirect: true }, + ]; + const out = filterRoutes(routes, filter({ domestic: true }), d); + expect(out).toHaveLength(1); + expect(out[0]!.route).toEqual(["MOW", "LED"]); + }); +}); + +describe("filterRoutes — international", () => { + it("keeps only routes with at least one non-RU city", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "NYC"], isDirect: true }, + ]; + const out = filterRoutes(routes, filter({ international: true }), d); + expect(out).toHaveLength(1); + expect(out[0]!.route).toEqual(["MOW", "NYC"]); + }); +}); + +describe("filterRoutes — both domestic AND international", () => { + it("applies no domestic/intl filter when both are true", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "NYC"], isDirect: true }, + ]; + const out = filterRoutes( + routes, + filter({ domestic: true, international: true }), + d, + ); + expect(out).toEqual(routes); + }); +}); + +describe("filterRoutes — connections", () => { + it("keeps only non-direct routes", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "X", "LED"], isDirect: false }, + ]; + const out = filterRoutes(routes, filter({ connections: true }), d); + expect(out).toHaveLength(1); + expect(out[0]!.route).toEqual(["MOW", "X", "LED"]); + }); +}); + +describe("filterRoutes — airport-code normalization", () => { + it("treats airport codes (SVO, JFK) as their city codes (MOW, NYC)", () => { + const routes: IFlightRoute[] = [ + { route: ["SVO", "JFK"], isDirect: true }, + { route: ["SVO", "LED"], isDirect: true }, + ]; + const intl = filterRoutes(routes, filter({ international: true }), d); + expect(intl).toHaveLength(1); + expect(intl[0]!.route).toEqual(["SVO", "JFK"]); + + const dom = filterRoutes(routes, filter({ domestic: true }), d); + expect(dom).toHaveLength(1); + expect(dom[0]!.route).toEqual(["SVO", "LED"]); + }); + + it("drops routes containing unknown codes when any toggle is active", () => { + const routes: IFlightRoute[] = [{ route: ["MOW", "???"], isDirect: true }]; + expect(filterRoutes(routes, filter({ domestic: true }), d)).toEqual([]); + expect(filterRoutes(routes, filter({ international: true }), d)).toEqual([]); + }); +}); + +describe("filterRoutes — combo", () => { + it("applies domestic + connections together", () => { + const routes: IFlightRoute[] = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "AER", "LED"], isDirect: false }, + { route: ["MOW", "NYC"], isDirect: false }, + ]; + const out = filterRoutes( + routes, + filter({ domestic: true, connections: true }), + d, + ); + expect(out).toHaveLength(1); + expect(out[0]!.route).toEqual(["MOW", "AER", "LED"]); + }); +}); diff --git a/src/features/flights-map/filterRoutes.ts b/src/features/flights-map/filterRoutes.ts new file mode 100644 index 00000000..f9363972 --- /dev/null +++ b/src/features/flights-map/filterRoutes.ts @@ -0,0 +1,45 @@ +/** + * Client-side route filter matching Angular `filterRoutes`. + * + * Rules: + * - domestic && !international → keep routes where every city is RU. + * - international && !domestic → keep routes with at least one non-RU city. + * - both domestic && international → no domestic/intl filter (show all). + * - connections → keep only routes where isDirect is false. + * + * City codes and airport codes both appear in route arrays; we normalize each + * entry to its city code before set-membership checks. + */ + +import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js"; +import type { IFlightRoute, IFlightsMapFilterState } from "./types.js"; + +export function filterRoutes( + routes: IFlightRoute[], + filter: Pick, + dictionaries: IDictionaries, +): IFlightRoute[] { + const { domestic, international, connections } = filter; + + const toCityCode = (code: string): string => + getCityCodeByAirportCode(dictionaries, code) ?? code; + + const isDomestic = (r: IFlightRoute): boolean => + r.route.every((code) => dictionaries.ruCityCodes.has(toCityCode(code))); + + const isInternational = (r: IFlightRoute): boolean => + r.route.some((code) => dictionaries.otherCityCodes.has(toCityCode(code))); + + const hasConnections = (r: IFlightRoute): boolean => !r.isDirect; + + const predicates: Array<(r: IFlightRoute) => boolean> = []; + + if (domestic && !international) predicates.push(isDomestic); + else if (international && !domestic) predicates.push(isInternational); + + if (connections) predicates.push(hasConnections); + + if (predicates.length === 0) return routes; + return routes.filter((r) => predicates.every((p) => p(r))); +}