From 68ad3ec77c482585ab0d81436897d7a845fbea3b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:55:33 +0800 Subject: [PATCH] fix(offline): reset connection prompts after recovery (#681) --- .../__tests__/useOfflineStatus.spec.ts | 78 ++++++++++++ .../default/components/OfflinePage.vue | 6 +- .../components/__tests__/OfflinePage.spec.ts | 117 ++++++++++++++++++ vite.config.ts | 14 +++ 4 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/composables/__tests__/useOfflineStatus.spec.ts create mode 100644 src/layouts/default/components/__tests__/OfflinePage.spec.ts diff --git a/src/composables/__tests__/useOfflineStatus.spec.ts b/src/composables/__tests__/useOfflineStatus.spec.ts new file mode 100644 index 00000000..90330ab2 --- /dev/null +++ b/src/composables/__tests__/useOfflineStatus.spec.ts @@ -0,0 +1,78 @@ +import { useGlobalOfflineStatus, useOfflineStatus } from '@/composables/useOfflineStatus' +import { beforeEach, describe, expect, it } from 'vitest' + +describe('useOfflineStatus', () => { + const status = useGlobalOfflineStatus() + + beforeEach(() => { + status.markServerOnline() + }) + + it('在所有消费者之间共享连接状态,并为组件暴露只读操作边界', () => { + const anotherGlobalStatus = useGlobalOfflineStatus() + const componentStatus = useOfflineStatus() + + expect(anotherGlobalStatus.connectionStatus).toBe(status.connectionStatus) + expect(anotherGlobalStatus.connectionReason).toBe(status.connectionReason) + expect(componentStatus.connectionReason).toBe(status.connectionReason) + expect(componentStatus).not.toHaveProperty('markServerOffline') + + status.markServerOffline('timeout') + expect(componentStatus.isOnline.value).toBe(false) + expect(componentStatus.isChecking.value).toBe(false) + expect(componentStatus.isOffline.value).toBe(true) + expect(componentStatus.canPerformNetworkAction.value).toBe(false) + + const initialCheckRequestId = status.connectionCheckRequestId.value + componentStatus.requestConnectionCheck('network-error') + expect(status.connectionStatus.value).toBe('checking') + expect(status.connectionReason.value).toBe('network-error') + expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 1) + }) + + it('区分待确认与离线状态,并只在确认离线后阻断网络操作', () => { + status.markConnectionChecking('timeout') + + expect(status.connectionStatus.value).toBe('checking') + expect(status.connectionReason.value).toBe('timeout') + expect(status.isOnline.value).toBe(false) + expect(status.isChecking.value).toBe(true) + expect(status.isOffline.value).toBe(false) + expect(status.canPerformNetworkAction.value).toBe(true) + + status.markServerOffline() + + expect(status.connectionStatus.value).toBe('offline') + expect(status.connectionReason.value).toBe('server-unreachable') + expect(status.isOffline.value).toBe(true) + expect(status.canPerformNetworkAction.value).toBe(false) + + status.markServerOnline() + + expect(status.connectionStatus.value).toBe('online') + expect(status.connectionReason.value).toBeNull() + expect(status.isOnline.value).toBe(true) + expect(status.canPerformNetworkAction.value).toBe(true) + }) + + it('为网络错误和主动检查递增探测序列,并为成功响应递增恢复序列', () => { + const initialCheckRequestId = status.connectionCheckRequestId.value + const initialSuccessSequence = status.serverSuccessSequence.value + + status.reportNetworkError() + expect(status.connectionStatus.value).toBe('checking') + expect(status.connectionReason.value).toBe('network-error') + expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 1) + + status.requestConnectionCheck('browser-offline') + expect(status.connectionReason.value).toBe('browser-offline') + expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 2) + + status.requestConnectionCheck() + expect(status.connectionReason.value).toBe('browser-offline') + expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 3) + + status.markServerOnline() + expect(status.serverSuccessSequence.value).toBe(initialSuccessSequence + 1) + }) +}) diff --git a/src/layouts/default/components/OfflinePage.vue b/src/layouts/default/components/OfflinePage.vue index 02585699..2b623a35 100644 --- a/src/layouts/default/components/OfflinePage.vue +++ b/src/layouts/default/components/OfflinePage.vue @@ -40,14 +40,14 @@ function showConnectionPrompt() { /** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */ function handleConnectionStatusChange() { - // 重启期间由重启进度弹窗承载反馈,避免离线提示与进度提示叠加。 - if (isRestarting.value) return - if (connectionStatus.value === 'online') { shownConnectionPromptKeys.clear() return } + // 重启期间由重启进度弹窗承载反馈,避免离线提示与进度提示叠加。 + if (isRestarting.value) return + const promptKey = connectionStatus.value === 'checking' ? connectionStatus.value diff --git a/src/layouts/default/components/__tests__/OfflinePage.spec.ts b/src/layouts/default/components/__tests__/OfflinePage.spec.ts new file mode 100644 index 00000000..c93fe455 --- /dev/null +++ b/src/layouts/default/components/__tests__/OfflinePage.spec.ts @@ -0,0 +1,117 @@ +import OfflinePage from '@/layouts/default/components/OfflinePage.vue' +import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus' +import { mount, type VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + error: vi.fn(), + isRestarting: { value: false }, + warning: vi.fn(), +})) + +vi.mock('@/composables/useSystemRestart', () => ({ + useSystemRestartStatus: () => ({ isRestarting: mocks.isRestarting }), +})) + +vi.mock('vue-i18n', async importOriginal => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.error, warning: mocks.warning }), +})) + +describe('OfflinePage', () => { + const status = useGlobalOfflineStatus() + let wrapper: VueWrapper | undefined + + beforeEach(() => { + mocks.error.mockReset() + mocks.warning.mockReset() + mocks.isRestarting.value = false + status.markServerOnline() + wrapper = mount(OfflinePage) + }) + + afterEach(() => { + wrapper?.unmount() + status.markServerOnline() + }) + + it('同一轮 checking 状态只提示一次,并使用非阻断警告', async () => { + status.markConnectionChecking('network-error') + await nextTick() + + expect(mocks.warning).toHaveBeenCalledWith('app.connectionChecking:app.connectionCheckingMessage', { + timeout: 5000, + }) + + status.markConnectionChecking('timeout') + await nextTick() + + expect(mocks.warning).toHaveBeenCalledTimes(1) + expect(mocks.error).not.toHaveBeenCalled() + }) + + it('按离线原因显示错误,并允许不同离线状态分别提示', async () => { + status.markServerOffline('timeout') + await nextTick() + + expect(mocks.error).toHaveBeenNthCalledWith(1, 'app.serviceUnavailable:app.serviceTimeoutMessage', { + timeout: 7000, + }) + + status.markServerOffline('browser-offline') + await nextTick() + + expect(mocks.error).toHaveBeenNthCalledWith(2, 'app.serviceUnavailable:app.browserOfflineMessage', { + timeout: 7000, + }) + + status.markServerOffline('server-unreachable') + await nextTick() + + expect(mocks.error).toHaveBeenNthCalledWith(3, 'app.serviceUnavailable:app.serviceUnavailableMessage', { + timeout: 7000, + }) + expect(mocks.warning).not.toHaveBeenCalled() + }) + + it('在线恢复后允许下一轮 checking 再次提示', async () => { + status.markConnectionChecking() + await nextTick() + expect(mocks.warning).toHaveBeenCalledTimes(1) + + status.markServerOnline() + await nextTick() + status.markConnectionChecking() + await nextTick() + + expect(mocks.warning).toHaveBeenCalledTimes(2) + }) + + it('重启期间不提示,但在线恢复仍清空上一轮去重状态', async () => { + status.markConnectionChecking() + await nextTick() + expect(mocks.warning).toHaveBeenCalledTimes(1) + + mocks.isRestarting.value = true + status.markServerOffline('timeout') + await nextTick() + expect(mocks.error).not.toHaveBeenCalled() + expect(mocks.warning).toHaveBeenCalledTimes(1) + + status.markServerOnline() + await nextTick() + expect(mocks.error).not.toHaveBeenCalled() + expect(mocks.warning).toHaveBeenCalledTimes(1) + + mocks.isRestarting.value = false + status.markConnectionChecking() + await nextTick() + + expect(mocks.warning).toHaveBeenCalledTimes(2) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index c13bc24c..afaf1302 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -336,7 +336,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/views/subscribe/SubscribeShareView.vue', 'src/composables/useMediaSubscribe.ts', 'src/composables/useLlmProviderDirectory.ts', + 'src/composables/useOfflineStatus.ts', 'src/composables/useTorrentFilter.ts', + 'src/layouts/default/components/OfflinePage.vue', 'src/components/cards/SubscribeCard.vue', 'src/components/cards/UserCard.vue', 'src/components/filter/TorrentFilterBar.vue', @@ -425,6 +427,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/composables/useOfflineStatus.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/layouts/default/components/OfflinePage.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/components/cards/SubscribeCard.vue': { branches: 75, functions: 80,