docs: close STEP-083 ios RN validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-09-18 22:42:47 +09:00
parent d8bb9f547b
commit 9e20233288
5 changed files with 138 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
# STEP-083 iOS RN 统一 FieldRuntime / TurboModule / Fabric 与 XCFramework 消费 v1
状态:`IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`
状态:`CLOSED / PASS`
## 基线与范围
@@ -22,10 +22,6 @@
- Test Host 消费的是 single MapLibre SPM product;未发现第二份 MapLibre binary。新 XCFramework 必须以 Metal 命令 `bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic` 生成。默认裸 Bazel 命令的 OpenGL artifact 只有色场、无粒子,已由 STEP-082 post-close correction 作废。
- Pleiades iPad mini 5 上 signed Release 安装运行。一次 V4 Wind load 实际观察到 `clearAllEncodedFields` → color binding → particle binding → T0 `ACCEPTED_INSERTED` → T1 `ACCEPTED_INSERTED` → midpoint seek;用户确认粒子恢复。
## 尚未逐按钮回归
- PLAY/PAUSE、clear selected、clear all、detach/re-attach 尚未逐按钮实测;不得将本 Step 标为完成,也不得把单次 V4 load 扩写为完整控制面验收。
## 已执行检查
- `ruby -c packages/maplibre-particle-rn/MapLibreParticleRN.podspec`:成功。
@@ -33,8 +29,10 @@
- `npm pack --dry-run --json --cache /private/tmp/step083-npm-cache`:成功,tgz 清单包含 podspec、`ios/WeatherRuntimeBridge.h/.mm`、既有 Android 与 `src`。
- package `codegenConfig.ios.modulesProvider.WeatherRuntimeBridge` 静态检查和 Native Spec 34 个方法与 ObjC++ 34 个实现逐项计数完成。
## RDC 下一步
## Final control regression 真机关闭
1. 用同一个 `App.tsx` 逐按钮完成 PLAY/PAUSE、clear selected/all、detach/re-attach;同时复核 style-not-ready smoke。
2. 继续维持 MLRN/App/Particle 的 single `MapLibre` product,禁止为 Particle pod 引入独立 framework。
3. 所有后续 iOS Particle artifact 一律以 Metal Bazel 命令构建,并在发布前复核 hash、renderer markers 与远端回读(若有发布授权)。
`integration/react-native/MapLibreParticleRNTestHost/App.tsx` 新增唯一按钮 `RUN V4 FINAL CONTROL REGRESSION`,仅在当前 `reactTag` 有效时执行,且运行中禁用以避免重复并发。它复用 V4 加载的 `prepareV4` 公共 API 路径:clearAll → color binding →(如有)particle binding → T0/T1 `ACCEPTED_INSERTED` → midpoint seek。
Pleiades iPad mini 5 真机已由用户用该单按钮完成最终控制序列:`prepare#1 → PLAY → 约500ms → PAUSE → clear selected → prepare#2 → clear all → detach → attach → prepare#3 → refresh`。用户明确反馈“FINAL CONTROL REGRESSION PASS”;最后一次 prepare/refresh 后粒子恢复并保持可见。此前同一真机的 V4 Wind 验收中,用户也已确认“粒子恢复了”。
上述结果关闭此前唯一未完成的逐控制面真机验收项;STEP-083 正式 `CLOSED / PASS`。回归按钮保留为后续入口,未改动其逻辑。

View File

@@ -35,3 +35,9 @@ bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic
## 未完成的 final control regression
PLAY/PAUSE、clear selected、clear all、detach/re-attach 尚未逐按钮实测。它们是本 Step 的剩余验收项;在完成前不得将状态改写为 `CLOSED` 或 `PASS`。
## Final control regression 入口追加(待真机执行)
RN Test Host 的 `App.tsx` 已新增单一 `RUN V4 FINAL CONTROL REGRESSION` 按钮。有效 `reactTag` 下,它复用现有 V4 `prepareV4` 公共 API 加载路径,并串行执行 PLAY → 约 500ms → PAUSE → clear selected → prepare → clear all → detach → attach → prepare → refresh;所有 command result 必须为 `ACCEPTED`,三次 prepare 的 T0/T1 均必须为 `ACCEPTED_INSERTED`。运行中按钮禁用,结果区域保留每一步摘要;任何失败明确显示失败步骤,不显示伪 PASS。detach/attach 后最后一次 prepare 会重新 binding/submit 并恢复粒子,供 Pleiades iPad 真机目视确认。
本追加只提供最终人工回归入口,不代表已执行真机验收。状态仍为 `IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`,直到用户确认该序列和最终粒子画面后才可关闭。

View File

