Align Flight-Map first-entry toggle defaults with TZ 4.1.1-R14/R21
This commit is contained in:
@@ -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
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user