fix(devices): 前端讀 tunnel_online 修雲端裝置恆離線
normalizeDevice 原本只讀 remote_status(DB 靜態值、exchange 建 device 寫死 offline 永不更新),沒讀後端即時算出的 tunnel_online → tunnel 已通 但雲端裝置列表恆顯示離線。 改為:tunnel_online === true 時 remoteStatus 覆蓋為 online,否則 fallback 回原本 remote_status 判定,兩者皆缺維持 unknown。嚴格布林比對避免 truthy 誤判。多裝置誤判限制(後端 resolveTunnelStatus 寬鬆比對、正解 R3/Phase 1) 已在 code 註解標明,不在本次前端修法範圍。 新增 4 test(true→online / false→沿用 / 缺欄位→沿用 / 皆缺→unknown), 共 16 test 全綠、tsc/eslint 乾淨。Reviewer 0C/0M。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dbe5d6a78e
commit
c0e2ac0a59
@ -87,6 +87,94 @@ describe("useDeviceStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tunnel_online === true → remoteStatus 覆蓋為 online(即使 remote_status 是 offline)", async () => {
|
||||||
|
// bug fix:後端 remote_status 是 DB 靜態 offline,但 tunnel_online 即時算出 tunnel 活著。
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remote_status: "offline", // DB 靜態值
|
||||||
|
tunnel_online: true, // 即時 tunnel 狀態
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
expect(useDeviceStore.getState().devices[0]).toMatchObject({
|
||||||
|
id: "dev-1",
|
||||||
|
remoteStatus: "online",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tunnel_online === false → 沿用 remote_status 判定(不覆蓋)", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "disconnected",
|
||||||
|
remote_status: "reconnecting",
|
||||||
|
tunnel_online: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
expect(useDeviceStore.getState().devices[0]).toMatchObject({
|
||||||
|
id: "dev-1",
|
||||||
|
remoteStatus: "reconnecting",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tunnel_online 缺欄位 → 沿用 remote_status(守住既有行為)", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "disconnected",
|
||||||
|
remote_status: "offline",
|
||||||
|
// 無 tunnel_online
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
expect(useDeviceStore.getState().devices[0]).toMatchObject({
|
||||||
|
id: "dev-1",
|
||||||
|
remoteStatus: "offline",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tunnel_online 與 remote_status 皆缺 → remoteStatus 預設 unknown", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [{ id: "dev-1", name: "KL520", type: "kl520", status: "connected" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
expect(useDeviceStore.getState().devices[0]).toMatchObject({
|
||||||
|
id: "dev-1",
|
||||||
|
remoteStatus: "unknown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => {
|
it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
jsonResponse(
|
jsonResponse(
|
||||||
|
|||||||
@ -77,6 +77,19 @@ export interface Device extends DeviceSummary {
|
|||||||
* 把後端回傳的裝置物件正規化成前端型別。
|
* 把後端回傳的裝置物件正規化成前端型別。
|
||||||
* - 接受 snake_case / camelCase 兩種形狀
|
* - 接受 snake_case / camelCase 兩種形狀
|
||||||
* - 對缺欄位寬容:`remoteStatus` 預設 `unknown`(而非 offline,避免誤判)
|
* - 對缺欄位寬容:`remoteStatus` 預設 `unknown`(而非 offline,避免誤判)
|
||||||
|
*
|
||||||
|
* remoteStatus 決策順序(bug fix:裝置恆離線):
|
||||||
|
* 1. 後端 `tunnel_online === true` → 覆蓋為 `online`
|
||||||
|
* 後端 `/api/devices` 即時查 tunnel session 算出 `tunnel_online`(devices.go),
|
||||||
|
* 是「當下 tunnel 是否活著」的真實狀態;而 `remote_status` 是 DB 靜態值,
|
||||||
|
* exchange 建立 device 時寫死 `offline` 之後永不更新,直接讀它會恆顯示離線。
|
||||||
|
* 2. 否則 fallback 回原本 `remote_status` / `remoteStatus`(守住既有行為)。
|
||||||
|
*
|
||||||
|
* ⚠️ 已知限制(技術債,只求單裝置 demo 正確):
|
||||||
|
* 後端 `tunnel_online` 由 `resolveTunnelStatus` 以寬鬆比對算出——session 未帶
|
||||||
|
* UserID/DeviceID 時(UserID=="")也會 match,故單裝置 demo 正確、多裝置場景會誤判
|
||||||
|
* (某台的 tunnel 活著可能讓另一台也判為 online)。多裝置的正解是後端 session
|
||||||
|
* backfill UserID/DeviceID(architect R3 / Phase 1),不在本次前端修法範圍內。
|
||||||
*/
|
*/
|
||||||
function normalizeDevice(raw: unknown): Device {
|
function normalizeDevice(raw: unknown): Device {
|
||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
@ -86,6 +99,11 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
// tunnel_online 為 boolean(snake_case / camelCase 皆容),true 時覆蓋 remoteStatus。
|
||||||
|
const tunnelOnline = pick<boolean>("tunnel_online", "tunnelOnline");
|
||||||
|
const rawRemoteStatus = pick<string>("remote_status", "remoteStatus") as
|
||||||
|
| RemoteStatus
|
||||||
|
| undefined;
|
||||||
return {
|
return {
|
||||||
id: String(pick<string>("id") ?? ""),
|
id: String(pick<string>("id") ?? ""),
|
||||||
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
|
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
|
||||||
@ -93,7 +111,7 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
type: String(pick<string>("type", "device_type") ?? ""),
|
type: String(pick<string>("type", "device_type") ?? ""),
|
||||||
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
||||||
remoteStatus:
|
remoteStatus:
|
||||||
(pick<string>("remote_status", "remoteStatus") as RemoteStatus) ?? "unknown",
|
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||||
firmwareVersion:
|
firmwareVersion:
|
||||||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user