@@ -0,0 +1,35 @@
# STEP-083 iOS RN 统一 FieldRuntime TurboModule Fabric 与 XCFramework 消费 Codex 完成报告 v3
## 执行结论
`CLOSED / PASS`。
Pleiades iPad mini 5 真机已完成最终控制面回归;用户先确认 Metal 修正版 V4 Wind“粒子恢复了”,随后确认单按钮回归“FINAL CONTROL REGRESSION PASS”。本报告为同一 STEP-083 的正式关闭记录,不新建功能 Step,不提交、不 push。
## 最终真机验收
- 设备:Pleiades iPad mini 5。
- Test Host signed Release:`BUILD SUCCEEDED`,已安装并启动。
- MLRN、App 与 Particle bridge 使用 single MapLibre SPM product;没有第二份 MapLibre framework。
- `RUN V4 FINAL CONTROL REGRESSION` 单按钮已在真机完成:`prepare#1 → PLAY → 约500ms → PAUSE → clear selected → prepare#2 → clear all → detach → attach → prepare#3 → refresh`。
- 全部命令返回预期成功结果;最终 prepare/refresh 后 V4 Wind 粒子恢复并保持可见。用户两次真机确认分别为“粒子恢复了”与“FINAL CONTROL REGRESSION PASS”。
## Metal XCFramework 验证坐标
- renderer:Metal;构建命令:`bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic`。
- artifact:`MapLibre.dynamic.xcframework.zip`。
- size:`24943196` bytes。
- SHA-256:`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`。
- release manifest 仍为本地验证坐标 `LOCAL_BUILD`;本次未修改 release manifest,也未进行发布。
## 范围与保留项
- App.tsx 的 final regression 按钮保留为回归入口,未改动其逻辑。
- 未修改 RN public API、iOS native/Core、renderer 或 release manifest。
- 未覆盖 v2 报告。
- 三处用户 dirty `docs/doxygen/doxygen-awesome-css`、`platform/ios/app/MBXAppDelegate.m`、`platform/windows/vendor/vcpkg` 保持不动;三份 `docs/40_任务` 未跟踪任务书也保持不动。
- 当前 `particle/main` HEAD:`d8bb9f547bf0db2f222fb49f1ec5df77373de310`;本次仅做收尾文档且不提交。
## 下一步
STEP-083 已正式关闭。下一步进入 NavSea iOS 首批大集成,不再拆分微小的 build/install Step。

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
@@ -25,6 +25,9 @@ export default function App() {
const [v4Frames, setV4Frames] = useState<Record<"T0" | "T1", string>>({ T0: "PENDING", T1: "PENDING" });
const [v4Bindings, setV4Bindings] = useState("PENDING");
const [v4Transport, setV4Transport] = useState("PENDING: raw QBIN bundled asset → local file:// URI");
const [finalControlRunning, setFinalControlRunning] = useState(false);
const [finalControlSummary, setFinalControlSummary] = useState("FINAL CONTROL REGRESSION: not run");
const finalControlRunRef = useRef(false);
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [visual, setVisual] = useState(initialVisual);
@@ -65,35 +68,92 @@ export default function App() {
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD ${dataset.id}: ${String(error)}`); }
}, [reactTag, dataset]);
const prepareV4 = useCallback(async (tag: number) => {
setV4Transport("LOADING: clear → binding → T0 → T1 → midpoint");
setV4Frames({ T0: "PENDING", T1: "PENDING" });
setV4Bindings("PENDING");
const clear = await FieldRuntime.clearAllEncodedFields(tag);
if (clear !== "ACCEPTED") throw new Error(`clearAllEncodedFields expected ACCEPTED, got ${clear}`);
const color = await FieldRuntime.setEncodedColorBinding(tag, v4Dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error(`ColorBinding expected ACCEPTED, got ${color}`);
const particle = v4Dataset.particleBinding == null ? "N/A" : await FieldRuntime.setEncodedParticleBinding(tag, v4Dataset.particleBinding);
if (particle !== "N/A" && particle !== "ACCEPTED") throw new Error(`ParticleBinding expected ACCEPTED, got ${particle}`);
setV4Bindings(`clearAll=${clear}; color=${color}; particle=${particle}`);
const localFrames = await Promise.all(v4Dataset.frames.map(materializeV4Frame));
const submitted: string[] = [];
for (const frame of localFrames) {
const submit = await FieldRuntime.submitEncodedField(tag, v4Dataset.fieldKey, frame.metadata, v4Dataset.profileContract, frame.qbinFileUri);
submitted.push(`${frame.label}=${submit}`);
setV4Frames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== "ACCEPTED_INSERTED") throw new Error(`${frame.label} expected ACCEPTED_INSERTED, got ${submit}`);
}
const midpoint = await FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs);
if (midpoint !== "ACCEPTED") throw new Error(`midpoint seek expected ACCEPTED, got ${midpoint}`);
await refresh(tag);
setV4Transport("READY: T0/T1 ACCEPTED_INSERTED; midpoint command ACCEPTED (mix=0.5 is not exposed)");
return `clearAll=${clear}; color=${color}; particle=${particle}; ${submitted.join(", ")}; midpoint=${midpoint}`;
}, [refresh, v4Dataset]);
const loadV4Dataset = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
try {
setV4Transport("LOADING: clear → binding → T0 → T1 → midpoint");
setV4Frames({ T0: "PENDING", T1: "PENDING" });
setV4Bindings("PENDING");
const clear = await FieldRuntime.clearAllEncodedFields(reactTag);
if (clear !== "ACCEPTED") throw new Error(`clearAllEncodedFields expected ACCEPTED, got ${clear}`);
const color = await FieldRuntime.setEncodedColorBinding(reactTag, v4Dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error(`ColorBinding expected ACCEPTED, got ${color}`);
const particle = v4Dataset.particleBinding == null ? "N/A" : await FieldRuntime.setEncodedParticleBinding(reactTag, v4Dataset.particleBinding);
if (particle !== "N/A" && particle !== "ACCEPTED") throw new Error(`ParticleBinding expected ACCEPTED, got ${particle}`);
setV4Bindings(`clearAll=${clear}; color=${color}; particle=${particle}`);
const localFrames = await Promise.all(v4Dataset.frames.map(materializeV4Frame));
for (const frame of localFrames) {
const submit = await FieldRuntime.submitEncodedField(reactTag, v4Dataset.fieldKey, frame.metadata, v4Dataset.profileContract, frame.qbinFileUri);
setV4Frames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== "ACCEPTED_INSERTED") throw new Error(`${frame.label} expected ACCEPTED_INSERTED, got ${submit}`);
}
const midpoint = await FieldRuntime.setEncodedForecastTime(reactTag, v4Dataset.midpointMs);
if (midpoint !== "ACCEPTED") throw new Error(`midpoint seek expected ACCEPTED, got ${midpoint}`);
await refresh(reactTag);
setV4Transport("READY: T0/T1 ACCEPTED_INSERTED; midpoint command ACCEPTED (mix=0.5 is not exposed)");
await prepareV4(reactTag);
setResult(`${v4Dataset.title}: V4 raw QBIN load accepted`);
} catch (error) {
setV4Transport(`ERROR: ${String(error)}`);
setResult(`LOAD V4 ${v4Dataset.id}: ${String(error)}`);
}
}, [reactTag, refresh, v4Dataset]);
}, [prepareV4, reactTag, v4Dataset]);
const runV4FinalControlRegression = useCallback(async () => {
if (reactTag == null || !Number.isInteger(reactTag) || reactTag <= 0) return setResult("MAP_NOT_READY: wait for a valid reactTag/style ready");
if (finalControlRunRef.current) return;
const steps: string[] = [];
const accepted = (step: string, value: string) => {
if (value !== "ACCEPTED") throw new Error(`${step} expected ACCEPTED, got ${value}`);
steps.push(`${step}=${value}`);
};
const prepare = async (attempt: number) => {
try { steps.push(`prepare#${attempt} PASS (${await prepareV4(reactTag)})`); }
catch (error) { throw new Error(`prepare#${attempt}: ${String(error)}`); }
};
finalControlRunRef.current = true;
setFinalControlRunning(true);
setFinalControlSummary("FINAL CONTROL REGRESSION: RUNNING");
try {
await prepare(1);
accepted("PLAY", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PLAY"));
await new Promise<void>((resolve) => setTimeout(resolve, 500));
steps.push("PLAY wait≈500ms=PASS");
accepted("PAUSE", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PAUSE"));
accepted("clear selected", await FieldRuntime.clearEncodedField(reactTag, v4Dataset.fieldKey));
await prepare(2);
accepted("clear all", await FieldRuntime.clearAllEncodedFields(reactTag));
try { await FieldRuntime.detach(reactTag); }
catch (error) { throw new Error(`detach: ${String(error)}`); }
steps.push("detach=PASS");
let attached: RuntimeSnapshot;
try { attached = await FieldRuntime.attach(reactTag); }
catch (error) { throw new Error(`attach: ${String(error)}`); }
setSnapshot(attached);
steps.push("attach=PASS");
await prepare(3);
try { await refresh(reactTag); }
catch (error) { throw new Error(`refresh: ${String(error)}`); }
steps.push("refresh=PASS");
const summary = steps.join(" | ");
setFinalControlSummary(`FINAL CONTROL REGRESSION PASS: ${summary}`);
setResult("FINAL CONTROL REGRESSION PASS");
} catch (error) {
const failure = `FAIL: ${String(error)}`;
steps.push(failure);
setFinalControlSummary(`FINAL CONTROL REGRESSION ${steps.join(" | ")}`);
setResult(`FINAL CONTROL REGRESSION ${failure}`);
} finally {
finalControlRunRef.current = false;
setFinalControlRunning(false);
}
}, [prepareV4, reactTag, refresh, v4Dataset.fieldKey]);
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
@@ -121,6 +181,8 @@ export default function App() {
<Text>V4 QBIN Golden: {v4Dataset.title}</Text><Text selectable>V4 raw QBIN transport = {v4Transport}</Text><Text selectable>V4 bindings: {v4Bindings}</Text><Text selectable>{v4Dataset.particleNote}</Text>
<Text selectable>V4 T0: {v4Frames.T0}</Text><Text selectable>V4 T1: {v4Frames.T1}</Text>
<View style={styles.row}>{qbinGoldenDatasets.map((item) => <Button key={item.id} title={`V4 ${item.id}`} onPress={() => setV4Dataset(item)} />)}</View><Button title="LOAD SELECTED V4 DATASET" onPress={() => void loadV4Dataset()} />
<Button title={finalControlRunning ? "RUN V4 FINAL CONTROL REGRESSION (RUNNING)" : "RUN V4 FINAL CONTROL REGRESSION"} disabled={finalControlRunning} onPress={() => void runV4FinalControlRegression()} />
<Text selectable>{finalControlSummary}</Text>
<View style={styles.row}><Button title="V4 seek T0" onPress={() => void command("V4 seek T0", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[0].validTimeMs))} /><Button title="V4 seek midpoint" onPress={() => void command("V4 seek midpoint (mix not exposed)", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs))} /><Button title="V4 seek T1" onPress={() => void command("V4 seek T1", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[1].validTimeMs))} /></View>
<View style={styles.row}><Button title="V4 PLAY" onPress={() => void command("V4 PLAY", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PLAY"))} /><Button title="V4 PAUSE" onPress={() => void command("V4 PAUSE", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PAUSE"))} /><Button title="V4 speed 1.0" onPress={() => void command("V4 speed 1.0", (tag) => FieldRuntime.setEncodedForecastPlaybackSpeed(tag, 1.0))} /><Button title="V4 clear selected" onPress={() => void command("V4 clear selected", (tag) => FieldRuntime.clearEncodedField(tag, v4Dataset.fieldKey))} /><Button title="V4 clear all" onPress={() => void command("V4 clear all", (tag) => FieldRuntime.clearAllEncodedFields(tag))} /></View>
</ScrollView>

