第24章 - 测试、国际化与贡献流程

前两章分别讲了“写插件”和“改源码”,这一章讲怎么把改动合进主干:本地要跑哪些门禁、测试怎么写、文案怎么翻译、文档与生成物怎么保持一致,以及 CI 红了先看哪里。GeoLibre 的门禁设计成一个反复出现的模式——棘轮(ratchet):指标只能变好,不能变坏。

说明:本章基于 GeoLibre 3.4 的 package.json、docs/contributing.md、docs/maintenance.md、docs/i18n.md、playwright.config.ts 与仓库现状(tests/ 下 858 个 *.test.ts、e2e/ 下 56 个 *.spec.ts)。

24.1 贡献流程

24.1.1 四种参与方式

方式 做法
报 bug / 提需求 开 issue,写清复现步骤、期望与实际,并注明操作系统、Web 还是桌面构建
改文档 改 docs/(就是文档站本体)
修 bug / 做功能 改应用或某个 package
写插件 不需要 fork 主仓库,按第21章的 Plugin API 走

计划做大改动时先开 issue 对齐方案,再投入 PR。

24.1.2 一个 PR 的生命周期

  1. 从 main 切分支:git switch -c feat/short-description。永远不要直接提交到 main。
  2. 改动保持聚焦,跟随周围代码的风格,不要顺手引入新模式。
  3. 跑质量检查(24.3),确认通过。
  4. 用 Conventional Commits 风格提交:feat:、fix:、docs:、refactor:、chore:。
  5. 推分支、对 main 开 PR;模板会问“改了什么/为什么”“怎么测的”“关联哪个 issue”。
  6. 等评审。自动评审可能留行内评论:要么改,要么说明为什么不适用。

24.1.3 格式化与提交钩子

.pre-commit-config.yaml 按语言分工,提交即自动改写文件:

工具 覆盖范围 配置
ruff .py、.pyi、.ipynb(format + lint --fix) ruff.toml,行宽 100,规则 F/I/W/E
oxfmt JS/TS/JSON/CSS/YAML/TOML .oxfmtrc.json,2 空格、双引号、分号、宽 100
ESLint 应用与包的 .tsx 等 见 24.3;--max-warnings 受棘轮约束
其它 check-yaml/check-json/check-toml、EOF、行尾空白、mixed-line-ending --fix=lf .gitattributes 统一 LF
strip-notebook-outputs 所有被跟踪的 .ipynb 用 nbstripout 就地重写,仓库里不允许带输出的 notebook
npm-build 整个应用 本地钩子,跑一次完整生产构建,慢

两条实践建议:

  1. 用 pre-commit run --files <你改的每个文件>,不要 --all-files(会重排整个仓库、动到你没碰的文件)。
  2. 已经构建过就加 SKIP=npm-build pre-commit run --files ... 跳过构建钩子。

pre-commit.ci 在每个 PR(含 fork)上跑同一批钩子并自动提交 style: auto-format 修复;它跳过 npm-build 与 eslint(需要完整工具链)。它不在 push 到 main 时运行,所以自动格式化不会绕过评审。

注意:strip-notebook-outputs 会对所有跟踪的 notebook 生效,包括你正在迭代的那份。提交任何文件都可能抹掉本地 notebook 的输出——重新跑一次,或留一份未跟踪的副本。

24.2 环境与依赖

24.2.1 前置条件

依赖 版本/说明
Node.js 22+;.nvmrc 锁主版本,nvm use 直接生效
Rust 桌面构建需要(rustup);rust-toolchain.toml 选 CI 用的 stable 通道
Linux webkit2gtk 与 libayatana-appindicator(Tauri 前置)
Python 3.10+,给 pre-commit、backend 与其测试用
包管理器 npm(仓库跟踪 package-lock.json)

24.2.2 装起来与跑起来

git clone https://github.com/YOUR_GITHUB_USERNAME/GeoLibre.git
cd GeoLibre
npm install                                  # 根目录一次装好所有 workspace
pre-commit install                           # 装提交钩子
pip install -e "backend/geolibre_server[test]"   # 不装,矢量/栅格/SQL/ML 测试会静默跳过

npm run dev          # 浏览器开发服务器 → http://localhost:5173
npm run tauri:dev    # 桌面应用(文件对话框、本地 MBTiles/栅格必需)

24.2.3 仓库布局(找文件用)

路径 内容
apps/geolibre-desktop Tauri + React 应用(壳、组合、Tauri I/O)
packages/core 领域类型、Zustand store、工程格式
packages/map MapLibre 集成与图层同步(含另三种引擎适配)
packages/ui Tailwind + shadcn 原语
packages/plugins Plugin API 与内置插件
packages/processing 客户端算法注册表
packages/embed 类型化 iframe 客户端
packages/collab-core 协作协议共享层
workers/ viewer、collab、collab-node、tiles、ai-proxy
backend/geolibre_server 可选 FastAPI sidecar
backend/geolibre_server_api 参考用的多用户项目/身份 API
python/ Jupyter widget 与 MCP 服务器
e2e/ Playwright 端到端测试
tests/ 前端单元与组件测试
docs/ 文档站(Zensical,读 mkdocs.yml)

24.3 质量门全览

24.3.1 三个入口

