Align Flight-Map first-entry toggle defaults with TZ 4.1.1-R14/R21

This commit is contained in:
2026-04-21 19:22:16 +03:00
parent 4b6cb5bc40
commit fbb84fc0da
3 changed files with 292 additions and 13 deletions
+74 -11
View File
@@ -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://<domain>/mf-manifest.json` exposing components and logic. Reference: https://module-federation.io/guide/basic/webpack.html.
- **React 18+**, Concurrent Mode compatible.
- `<Suspense>` 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.
@@ -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(<FlightsMapStartPage />);
// Wait for geolocation callback to fire
await new Promise((resolve) => setTimeout(resolve, 50));
rerender(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
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(<FlightsMapStartPage />);
// Wait for geolocation error callback to fire
await new Promise((resolve) => setTimeout(resolve, 50));
rerender(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
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(<FlightsMapStartPage />);
await new Promise((resolve) => setTimeout(resolve, 50));
rerender(<FlightsMapStartPage />);
// At this point, geo should have fired, setting both domestic and international to true
let filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
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(<FlightsMapStartPage />);
await new Promise((resolve) => setTimeout(resolve, 50));
rerender(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
// Snapshot values should be preserved, not overwritten by geo defaults
expect(filterValue["domestic"]).toBe(false);
expect(filterValue["international"]).toBe(true);
});
});
@@ -121,7 +121,14 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
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
},
),
});