diff --git a/AGENTS.md b/AGENTS.md index 5cf33478..458ddf14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,78 @@ pnpm test:e2e # React app (port 8080) pnpm test:e2e:angular # Angular app (port 4203) ``` -## Architecture +## Current State -**Stack:** Modern.js 2.70.8 (Rspack), React 18.2, Module Federation 2.3.3 +React 18 SSR application built with **Modern.js 2.70.8** (Rspack bundler) and **Module Federation 2.3.3**. The app is a remote frontend component embeddable in the customer's channel apps (Web, PWA). + +**Stack:** Modern.js 2.70.8, React 18.2, Rspack, Module Federation 2.3.3, i18next (9 languages), PrimeReact, Leaflet, SignalR, OpenTelemetry, Vitest, Playwright. + +**Source:** `src/` (file-based routing under `src/routes/`). Legacy Angular 12 SPA in `ClientApp/` (read-only reference, not deployed). + +**Builds:** `pnpm build:standalone` (SSR server at `dist/standalone/`), `pnpm build:remote` (MF remote at `dist/remote/` with `mf-manifest.json`). + +**Dev:** `pnpm dev` (Modern.js on :8081), `pnpm dev:full` (proxy on :8080 with API forwarding via curl to bypass WAF). + +**Known constraint:** Modern.js 3.x upgrade is blocked by `@module-federation/modern-js` ESM incompatibilities (broken `__filename`/`require` in ESM bundles, missing `api.modifyWebpackConfig` in Rsbuild 2.0). React Router v7 future flags are enabled to suppress deprecation warnings. + +## Contractual Requirements + +The following are contractual hard constraints for the remote frontend component. + +### 1. Tech Stack + +- **ModernJS (SSR)** for the frontend framework. +- **Module Federation 2.0**. Any bundler with MF 2.0 support is acceptable: Webpack 5, Rsbuild, Rspack, or Vite. +- Must emit `mf-manifest.json` at `https:///mf-manifest.json` exposing components and logic. Reference: https://module-federation.io/guide/basic/webpack.html. +- **React 18+**, Concurrent Mode compatible. +- `` support required when async loading is used. +- Component bodies must be side-effect free — **no `fetch` outside `useEffect`**. +- Dynamic imports must use `React.lazy()`. + +### 2. Data & Integrations + +- Consumes customer REST APIs, JSON payloads only. +- Rendered data must stay consistent with API responses (no stale state leaking into the UI). + +### 3. Performance + +- Must sustain **100 RPS**. + +### 4. Availability & Fault Tolerance + +- VMs hosting the component must be geographically distributed. +- 24/7/365 availability; recovery time ≤ 6 hours after infra is restored. + +### 5. Security + +- Component must be isolated — no attack surface exposed to other components of the host site. + +### 6. SEO & Accessibility + +- SEO optimization required. +- Render microdata: **JSON-LD** and **OpenGraph**. +- Web analytics integrations: **Yandex.Metrica, CTM, Variocube, Key-Astrom (Dynatrace)**. + +### 7. Cross-Platform + +- Embeddable in multiple channel apps (Web, PWA). +- Fully responsive ("fluid") layout across all screen sizes. + +### 8. Logging & Monitoring + +- Frontend log collection in a customer-specified format, shipped to the customer's log aggregation system. +- System event monitoring with export to a metrics aggregator. + +### 9. Module Structure + +- Must conform to the customer's standard remote frontend module structure for uniform deployment. + +### 10. Design + +- Implement against customer-provided mockups using the customer's design system. +- Must embed other customer remote components when available. + +## Architecture **Source structure:** - `src/routes/` — File-based routing (React Router v7) @@ -100,14 +169,8 @@ pnpm test:e2e:angular # Angular app (port 4203) - **Remote** — Dockerfile.remote (nginx serving static files) - CI/CD: `.github/workflows/ci.yml`, `.github/workflows/deploy.yml` -## Known Constraints - -- Modern.js 3.x blocked by `@module-federation/modern-js` ESM incompatibilities -- React Router v7 future flags enabled to suppress deprecation warnings -- Legacy Angular 12 SPA in `ClientApp/` (read-only reference) - ## Commit Rules -- No `Co-Authored-By` lines -- English commit messages, focus on "why" not "what" -- Commit autonomously when stable; ask before pushing/force-pushing +- Never add `Co-Authored-By` lines to commit messages. +- Commit messages in English, concise, focused on "why" not "what". +- Commit autonomously when changes are complete and stable — no need to ask for permission. Group related edits into logical commits. Still ask before pushing, force-pushing, or any destructive git operation. diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx index 185ea20b..6b087f02 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import { FlightsMapStartPage } from "./FlightsMapStartPage.js"; import type * as DictionariesModuleNS from "@/shared/dictionaries/index.js"; @@ -578,3 +578,212 @@ describe("4.1.8 / 4.1.1-R26: Flight-Map filter cross-section isolation", () => { expect(filterValue["international"]).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// TZ §4.1.1 ¶4 / ¶9: Flight-Map first-entry toggle defaults +// --------------------------------------------------------------------------- + +describe("4.1.1-R14/R21: Flight-Map first-entry toggle defaults per TZ §4.1.1", () => { + let originalGeolocation: Geolocation | undefined; + + beforeEach(() => { + lastMapFilterProps = null; + dictState.dictionaries = buildDictionaries({ + regions: [], + countries: [], + cities: [ + { + code: "MOW", + title: { ru: "Москва" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: 55.75, lon: 37.62 }, + }, + ], + airports: [ + { + code: "SVO", + city_code: "MOW", + title: { ru: "Шереметьево" }, + has_afl_flights: true, + location: { lat: 55.75, lon: 37.62 }, + }, + ], + }); + dictState.loading = false; + dictState.error = null; + searchState.routes = []; + searchState.loading = false; + searchState.error = null; + resetCrossSectionStore(); + + // Save original geolocation + originalGeolocation = navigator.geolocation; + }); + + afterEach(() => { + // Restore geolocation + Object.defineProperty(navigator, "geolocation", { + configurable: true, + writable: true, + value: originalGeolocation, + }); + }); + + it("4.1.1-R14: with geo consent (success) — domestic ON, international ON, transfers OFF", async () => { + // Mock geolocation success callback + let geoCallback: PositionCallback | null = null; + Object.defineProperty(navigator, "geolocation", { + configurable: true, + writable: true, + value: { + getCurrentPosition: (cb: PositionCallback) => { + geoCallback = cb; + // Simulate async position callback on next tick + setTimeout(() => { + cb({ + coords: { + latitude: 55.75, + longitude: 37.62, + accuracy: 50, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as GeolocationPosition); + }, 0); + }, + }, + }); + + const { rerender } = render(); + + // Wait for geolocation callback to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + rerender(); + + const filterValue = lastMapFilterProps!["value"] as Record; + expect(filterValue["domestic"]).toBe(true); // R14: domestic ON + expect(filterValue["international"]).toBe(true); // R14: international ON + expect(filterValue["connections"]).toBe(false); // R14: transfers OFF + }); + + it("4.1.1-R21: without geo consent (error/denied) — all three toggles OFF", async () => { + // Mock geolocation failure (permission denied, etc.) + Object.defineProperty(navigator, "geolocation", { + configurable: true, + writable: true, + value: { + getCurrentPosition: ( + _cb: PositionCallback, + errorCallback: PositionErrorCallback, + ) => { + // Simulate async error callback on next tick + setTimeout(() => { + errorCallback({ + code: 1, // PERMISSION_DENIED + message: "User denied geolocation", + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + } as GeolocationPositionError); + }, 0); + }, + }, + }); + + const { rerender } = render(); + + // Wait for geolocation error callback to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + rerender(); + + const filterValue = lastMapFilterProps!["value"] as Record; + expect(filterValue["domestic"]).toBe(false); // R21: domestic OFF + expect(filterValue["international"]).toBe(false); // R21: international OFF + expect(filterValue["connections"]).toBe(false); // R21: transfers OFF + }); + + it("R14: toggling off domestic when geo succeeds should not re-enable international", async () => { + // Mock geolocation success + Object.defineProperty(navigator, "geolocation", { + configurable: true, + writable: true, + value: { + getCurrentPosition: (cb: PositionCallback) => { + setTimeout(() => { + cb({ + coords: { + latitude: 55.75, + longitude: 37.62, + accuracy: 50, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as GeolocationPosition); + }, 0); + }, + }, + }); + + const { rerender } = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + rerender(); + + // At this point, geo should have fired, setting both domestic and international to true + let filterValue = lastMapFilterProps!["value"] as Record; + expect(filterValue["domestic"]).toBe(true); + expect(filterValue["international"]).toBe(true); + }); + + it("R21: stored map snapshot takes precedence over geo defaults", async () => { + // Pre-set a map snapshot with specific toggle values + const mapSnap: MapFilterSnapshot = { + departure: "MOW", + arrival: null, + date: null, + showInternal: false, + showInternational: true, + showTransfers: false, + }; + setMapFilter(mapSnap); + + // Mock geolocation success (should NOT override the snapshot) + Object.defineProperty(navigator, "geolocation", { + configurable: true, + writable: true, + value: { + getCurrentPosition: (cb: PositionCallback) => { + setTimeout(() => { + cb({ + coords: { + latitude: 55.75, + longitude: 37.62, + accuracy: 50, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as GeolocationPosition); + }, 0); + }, + }, + }); + + const { rerender } = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + rerender(); + + const filterValue = lastMapFilterProps!["value"] as Record; + // Snapshot values should be preserved, not overwritten by geo defaults + expect(filterValue["domestic"]).toBe(false); + expect(filterValue["international"]).toBe(true); + }); +}); diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index 7db74d9c..03e068c9 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -121,7 +121,14 @@ export const FlightsMapStartPage: FC = ({ setFilterState((prev) => prev.departure || prev.arrival ? prev - : { ...prev, departure: cityCode }, + : { + ...prev, + departure: cityCode, + // TZ §4.1.1 ¶4: with geo consent, enable domestic + international + domestic: true, + international: true, + // connections stays OFF per R14 + }, ), });