命令 覆盖 需要什么
npm run ci:web lint、共享依赖、i18n catalog、gallery、插件文档、typecheck:fast、typecheck:tests、前端单元测试 只要 Node
npm run ci:frontend i18n 检查 → gallery → 插件文档 → 构建 → 前端覆盖率 → 未测模块 → worker 检查 只要 Node(但会完整构建)
npm run ci lint + check:shared-deps + ci:frontend + typecheck:tests + ci:backend + check:rust 完整工具链(Node + Python + Rust)

只改文档或前端时,npm run ci:web 是快速门;要合并前跑 npm run ci(CI 跑的就是它)。

24.3.2 门禁清单

步骤 命令 管什么
Lint npm run lint ESLint 覆盖 apps/、packages/、workers/、tests/,--max-warnings 242
共享依赖 npm run check:shared-deps 应用与 packages/plugins 同时声明的依赖必须范围一致
i18n 工具文案 npm run i18n:tools:check en.json 里的处理工具串与注册表一致(npm run i18n:tools 重生成)
Gallery npm run gallery:check docs/gallery.md 与 docs/demos.md 摘要匹配 scripts/demo-gallery.json
插件文档 npm run plugins:docs:check docs/user-guide/plugins.md 的内置插件表匹配注册表
构建 npm run build tsc -b + Vite 构建(含启动包预算门禁)
前端测试+覆盖率 npm run test:frontend:coverage tests/ 全量 + 覆盖率地板
未测模块 npm run check:untested-modules 500 行以上且无测试加载的源文件,基线棘轮
Worker npm run test:worker 五个 worker 的类型检查 + collab-node 测试
测试类型 npm run typecheck:tests tsc 检查 tests/,错误数棘轮(当前 --max-errors 0)
后端 npm run test:backend:coverage sidecar 的 pytest + 覆盖率地板 55%
Docker npm run test:docker 容器部署与会话策略脚本的 pytest
Rust npm run check:rust Tauri crate 的 cargo check

24.3.3 CI 的作业与分流

.github/workflows/ci.yml 不是一个串行大管道,而是并行作业:lint(“Lint and type check”)、checks(“Build and test”)、e2e(“E2E core (Playwright)”)、backend、containers、rust,以及两个集成作业 sidecar-mssql、identity-integration;最前面的 changes 作业持有路径过滤器。

规则 说明
PR 必跑 lint 与 “Build and test” 始终运行
按路径触发 backend、Rust、containers、E2E、数据库与 OIDC 集成只在改动相关路径时运行
唯一必过检查 汇总作业 CI passed:任一作业失败它就失败,分支保护只要求它
新作业 必须加进 ci-passed 的 needs 列表,否则它的失败不会阻塞合并
慢速 e2e 打 full-e2e 标签可在 PR 上跑 nightly 的功能规格
预览冒烟 每个 PR 的 Cloudflare 预览部署后有一次冒烟:/demo/ 必须完成地图加载、无 console 错误、自身主机无 4xx/5xx

提示:给 npm run ci 加一步时,同时把它加进 ci:frontend 或 ci:backend 之一——CI 才拿得到。

24.3.4 只跑你关心的那一段

不必每次都跑全量。按改动范围挑:

