Skip to content

Commit c925f53

Browse files
committed
refactor(map): 抽出三个规则引擎并补齐单测
继续「先测试后重构」:这次搬的是 map-store 里三段真正的业务判定。 它们此前都读整个 MapState,航迹那段还依赖模块级可变量 ctx.lastRoutePoint (跑一次污染下一次),全都没法脱离 Zustand 调用 —— 也就一直没有测试。 services/flight-alerts.ts 告警规则引擎(失速/爬升/下降/坡度/迎角/近地) services/hud-timer-rules.ts 计时器自动启停判定(3 种启动 × 3 种停止模式) services/map-telemetry.ts 航迹累积与机场标记归并 入参一律收窄成规则真正用到的字段,不再依赖 MapState 的整体形状; 航迹的「上一个点」与时间戳改成传入。计时器顺便把判定与副作用拆开: 纯函数只回答该做什么,store 负责去做。 map-store.ts 1524 → 1364 行。 补 42 例单测,重点在最不容易被肉眼发现的一类错误:告警阈值判错方向或 把危险与警告写反 —— 界面上照样有东西在闪,只是闪错了级别。 14 处变异(>= 改成 >、危险与警告分支对调、下降率误用 >=、坡度不取绝对值、 近地忽略起落架、空中允许停表、航迹裁错端、机场不去重…)全部被捕获。 改动中唯一的行为等价性问题已核实:原实现在「没起过且没在动」时会落到 停止分支,重构后直接返回。因为 startHudTimer 同时置起两个标志、 resetHudTimer 同时清掉,isHudTimerRunning 为真必然蕴含 hasHudTimerStarted 为真,那条路径本就不可达。已写进注释。
1 parent 70e507e commit c925f53

9 files changed

Lines changed: 826 additions & 184 deletions

File tree

docs/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
### 新增
1515

16+
- **三个规则引擎的单测**(42 例):飞行告警(阈值边界、危险优先于警告、各条独立开关)、
17+
HUD 计时器自动启停(3 种启动 × 3 种停止模式)、遥测派生(航迹裁剪、机场去重)。
18+
14 处变异全部被捕获。
1619
- **ESLint 门禁**(工程手册 §4.1 警告即错误):`npm run lint``--max-warnings=0`
1720
已接入 `npm run check` 与 CI。只收能抓真实缺陷的规则(浮动 Promise、误用 any、
1821
`no-base-to-string`),风格交给 `.editorconfig`。首次全量扫描报出 57 处,已全部清零。
@@ -24,6 +27,12 @@
2427

2528
### 调整
2629

30+
- **`map-store.ts` 再抽出三个规则引擎**`services/flight-alerts.ts`(告警判定)、
31+
`services/hud-timer-rules.ts`(计时器启停判定)、`services/map-telemetry.ts`
32+
(航迹累积与机场标记)。三者此前都读整个 `MapState`、且航迹依赖模块级可变量
33+
`ctx.lastRoutePoint`,没法脱离 Zustand 调用 —— 也就一直没有测试。
34+
现在入参收窄、状态改为传入,`map-store.ts` 1524 → 1364 行。
35+
计时器的判定与副作用也就此分开:纯函数只回答「该做什么」,store 负责做。
2736
- **`map-store.ts` 抽出纯解析层**:9 个解析函数移入
2837
`modules/map/services/map-response-parsers.ts``map-store.ts` 由 1726 行降到 1524 行。
2938
解析器不再依赖 store,可被直接调用与测试,并纳入架构门禁的「纯计算」白名单。

docs/DESIGN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ flowchart TD
151151
| M3 | 后端对齐:存储、设置、天气、空域接口 | 完成 |
152152
| M4 | 航空要素增强:进近设施、波束、等待航线、PAPI | 完成 |
153153
| M5 | 工程规范化 + CI/CD + 中间件同源托管 | 进行中 |
154-
| M6 | 纯计算与解析单测(86 例)+ 架构/i18n/ESLint 门禁 | 完成 |
154+
| M6 | 纯计算、解析与规则引擎单测(128 例)+ 架构/i18n/ESLint 门禁 | 完成 |
155155
| M7 | UI 与 store 测试、超大文件拆分 | 进行中 |
156156

157157
---
@@ -162,8 +162,8 @@ flowchart TD
162162