View File

@@ -856,3 +856,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- 发行固定为 `bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic`。新 Metal zip 为 `24943196` bytes,SHA-256=`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`,source commit=`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`;device binary 含 Metal/`MTLDevice`/`ParticleGPU` 且无 OpenGL marker,远端 URL 仍为 `LOCAL_BUILD`。
- RDC 已实际完成 npm ci、typecheck、RN codegen、Expo prebuild、Pod integration、device arm64 build、signed Release build/签名/安装;MLRN/App/Particle 使用 single MapLibre SPM product。Pleiades iPad mini 5 上一次 V4 Wind load 观察到 clearAll、bindings、T0/T1 accepted 与 midpoint seek,用户确认粒子恢复。
- 当前状态 `IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`:PLAY/PAUSE、clear selected/all、detach/re-attach 尚未逐按钮实测;不将单次 load 证据扩大为完整控制面验收。未 commit/push。
### 2026-09-18 STEP-083 iOS RN 真机最终控制回归与正式关闭
- Pleiades iPad mini 5 真机在 Test Host signed Release 上完成 `RUN V4 FINAL CONTROL REGRESSION` 单按钮序列:`prepare#1 → PLAY → 约500ms → PAUSE → clear selected → prepare#2 → clear all → detach → attach → prepare#3 → refresh`;用户明确确认“FINAL CONTROL REGRESSION PASS”。
- 最后一次 prepare/refresh 后 Metal V4 Wind 粒子恢复并保持可见;此前用户已确认“粒子恢复了”。single MapLibre SPM、signed Release `BUILD SUCCEEDED`、安装启动与 Metal artifact(`24943196` bytes,SHA-256 `ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`)验证事实不变。
- STEP-083 正式 `CLOSED / PASS`。App.tsx 回归按钮保留且逻辑不变;未修改 RN public API/native/Core/renderer/release manifest,未提交。下一步进入 NavSea iOS 首批大集成,不再拆分微小 build/install Step。