改动 最小验证
只改文案/组件 npm run typecheck:fast + node --import tsx --test tests/<name>.test.ts
动了处理工具注册表 npm run i18n:tools + 对应的 tests/*-tools.test.ts
动了内置插件注册 npm run plugins:docs + npm run test:frontend
动了 workers/ npm run test:worker
动了 sidecar npm run test:backend(先装 test extra)
动了 Tauri crate npm run check:rust
想要 PR 前的完整信号 npm run ci(或前端改动用 npm run ci:web)

24.4 前端测试

24.4.1 运行方式

前端测试跑在 node --test 上,用 tsx 加载 TS(只剥类型、不做类型检查):

npm run test:frontend                            # tests/*.test.ts 全量
npm run test:frontend:coverage                   # 附带覆盖率(有地板)
node --import tsx --test tests/<name>.test.ts    # 单个文件

tsx 不检查类型,所以“测试照跑、照样通过”不代表测试里的假对象还与真实接口一致——这正是 typecheck:tests 存在的理由(见 24.8.3)。

24.4.2 组件测试的六条规矩

React 面板也跑在同一个 runner 里,对着一份 happy-dom + Testing Library,背后没有地图。样板看 tests/component-*.test.ts:

import { fireEvent, render, screen, useAppStore } from "./helpers/dom";
import { createElement } from "react";

const { LayerPanel } = await import("../apps/geolibre-desktop/src/components/panels/LayerPanel");
  1. 必须动态 import():helper 注册的 loader 钩子只对“注册之后加载”的模块生效,静态 import 在钩子之前就执行了。
  2. 状态用 useAppStore.setState(...) 铺;断言看用户可见文本或 store 结果。
  3. mapControllerRef 传 { current: null }——需要真地图才能测的面板,说明逻辑该挪到 lib/ 里当单元测试。
  4. render 之后写 store 要用 act(...) 包住,否则断言时还没重渲染。
  5. fetch 默认 reject;要响应就用 mockFetch(...)。虚拟化列表(属性表)要调 stubLayout(),否则每行 0px 高、渲染不出行。
  6. 断言普通值(textContent、.checked、.value、store 状态),不要断言 DOM 节点:assert.equal 比较节点失败时 Node 会格式化整棵 happy-dom 树,看起来像卡死;数行数用 queryAll…().length。

helper 还会安装应用自己的 i18next(用 en.json),所以查询用的是真实英文串;每个用例结束后它会卸载组件并重置应用/设置 store、localStorage、fetch 与 stubLayout。

24.4.3 写测试的取舍

场景 建议
大模块第一次有测试 测叶子模块(或从中抽一个),别直接 import 大文件——它会把整片依赖拉进覆盖率分母(见 24.8.1)
需要图层记录 用 tests/helpers/layer-fixtures.ts,部分 LayerStyle 用 DEFAULT_LAYER_STYLE 展开
需要伪造大接口 在小的类型化 helper 里经 unknown 转一次,别在每个调用点 as
需要测 worker 源码 主项目 exclude,并加进某个 worker 的 tsconfig 列表(脚本会检查漏加)

24.5 后端测试

24.5.1 前置与运行

pip install -e "backend/geolibre_server[test]"      # 关键:装上可选引擎,否则大量用例自我跳过
npm run test:backend                                # pytest backend/geolibre_server/tests
npm run test:backend:coverage                       # 加覆盖率,地板 55%
python -m pytest backend/geolibre_server/tests/test_x.py::test_y   # 单个用例

警告:没装 test extra 时,矢量/栅格/SQL/ML 测试会静默跳过——CI 变绿但内容是空的。这是后端最常见的“假绿”。

24.5.2 覆盖率与依赖

后端覆盖率跑 --cov=geolibre_server --cov-fail-under=55;npm run ci 调的是 :coverage 变体,所以需要 pytest-cov(在 backend 的 dev extra 里)。

24.5.3 容器与集成测试

测试 命令 说明
容器脚本 npm run test:docker docker/tests:部署策略与 sidecar 策略脚本
SQL Server CI 作业 sidecar-mssql sidecar 的 mssql 路由集成
身份 CI 作业 identity-integration PostgreSQL + OIDC 的 backend/geolibre_server_api 集成

24.6 端到端测试(Playwright)

24.6.1 两个项目,一次跑完全部

npm run test:e2e 对构建产物跑(先 npm run build,再用 vite preview 起服务),headless Chromium 驱动。首次需 npx playwright install chromium。

项目 命令 覆盖 何时跑
core npm run test:e2e:core 应用能启动并渲染地图,加上共享 UI 面:图层面板、属性表、对话框、拖放、主题、RTL、无障碍、PWA 外壳、工程保存与重开、插件深链 每次 push,以及改动 Web 构建所读路径的 PR(CI 作业 E2E core (Playwright))
core-engines 由 test:e2e:core 一并触发 每个备用引擎一次冒烟:切到 Cesium、再切 ArcGIS、加 GeoJSON、Identify 一个要素 同上;在 core 之后、单 worker 跑
features npm run test:e2e:features 按功能集成:Mapbox/Cesium、STAC、导出、故事地图、场景图、插件安装 nightly 与按需(e2e-full.yml,4 分片),或打 full-e2e 标签的 PR

两个项目对 e2e/ 做划分(不是重复):所以直接 npm run test:e2e 仍然每个 spec 只跑一次。另有独立配置:npm run test:e2e:sso(企业 SSO,需要活的 API 与 Keycloak)与 npm run test:e2e:preview(对着已部署站点)。

24.6.2 playwright.config.ts 的关键参数

参数 值 原因
testDir ./e2e —
workers CI 3 / 本地 1 软件渲染下加速是次线性的;10 worker 时争用开始让串行能过的用例失败
retries CI 1 / 本地 0 —
timeout / expect.timeout 60 s / 15 s —
Chromium 参数 --use-gl=angle --use-angle=swiftshader headless CI 没有 GPU,必须软件渲染才能初始化地图
storageState 预置 uiProfile.onboarded: true 否则首次启动的 UI profile 向导会在每个新上下文里弹窗拦截点击
trace / screenshot on-first-retry / only-on-failure 失败产物随报告上传
webServer.command npm run build && npm run preview -w geolibre-desktop -- --port <PORT> --strictPort 对着生产输出测
webServer.env CESIUM_TOKEN/VITE_CESIUM_TOKEN 置空 否则有 token 的机器会构建出另一个应用,cesium-globe.spec.ts 就分不清有/无 token 的路径
reuseExistingServer 本地 true、CI false 本地复用已监听的服务;注意复用会跳过上面的 env,所以那条断言写成“检查无 token 提示”而不是信任配置
E2E_PORT 默认 4173 第二个 checkout/并行运行时换端口

提示:本地跑 core-engines 时用 --no-deps 单独起(否则会先等 core 跑完);改 --workers=N 可覆盖默认值。

24.6.3 加一个 spec 的规则

  • 只有“坏了会影响每个用户”的 spec 才进 core:名单在 playwright.config.ts 的 CORE_SPECS(smoke、theme、a11y、keyboard-only、layer-panel、drop-overlay、error-handling、attribute-status、rtl、pwa、deployment-policy、layer-groups、plugin-deep-link),不在名单里的自动归 features。
  • 拆分是墙钟时间的取舍,不是价值判断:全量串行约 36 分钟,feature 规格占约 27 分钟,曾是 CI 唯一的瓶颈路径。
  • 碰到 nightly 覆盖的区域时,给 PR 打 full-e2e 标签,让那批规格在合并前跑一次。
  • 无障碍断言放在 e2e/a11y.ts 与 a11y*.spec.ts;拖放/错误处理等共享步骤抽到 e2e/helpers.ts,spec 里别再复制一遍。

24.6.4 失败产物与本地复现

配置 产物
trace: "on-first-retry" 首次重试时录制 trace,可在报告里逐步回放(含 DOM 快照与网络)
screenshot: "only-on-failure" 失败时截图;video: "off",不产视频
reporter CI 用 list + html(open: "never");本地只有 list

CI 的两个 e2e 作业都会在失败时上传 Playwright 报告 artifact;本地复现时优先按 spec 名跑单个文件,再用 --project 限定项目。core-engines 依赖 core,想单独跑要加 --no-deps。所有断言都对着构建产物,所以先确认本地构建是最新的——webServer 命令会重新构建并起 vite preview。

24.7 Worker 的类型检查与测试

npm run test:worker 是一个复合命令,按顺序做六件事:typecheck -w geolibre-viewer-worker、collab-worker、collab-node,跑 collab-node 的测试,再 typecheck -w geolibre-tiles-worker 与 ai-proxy-worker。

为什么 worker 要单独检查:Vite 把每个 new Worker(new URL(...)) 打成独立构建,worker 里 import 的东西会在主线程之外再发一份;Workers 运行时类型还重新声明了 DOM 全局,不能和主程序共用一个 program。历史上 h5wasm(libhdf5,约 4.8 MB)就被发过两份,现在主构建产出显式 chunk、worker 按 URL import,且构建在出现非唯一 hdf5_hl-*.js 时失败。

24.8 四种棘轮与一个预算

24.8.1 覆盖率地板

套件 地板
前端 78% 行 / 78% 分支 / 63% 函数
后端 55%(--cov-fail-under)

地板比当前数字低几分,作为棘轮:退步就红,涨上去后要把地板抬起来锁住收益。前端报告只统计被测试 import 过的文件——所以“大模块的第一个测试”会读起来像覆盖率退步:模块本身和它 import 的一切同时进入分母。真实案例:一个测试 import 了 usePlugins.ts,把整个内置插件注册表(39 个文件)拖进分母,函数覆盖率从 72.90% 掉到 60.36%。正确的解法是测叶子模块(lib/plugin-layer-queries.ts、geo-editor-geometry.ts 就是这么抽出来的),而不是下调地板。

test:frontend:coverage 走 scripts/coverage-check.mjs 而不是直接 node --test:Node 仍然强制三个地板,wrapper 只做一件事——当且仅当所有测试通过、且只有行覆盖率不达标时重测一次。行覆盖率在 CI 上不确定(同源码两次运行报过 81.82% 与 76.47%,444 个文件里 114 个行覆盖不同,而分支/函数零差异);分支/函数不达标或任何测试失败都立即失败、不重试。

24.8.2 未测模块基线

npm run check:untested-modules(scripts/check-untested-modules.mjs)读 test:frontend:coverage 写出的 coverage/frontend.lcov,与 apps/*/src、packages/*/src 下所有源文件比对。声明文件、index.* barrel、语言 catalog、生成物(带 AUTO-GENERATED/@generated/DO NOT EDIT 头,以及 Whitebox 菜单 catalog)不算。

规则 说明
500 行门槛 每个无测试的 500 行以上源文件必须列在 scripts/untested-modules-baseline.json(带行数)
新文件 出现新的 500 行+ 无测试文件 → 失败;要么加测试,要么抽出叶子模块再测
增长 已在基线里的文件再长 50 行且仍无测试 → 失败
基线只缩不扩 文件有测试/降到 500 行以下/被删时,用 -- --prune 在同一个 PR 里移除;--write-baseline 只用于首次 bootstrap

它跑在 ci:frontend 的覆盖率步骤之后;ci:web 跳过它(因为不测覆盖率)。命令还会不阻塞地打印后端没有 test_<module>.py 的模块,当作“去查一下”的提示,而不是未测代码计数。

24.8.3 测试类型棘轮

npm run typecheck:tests(scripts/check-test-typecheck.mjs)对 tests/ 跑三个 tsc --noEmit 项目,错误数超过 --max-errors 就失败——当前是 0。

项目 检查什么
tests/tsconfig.json 全部测试,DOM lib + Node/React/Vite 类型 + 应用与包的 ambient .d.ts;allowJs 让测试能 import scripts/*.mjs
tests/tsconfig.workers.json import workers/tiles 或 workers/viewer 的测试(@cloudflare/workers-types)
tests/tsconfig.ai-proxy.json import workers/ai-proxy 的测试(该 worker 生成的 worker-configuration.d.ts)

只有 tests/ 里的错误计数:测试 import 的产品源码由它自己 workspace 的 tsconfig 管,报在测试设置下的错误只打印、不计数。新增一个 import worker 源码的测试,要同时进主项目的 exclude 与某个 worker 项目,脚本会检查“被 exclude 却不在任何 worker 项目里”的情况。

修法:对着真实类型造 fake(DEFAULT_LAYER_STYLE 展开、tests/helpers/layer-fixtures.ts、大接口的故意残缺假对象经 unknown 在小 helper 里转一次)。不要抬上限;修好了反而要把上限降到新的总数。

24.8.4 ESLint 告警棘轮

npm run lint 传 --max-warnings 242(写在根 package.json 里),所以告警数只能降。告警来源:react-hooks/exhaustive-deps、@typescript-eslint/no-explicit-any、类型感知的 @typescript-eslint/no-floating-promises、local/no-physical-tailwind(RTL 规则)、以及应用与包 .tsx 上的两条 eslint-plugin-jsx-a11y 规则。

情形 做法
PR 新增一个告警 修掉。故意 fire-and-forget 的加 void 并确保自己处理 rejection;必须保持物理方向的加 // eslint-disable-next-line local/no-physical-tailwind -- <理由>
PR 修掉告警 同一个 PR 里把上限降到 ESLint 打印的新总数
开启新规则 这是上限唯一允许上调的时机:按新规则在当前代码上的计数精确上调,并在 PR 里说明

24.8.5 启动包预算

npm run build 在入口静态 import 的 JS(index.html 在壳挂载前 modulepreload 的那些)超过预算时失败,或在其中发现 Cesium 时失败。检查逻辑是 vite.config.ts 的 bootBundleBudgetPlugin,预算在 apps/geolibre-desktop/boot-budget.json:

预算 上限 设定时实测
rawBytes 3 MiB(minified) 约 2.2 MB
gzipBytes 900 KiB(gzip 9) 约 680 kB

大多数“超预算”来自 chunk 分组变化,把整包懒加载依赖(Cesium、DuckDB、某个 maplibre-gl-*)拖上了启动路径。修法是改 CODE_SPLITTING_GROUPS/manualChunks,不要抬预算;npm run bundle:report 会在构建后写出每个 JS/WASM 资源的原始与 gzip 体积、启动集合与各顶层目录体积,CI 会把它作为 bundle-report artifact 上传。

24.9 国际化(i18n)体系

24.9.1 目录、加载与回退

apps/geolibre-desktop/src/i18n/
  index.ts        # i18next 初始化、catalog 自动发现、语言检测
  languages.ts    # 友好语言名、解析与方向辅助
  i18next.d.ts    # 用 en.json 给 t() 的键做类型
  locales/        # en.json 为源,另有 20 个语言(zh/es/fr/de/pt/it/nl/ja/ko/ru/tr/id/hi/th/vi/az/ka/ar/he/fa)
机制 说明
源与类型 en.json 是唯一源;i18next.d.ts 用它类型化 t(),缺键/拼错是编译错误
自动发现 index.ts 用 import.meta.glob 收 locales/*.json——加文件即多一门语言,无需改代码
打包策略 英语随应用 chunk;其余每个 catalog 是独立 chunk i18n-locale-<code>,切到该语言才加载(i18nReady 在首渲染前 await 初始语言)
回退 fallbackLng: "en";非英文 catalog 允许落后于 en.json,缺的键运行时回落
离线 Service Worker 把这些 chunk 排除在 app-shell 预缓存外,首次使用后 CacheFirst 缓存

非英文 catalog 是机器辅助翻译起步的,欢迎母语者提 PR 修正。

24.9.2 语言检测顺序

  1. ?locale= 或 ?lang= 查询参数(给嵌入用,与 ?theme=/?maponly 一致)。
  2. 桌面设置里持久化的 language。
  3. 浏览器偏好语言(navigator.languages)。
  4. 默认 en。

只有带 catalog 的语言会被采纳,否则落到下一条。应用内选择器(Settings → Language)即时切换并记录选择。例子:https://geolibre.app/?maponly&locale=es。

24.9.3 在组件里用

import { useTranslation } from "react-i18next";

function Example() {
  const { t } = useTranslation();
  return <button aria-label={t("common.cancel")}>{t("common.save")}</button>;
}
需求 写法
插值 t("settings.env.removeAria", { name }),catalog 值 "Remove {{name}}"
复数 定义 key_one/key_other,调用 t("...", { count })
富文本(链接、加粗) <Trans components={{ tokenLink: <a href="…" /> }} />
模块级常量 存 catalog key 而不是英文串,渲染处 t(item.labelKey)(见 SettingsDialog.tsx 的 SECTION_ITEMS、toolbar/constants.ts)

不要翻译:开发者日志、URL、store ID、文件格式名/扩展名、持久化数据值。

24.9.4 加一门语言

  1. 复制 locales/en.json 为 locales/<code>.json 并翻译。目标译全;漏掉的运行时回落英文,catalog 测试会报告而不是失败。产品名、格式名、单位按该语言的惯例保留英文。
  2. 复数按该语言的 CLDR 类别:中/日/韩/印尼/泰只留 _other;俄语加 _few/_many;阿拉伯语有六种形式。catalog 测试按“复数归一化后的键”比较,所以形式多寡不影响通过。
  3. 若代码不在 LANGUAGE_NAMES(languages.ts)里,加一条友好名(可选,否则显示原始 code)。
  4. 完成:语言出现在 Settings → Language,也支持 ?locale=<code>。

24.9.5 目录完整性测试的规则

tests/i18n-catalogs.test.ts(属于 npm run test:frontend)对每个 catalog 检查:

规则 结果
没有多余键 en.json 里不存在的键 → 失败(拼错或残留)
占位符一致 每条译文保留英文的 {{placeholders}} 与 <tag> 标记
缺键 只报告不强制;复数键按基名比较

所以新增一个 t() 键(或内置插件通过 app.translate 解析的 plugin.<id>.* 键)只需要加进 en.json;其它语言在发版前用 geolibre-i18n 审计补齐。补译文时不要重排 catalog(多份已与 en.json 顺序漂移,重排的 diff 会把真实改动埋掉)。

24.9.6 从右到左(RTL)

阿拉伯语(ar)、希伯来语(he)、波斯语(fa)是 RTL catalog,布局方向已经接好,新增 RTL 语言是加法:

机制 说明
方向注册表 languages.ts 的 RTL_LANGUAGES 列基语言(ar、he、fa、ur);languageDirection(code) 与 resolveLanguage 同规则解析
文档镜像 index.ts 在 init 之前订阅 languageChanged(所以初始语言也触发),设置 document.documentElement.lang 与 .dir
Radix 方向 App.tsx 用 DirectionProvider 包壳,菜单/子菜单朝阅读方向展开,方向键、滑块、tabs 跟随镜像
逻辑工具类 用 ms-/me-、ps-/pe-、text-start/text-end、border-s/border-e、start-/end-,不用 ml-/left-;同一个边不要混用物理与逻辑类(tailwind-merge 把它们当不同组,两个都会生效)
地图外壳固定 ltr index.css 把 .maplibregl-control-container 钉成 direction: ltr;.maplibregl-map 其余部分仍可镜像,所以 portal 进地图容器的应用覆盖物(如故事地图演示器)跟随 RTL
文本整形 弹窗里的阿拉伯文经 Unicode bidi 正常整形;地图底图标签靠 MapLibre 内建 bidi/shaping(6.11+),没有注册旧版 RTL 插件(它会错误连接库尔德语字母);导出的故事地图 HTML 钉的是更老的 CDN maplibre-gl,仍会加载该插件

对称居中(left-1/2 + -translate-x-1/2)、贴地图的覆盖层、JS 计算的像素偏移故意保持物理方向。

24.9.7 让格式化跟随语言

数字、日期、坐标格式化继续走运行时 locale 的 Intl API(new Intl.NumberFormat(undefined, …) / Intl.DateTimeFormat),不要硬编码格式。

24.9.8 处理工具元数据

工具名、描述、分组标签、参数标签/帮助、下拉项标签住在没有 i18n 访问权的处理注册表里。处理对话框(Vector / Network / Spatial statistics / Raster / Batch、Whitebox 工具箱、模型构建器面板)通过 apps/geolibre-desktop/src/lib/processing-tool-i18n.ts 解析 processing.toolMeta.<catalog>.<toolId>…,失败则回退注册表自己的字符串。

要点 说明
键里为什么要 catalog 工具 id 会跨注册表重名(reproject 既是矢量工具也是栅格工具)
分组 共用 processing.toolGroup.<slug>,因为同名分组出现在多个注册表
生成物 四个小型的随包注册表由 npm run i18n:tools 生成 en.json 基线,CI 检查漂移;只重写 processing.toolMeta 与 processing.toolGroup,不动其它语言
Whitebox 数百条描述不进任何随包 catalog;英文由运行时提供,可选译文是版本化语言包(opengeos/geolibre-language-packs),从 Settings → Language 下载或导入 JSON,校验后合并、存进独立 IndexedDB
语言包根 v1 只接受 processing.toolMeta.whitebox、processing.whitebox.categories、menuTool、menuSubcategory;官方包在 https://languages.geolibre.app/v1/whitebox/<locale>.json,自托管可用 VITE_LANGUAGE_PACK_BASE_URL 指镜像,本地导入离线可用
模型构建器 groupModelTools 按原始标签分组、标题本身没有 provider,且 WASM catalog 的 terrain(小写)会 slug 到栅格注册表的 Terrain——所以用 translateModelToolGroup(只在组内至少一个工具来自矢量注册表时翻译),不要直接用 translateToolGroup

24.9.9 插件文案与错误

面 机制
插件显示名 解析 toolbar.plugin.<pluginId>;所有展示它的面(Plugins 菜单、命令面板、Settings → Interface、Manage Plugins)都走 lib/plugin-display-name.ts,避免一个菜单是译文、下一个是英文
插件自己的 UI 原生 DOM,靠 getLocale/onLocaleChange/translate/registerTranslations(见第21章 21.8.4);内置插件把串放 bundled catalog 下的 plugin.<pluginId>.*,用 createPluginTranslator 解析
非 UI 模块抛错 用 @geolibre/plugins 的 LocalizedError:构造时通过宿主注册的 translator 自译(setLocalizedErrorTranslator 在 hooks/usePlugins.ts 里注册),英文留在 error.englishMessage;纯 GeoLibre 撰写的片段用 localizedMessage(key, english, params),服务端返回的原文保持原样
校验 tests/localized-error.test.ts 扫描 packages/plugins/src 里每个调用,键缺失或英文不一致就失败
注册表派生键 Deck.gl Layer 对话框用 deckgl-viz/registry.ts 的 id 派生键(addData.deckViz.kinds.<kind>.label|description 等),tests/deck-viz-i18n.test.ts 在 en.json 与注册表不一致时失败

24.9.10 已知限制

包了第三方 MapLibre 控件的插件(例如 Esri Wayback 的 maplibre-gl-esri-wayback,文案如 “Only versions with local changes”)由外部库自己渲染,不经过 react-i18next,所以无论选哪种语言都是英文。要译只能上游改(或 fork),在这里的 catalog 里做不到。

24.9.11 新增文案的自查清单

  1. 键只加进 en.json(源),值写成完整英文句子;组件里用 t(),不要拼字符串。
  2. 保留占位符与标签:{{name}}、<tokenLink>…</tokenLink> 的写法在译文里必须原样出现,否则 catalog 测试失败。
  3. 需要复数就定义 _one/_other(其它语言按自己的 CLDR 形式),调用时传 { count }。
  4. 插件相关键用 plugin.<pluginId>. 前缀;处理工具相关键用 processing.toolMeta.<catalog>.<toolId>,并跑 npm run i18n:tools。
  5. 不重排 catalog 顺序——多份语言已与 en.json 漂移,重排的 diff 会把真实改动埋掉。
  6. 新组件的布局用逻辑工具类;数字/日期用 Intl,不要手拼格式。
  7. 跑 npm run test:frontend -- tests/i18n-catalogs.test.ts(或全量前端测试)确认没有多余键与占位符差异。

24.10 文档与生成物

24.10.1 文档站

站点用 Zensical(MkDocs Material 团队的后继工具)构建,直接读现有的 mkdocs.yml:

python -m pip install -r requirements-docs.txt
zensical serve        # → http://localhost:8000
规则 说明
新页面 必须加进 mkdocs.yml 的 nav
严格构建 CI 跑 zensical build --strict:坏链接、或没进导航的页面会让构建失败
站内链接 用相对路径(如 architecture.md)
站外文件 用完整 GitHub URL;唯一例外是 geolibre-assets 里的资产,用它的发布 URL

24.10.2 图片、演示与样例数据

不要把截图、GIF、视频、样例数据提交进主仓库:二进制永远删不干净,会让所有人的 clone 和 CI 永久变大。放到 opengeos/geolibre-assets(纯静态主机,任意文件类型),推到它的 main 后由 GitHub Pages 发布:

仓库路径 发布 URL
images/my-panel.webp https://assets.geolibre.app/images/my-panel.webp
demos/my-demo.gif https://assets.geolibre.app/demos/my-demo.gif
data/sample.parquet https://assets.geolibre.app/data/sample.parquet

约定:images/ 放静图、demos/ 放动图与录屏、data/ 放样例数据、styles//fonts/ 放地图资源;静图优先 WebP/AVIF,数据优先 PMTiles/GeoParquet;单文件低于 100 MB(GitHub 硬上限);发布路径事实上永久,改名/删除会打断所有已引用它的页面;资产带宽松 CORS,应用可跨域取。样例数据集另有 data.geolibre.app 托管,那些 URL 继续可用、无需改写。仓库里唯一该存在的图片是 docs/assets/ 下的站点装饰(目前只有 logo/favicon),不要往里加东西。

24.10.3 生成物与跨文件同步

生成物 生成命令 检查命令 源
docs/gallery.md 与 docs/demos.md 的展示摘要 npm run gallery npm run gallery:check scripts/demo-gallery.json
docs/user-guide/plugins.md 的内置插件表 npm run plugins:docs npm run plugins:docs:check usePlugins.ts 的 BUILT_IN_PLUGINS、各插件定义的 id/name、en.json 的 toolbar.plugin、PluginsMenu.tsx 的菜单位置、scripts/plugin-reference.json 里手写的一行描述
en.json 的处理工具元数据 npm run i18n:tools npm run i18n:tools:check packages/processing 的四个小型注册表
skills/geolibre/(面向使用者的 agent skill) 手工维护 python/tests/test_agent_skill.py MCP 工具面、basemap/color-ramp/legend catalog、工程 schema、嵌入参数

几条容易踩空的规则:

  • 插件文档生成器在“注册了但没有描述”时直接失败;tests/plugin-reference-docs.test.ts 也守着同一份表。
  • 手改生成区(<!-- plugin-reference:start/end --> 之间)会被下次生成覆盖,改了也白改。
  • skills/geolibre/ 无法机械校验的只有散文;尺寸上限、行为限制这类“说明性事实”必须同 PR 手改,否则会把外部 agent 带到已经不存在的路上——而且没有任何失败提示。

24.10.4 文档 PR 自查

检查项 说明
新页面进 nav 否则 zensical build --strict 失败
站内链接 相对路径,指向 docs/ 内真实存在的 .md
图片 用 assets.geolibre.app 的发布 URL,不要把二进制提交进仓库
生成区 <!-- plugin-reference:start/end --> 之类区块不要手改,改源后重生成
代码块 标注语言,命令可直接复制

24.11 发布与维护要点

24.11.1 依赖更新与安全审计

机制 说明
Dependabot .github/dependabot.yml 按周分组开 PR:npm、pip(backend + python/)、cargo、Actions
npm run audit:ci scripts/audit-check.mjs 包 npm audit --omit=dev,仍然对每个 high/critical 失败,除了 ALLOWLIST 里列出的;跑在 CI 的 “Lint and type check” 作业
pip-audit 对后端解析后的环境跑,不阻塞,在 “Backend tests” 作业
上游内部实现被手抄的部分 升级 maplibre-gl、任一 maplibre-gl-*、geolibre-wasm、@tauri-apps/plugin-http 前先读 docs/maintenance.md 并跑前端测试套件;漂移是静默的

24.11.2 发布包与凭据扫描

事项 说明
npm 发布 GitHub Release 触发 publish-packages.yml,发布 @geolibre/core、@geolibre/map、@geolibre/embed
入口字段 仓库里指向 TS 源码(Vite/tsc/tsx 直接吃),tarball 指向 dist,由 publishConfig + scripts/prepare-npm-package.mjs 在上传前抬升
sidecar 锁 backend/geolibre_server/uv.lock 必须提交;CI 有 uv lock --check 步骤防止漂移
凭据扫描 scripts/scan-credentials.mjs 校验构建输出(build-embed.mjs 暂存 wheel 前、hatch_build.py 打包任何 wheel/sdist 前都跑);规则与 scripts/credential-patterns.json 共用,JS 与 Python 扫描器不会漂移
白名单哈希 allowedValueHashes 只放“公开但命中规则”的值(目前是 CesiumJS 内置的默认 Ion token);先解码确认再加哈希,Cesium 升级会让它变化,这是预期
构建环境变量 BUILD_ENV_KEYS 是能进 bundle 的 VITE_ 白名单;CREDENTIAL_ENV_KEYS 在可再分发构建里置空

24.11.3 棘轮怎么维护

棘轮 上涨/下降的时机
覆盖率地板 覆盖率明显高于地板时上调,锁住收益
lint 告警上限 修掉告警时在同一个 PR 里下调;只有开新规则才允许上调
测试类型错误数 修好后下调;当前为 0
未测模块基线 文件有测试/变短/被删时用 -- --prune 移除;永远不手工新增
启动包预算 只有确认“新代码确实该在启动路径上”才调,并在 PR 里说明

24.11.4 版本号从哪来

位置 版本
根 package.json 应用版本(本教程基于 3.4.0)
python/src/geolibre/__init__.py Python 包的动态版本源(hatch_build.py 从这里取)
CITATION.cff 引用信息与 DOI(10.5281/zenodo.20785400)

改版本时注意:Python 包版本不写在 pyproject.toml 里,而在 __init__.py;发版工作流靠它给 wheel 打版本号。

24.12 CI 失败排查清单

症状 先看 处理
“启动包超预算” bundle-report artifact 里最大的启动 chunk 改 CODE_SPLITTING_GROUPS/manualChunks,别抬预算
覆盖率不达标 本地 npm run test:frontend:coverage 若是“新测试把大模块带进分母”,改成测叶子模块
覆盖率只报行、且重试后过了 coverage-check.mjs 的日志 已知的行覆盖率不确定性;分支/函数与测试失败不重试
未测模块棘轮 check:untested-modules 的输出 加测试,或 -- --prune 清掉已解决的基线条目
ESLint 超过 --max-warnings 本地 npm run lint 修告警;修完把上限降到新总数
typecheck:tests 失败 node scripts/check-test-typecheck.mjs --max-errors 0 --verbose 按真实类型修 fake,不要抬上限
i18n:tools:check / gallery:check / plugins:docs:check 对应生成命令 跑生成器并提交产物;只改源不改产物必红
check:shared-deps 应用与 packages/plugins 的 package.json 两侧依赖范围对齐
E2E 超时 playwright-report artifact(含 trace/截图) SwiftShader 是 CPU 瓶颈;确认 worker 数,必要时打 full-e2e 重跑
后端“绿但空” pytest 输出里的 skip 数 装 backend/geolibre_server[test] extra
cargo check 失败 本地 npm run check:rust 依赖 Rust 版本或 Tauri 配置
“CI passed” 红了但各作业都绿 ci.yml 的 ci-passed 作业 新作业没进 needs 列表

24.13 本章小结

  • 分支 → 聚焦改动 → 跑门禁 → Conventional Commits → PR;格式化交给 pre-commit 钩子,笔记本输出永不被提交。
  • 三个入口:ci:web(只要 Node)、ci:frontend(构建 + 前端 + worker)、ci(全量含后端与 Rust);CI 把它们拆成并行作业,合并只看 CI passed。
  • 前端测试是 node --test + tsx(不查类型);组件测试用 happy-dom + Dynamic import + useAppStore.setState,mapControllerRef 传 { current: null }。
  • 后端测试必须先装 test extra,否则大量用例静默跳过;覆盖率为 55%。
  • e2e 用 Playwright,分 core/core-engines/features 三个项目划分而非重复;CORE_SPECS 决定谁能进每提交门禁。
  • 四种棘轮(覆盖率、未测模块基线、lint 告警、测试类型错误)+ 一个启动包预算:指标只能变好,修复收益要在同一个 PR 里锁住。
  • i18n 的源是 en.json,键被类型化;其它语言懒加载、允许落后、运行时回落;RTL 靠方向注册表 + 逻辑工具类;插件与处理工具各有自己的桥。
  • 生成物(gallery、插件表、i18n:tools、agent skill)必须与源同 PR 更新,否则 CI 或测试会红;文档站 --strict 会拦坏链接。

24.14 实践练习

  1. 跑一遍全门禁:在干净 clone 上依次跑 npm run ci:web、npm run test:e2e:core、npm run ci,记录每一步耗时与失败点;对每一步说明它在防什么回归。
  2. 写一个组件测试:给图层面板加一个“只统计可见图层数”的小组件,用 tests/helpers/dom.ts 写测试,覆盖空状态、有图层、切换可见性三种情况;断言只用普通值。
  3. 制造并修掉一次覆盖率退步:写一个 import 大模块的测试,观察前端覆盖率数字变化;再改成测叶子模块(或抽出叶子模块),使地板重新通过。
  4. 加一门语言:复制 en.json 为 pt-BR.json,翻译 20 个常用键(含一个复数键、一个带 {{name}} 的键),跑 tests/i18n-catalogs.test.ts;再验证 ?locale=pt-BR 生效、切换语言后插件面板标题跟随。
  5. 修一个 RTL 问题:挑一个用了物理方向类的组件,换成逻辑工具类,跑 npm run lint 确认没有新增 local/no-physical-tailwind 告警,并在 RTL 语言下目视检查。
  6. 加一个 e2e spec:为新功能写一个 spec,判断它该进 core 还是 features 并说明理由;本地分别用 --project=core 与 --project=features 跑一次。

24.15 延伸阅读