163163
| 差距 | 现状 | 为什么先不动 |
164164
|---|---|---|
165-
| §5.1 单文件 ~400 行 | 17 个文件超标,最大 `map-store.ts` 1524 行、`map-canvas.tsx` 1474| 按「先测试后重构」的顺序推进中:`map-store` 的纯解析部分已抽到 `services/map-response-parsers.ts` 并锁上单测(1726 → 1524 行)。剩余部分是 Zustand 状态与副作用,拆分前需要 store 级测试 |
166-
| §10 测试 | 纯计算、响应解析、报文解码与解析工具已覆盖(Vitest,86 例);**UI 与 store 编排仍无测试** | 组件测试要引 jsdom 与 testing-library,成本高于收益;先把最容易出错的几何与解析锁住 |
165+
| §5.1 单文件 ~400 行 | 17 个文件超标,最大 `map-canvas.tsx` 1474 行、`map-store.ts` 1364| 按「先测试后重构」的顺序推进中:`map-store` 已抽出解析层(`map-response-parsers`)与三个规则引擎(`flight-alerts` / `hud-timer-rules` / `map-telemetry`),每步都先补测试再搬,1726 → 1364 行。剩余是 Zustand 状态与图层副作用 |
166+
| §10 测试 | 纯计算、响应解析、报文解码与解析工具已覆盖(Vitest,128 例);**UI 与 store 编排仍无测试** | 组件测试要引 jsdom 与 testing-library,成本高于收益;先把最容易出错的几何与解析锁住 |
167167
| §7 i18n 覆盖 | de_DE **0/987**,11 个模块全部只有 zh/en | 有「当前语言 → en_US → key」回退链兜底,界面显示英文而非崩坏。航空术语机翻质量不可控,宁可留空也不要错译 |
168168

169169
`npm run check` 可一次跑完全部门禁(版本同步 / 架构 / i18n / ESLint / 类型 / 单测)。

src/modules/map/providers/map-store.ts

Lines changed: 22 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { create } from 'zustand';
22
import { PersistenceService } from '../../../core/services/persistence-service';
33
import { AppLogger } from '../../../core/utils/logger';
44
import {
5-
calculateDistanceNm,
65
pickString,
76
toJsonMap,
87
} from '../../../core/utils/parse-utils';
@@ -39,6 +38,13 @@ import {
3938
parseRunwayNavaid,
4039
parseTaxiwayFile,
4140
} from '../services/map-response-parsers';
41+
import { evaluateFlightAlerts } from '../services/flight-alerts';
42+
import { resolveHudTimerAction } from '../services/hud-timer-rules';
43+
import {
44+
appendRoutePoint,
45+
buildAirportsFromSnapshot,
46+
distanceInMeters,
47+
} from '../services/map-telemetry';
4248
import { fetchAirportWeather } from '../services/airport-weather';
4349
import { parseAirportDetail } from '../services/map-airport-parser';
4450

@@ -72,9 +78,7 @@ const DEFAULT_DESCENT_WARNING_FPM = -3000;
7278
const DEFAULT_DESCENT_DANGER_FPM = -5000;
7379

7480
/** 航迹最多保留的点数,避免长航班无限增长 */
75-
const MAX_ROUTE_POINTS = 4000;
7681
/** 相邻航迹点最小间隔(米),低于此值不记点 */
77-
const MIN_ROUTE_POINT_DISTANCE_M = 30;
7882
/** 判定「移动中」的地速门槛(kt) */
7983
const MOVING_GROUND_SPEED_KT = 1.5;
8084
/** UI 刷新限流间隔(ms)—— 地图重绘开销大 */
@@ -495,8 +499,11 @@ export const useMapStore = create<MapState>((set, get) => ({
495499
patch.isAircraftMoving = isMoving;
496500

497501
// 航迹累积:与上一点距离足够才记
498-
const nextRoute = appendRoutePoint(state.route, aircraft);
499-
if (nextRoute !== state.route) patch.route = nextRoute;
502+
const appended = appendRoutePoint(state.route, aircraft, ctx.lastRoutePoint);
503+
if (appended.appended) {
504+
patch.route = appended.route;
505+
ctx.lastRoutePoint = appended.lastPoint;
506+
}
500507

501508
// 起降点标记:onGround 翻转时打点
502509
const onGround = flightData.onGround;
@@ -507,7 +514,16 @@ export const useMapStore = create<MapState>((set, get) => ({
507514
}
508515
ctx.lastOnGround = onGround;
509516

510-
handleAutoHudTimer(state, aircraft, isMoving, patch);
517+
// 判定是纯函数,副作用留在这里
518+
const timerAction = resolveHudTimerAction(state, aircraft, isMoving);
519+
if (timerAction === 'start') {
520+
useMapStore.getState().startHudTimer();
521+
patch.hasHudTimerStarted = true;
522+
patch.isHudTimerRunning = true;
523+
} else if (timerAction === 'stop') {
524+
useMapStore.getState().pauseHudTimer();
525+
patch.isHudTimerRunning = false;
526+
}
511527
} else if (!isConnected) {
512528
patch.aircraft = null;
513529
patch.aiAircraft = [];
@@ -1252,185 +1268,10 @@ function isLayerStyle(value: unknown): value is MapLayerStyle {
12521268
}
12531269

12541270

1255-
function distanceInMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
1256-
return calculateDistanceNm(lat1, lon1, lat2, lon2) * 1852;
1257-
}
1258-
1259-
/** 航迹追加:距上一点足够远才记,并裁剪到最大点数 */
1260-
function appendRoutePoint(
1261-
route: MapRoutePoint[],
1262-
aircraft: MapAircraftState,
1263-
): MapRoutePoint[] {
1264-
const last = ctx.lastRoutePoint;
1265-
if (
1266-
last &&
1267-
distanceInMeters(
1268-
last.latitude,
1269-
last.longitude,
1270-
aircraft.position.latitude,
1271-
aircraft.position.longitude,
1272-
) < MIN_ROUTE_POINT_DISTANCE_M
1273-
) {
1274-
return route;
1275-
}
1276-
ctx.lastRoutePoint = aircraft.position;
1277-
const next: MapRoutePoint[] = [
1278-
...route,
1279-
{
1280-
latitude: aircraft.position.latitude,
1281-
longitude: aircraft.position.longitude,
1282-
altitude: aircraft.altitude,
1283-
groundSpeed: aircraft.groundSpeed,
1284-
timestamp: new Date(),
1285-
},
1286-
];
1287-
return next.length > MAX_ROUTE_POINTS ? next.slice(next.length - MAX_ROUTE_POINTS) : next;
1288-
}
1289-
1290-
function buildAirportsFromSnapshot(snapshot: FlightDataSnapshot): MapAirportMarker[] {
1291-
const result: MapAirportMarker[] = [];
1292-
const seen = new Set<string>();
1293-
1294-
const push = (
1295-
airport: { icaoCode: string; name: string; latitude: number; longitude: number } | undefined,
1296-
isPrimary: boolean,
1297-
) => {
1298-
if (!airport) return;
1299-
const code = airport.icaoCode.trim().toUpperCase();
1300-
if (code.length === 0 || seen.has(code)) return;
1301-
if (!isValidCoordinate(airport.latitude, airport.longitude)) return;
1302-
seen.add(code);
1303-
result.push({
1304-
code,
1305-
name: airport.name,
1306-
position: { latitude: airport.latitude, longitude: airport.longitude },
1307-
isPrimary,
1308-
});
1309-
};
1310-
1311-
push(snapshot.departureAirport, true);
1312-
push(snapshot.destinationAirport, true);
1313-
push(snapshot.alternateAirport, true);
1314-
push(snapshot.nearestAirport, false);
1315-
for (const airport of snapshot.suggestedAirports) push(airport, false);
1316-
1317-
return result;
1318-
}
1319-
1320-
/**
1321-
* 飞行告警评估
1322-
* 与桌面版一致:失速、超速、爬升/下降率超限、大坡度、大迎角
1323-
*/
1324-
function evaluateFlightAlerts(state: MapState, flightData: FlightData): MapFlightAlert[] {
1325-
if (!state.alertsEnabled || !state.isConnected) return [];
1326-
1327-
const alerts: MapFlightAlert[] = [];
1328-
const enabled = (id: string) => !state.disabledAlertIds.includes(id);
1329-
1330-
if (enabled('stall_warning') && flightData.stallWarning === true) {
1331-
alerts.push({ id: 'stall_warning', level: 'danger', message: 'STALL' });
1332-
}
1333-
1334-
const verticalSpeed = flightData.verticalSpeed;
1335-
if (verticalSpeed !== undefined) {
1336-
if (enabled('excessive_climb_rate')) {
1337-
if (verticalSpeed >= state.climbRateDangerFpm) {
1338-
alerts.push({ id: 'excessive_climb_rate', level: 'danger', message: 'CLIMB RATE' });
1339-
} else if (verticalSpeed >= state.climbRateWarningFpm) {
1340-
alerts.push({ id: 'excessive_climb_rate', level: 'warning', message: 'CLIMB RATE' });
1341-
}
1342-
}
1343-
if (enabled('excessive_descent_rate')) {
1344-
if (verticalSpeed <= state.descentRateDangerFpm) {
1345-
alerts.push({ id: 'excessive_descent_rate', level: 'danger', message: 'SINK RATE' });
1346-
} else if (verticalSpeed <= state.descentRateWarningFpm) {
1347-
alerts.push({ id: 'excessive_descent_rate', level: 'warning', message: 'SINK RATE' });
1348-
}
1349-
}
1350-
}
1351-
1352-
const bank = flightData.bank;
1353-
if (enabled('bank_angle') && bank !== undefined && Math.abs(bank) >= 45) {
1354-
alerts.push({
1355-
id: 'bank_angle',
1356-
level: Math.abs(bank) >= 60 ? 'danger' : 'warning',
1357-
message: 'BANK ANGLE',
1358-
});
1359-
}
13601271

1361-
const aoa = flightData.angleOfAttack;
1362-
if (enabled('high_aoa') && aoa !== undefined && aoa >= 15) {
1363-
alerts.push({ id: 'high_aoa', level: aoa >= 18 ? 'danger' : 'caution', message: 'HIGH AOA' });
1364-
}
13651272

1366-
// 近地告警:低无线电高度 + 大下降率
1367-
const radioAltitude = flightData.radioAltitude;
1368-
if (
1369-
enabled('terrain_warning') &&
1370-
state.showTerrainWarning &&
1371-
radioAltitude !== undefined &&
1372-
radioAltitude < 1000 &&
1373-
verticalSpeed !== undefined &&
1374-
verticalSpeed < -1500 &&
1375-
flightData.gearDown !== true
1376-
) {
1377-
alerts.push({ id: 'terrain_warning', level: 'danger', message: 'TERRAIN' });
1378-
}
13791273

1380-
return alerts;
1381-
}
13821274

1383-
/**
1384-
* HUD 计时器自动启停
1385-
*
1386-
* 起:按所选模式判定「开始滑行 / 推出 / 任意移动」
1387-
* 停:按所选模式判定「稳定落地 / 脱离跑道 / 到达停机位」
1388-
*/
1389-
function handleAutoHudTimer(
1390-
state: MapState,
1391-
aircraft: MapAircraftState,
1392-
isMoving: boolean,
1393-
patch: Partial<MapState>,
1394-
): void {
1395-
if (!state.autoHudTimerEnabled) return;
1396-
1397-
const onGround = aircraft.onGround ?? true;
1398-
const groundSpeed = aircraft.groundSpeed ?? 0;
1399-
1400-
// ── 自动启动 ──
1401-
if (!state.hasHudTimerStarted && isMoving) {
1402-
const shouldStart =
1403-
state.autoTimerStartMode === 'anyMovement'
1404-
? true
1405-
: state.autoTimerStartMode === 'pushback'
1406-
? onGround && aircraft.parkingBrake !== true
1407-
: // runwayMovement:地速超过 30kt 视为进跑道加速
1408-
onGround && groundSpeed >= 30;
1409-
1410-
if (shouldStart) {
1411-
useMapStore.getState().startHudTimer();
1412-
patch.hasHudTimerStarted = true;
1413-
patch.isHudTimerRunning = true;
1414-
}
1415-
return;
1416-
}
1417-
1418-
// ── 自动停止 ──
1419-
if (state.isHudTimerRunning && onGround) {
1420-
const shouldStop =
1421-
state.autoTimerStopMode === 'parkingArrival'
1422-
? groundSpeed < 1 && aircraft.parkingBrake === true
1423-
: state.autoTimerStopMode === 'runwayExitAfterLanding'
1424-
? groundSpeed < 30
1425-
: // stableLanding:完全停稳
1426-
groundSpeed < 5;
1427-
1428-
if (shouldStop) {
1429-
useMapStore.getState().pauseHudTimer();
1430-
patch.isHudTimerRunning = false;
1431-
}
1432-
}
1433-
}
14341275

14351276
/**
14361277
* 上报新出现的地形告警

0 commit comments

Comments
 (0)