diff --git a/src/ui/scripts/time.js b/src/ui/scripts/time.js index eec4de0..295261b 100644 --- a/src/ui/scripts/time.js +++ b/src/ui/scripts/time.js @@ -37,6 +37,7 @@ export function getTimeCode() { this.syncAttemptId = 0; this.activeSampleControllers = new Set(); this.started = false; + this.hasSettledSync = false; this.loadCachedOffset(); } @@ -248,6 +249,7 @@ export function getTimeCode() { this.localWallAtSyncMs = Date.now(); this.monotonicEpochAtSyncMs = this.monotonicEpochNow(); this.status = 'synced'; + this.hasSettledSync = true; this.generation += 1; this.persistOffset(); this.renderStatus(); @@ -256,6 +258,7 @@ export function getTimeCode() { } markSyncFailure() { + this.hasSettledSync = true; this.status = Number.isFinite(this.offsetMs) ? 'cached' : 'local'; this.renderStatus(); } @@ -352,7 +355,11 @@ export function getTimeCode() { const isStale = Number.isFinite(ageMs) && ageMs > CLOCK_SYNC_STALE_MS; let message = ''; - if (this.status === 'local' && !this.syncPromise) { + // 首次校准落地前不提示:页面刚加载时缓存偏移已生效但校准仍在进行, + // 此时提示会在校准成功后立即消失,表现为刷新时的一次闪烁。 + if (!this.hasSettledSync) { + message = ''; + } else if (this.status === 'local') { message = '无法校准服务器时间,OTP 正在使用设备时间,可能不正确。'; } else if (this.status === 'cached') { const ageText = this.formatAge(ageMs); diff --git a/tests/scripts/otp-time-sync.test.js b/tests/scripts/otp-time-sync.test.js index c821481..261b539 100644 --- a/tests/scripts/otp-time-sync.test.js +++ b/tests/scripts/otp-time-sync.test.js @@ -548,6 +548,61 @@ describe('trusted browser clock', () => { await expect(backgroundSync).resolves.toBe(true); }); + it('keeps the warning hidden on load while the first sync is still in flight', async () => { + const elements = createWarningElements(); + const harness = createHarness({ + elements, + storageValues: { + [STORAGE_KEY]: createCachedClock({ + offsetMs: 12_000, + localWallAtSyncMs: SERVER_BASE_MS - 60 * 60 * 1000, + }), + }, + }); + expect(harness.api.trustedClock.status).toBe('cached'); + + harness.queueSamples([20, 40, 60].map((rttMs) => ({ rttMs, offsetMs: 15_000 }))); + harness.api.trustedClock.start(); + const initialSync = harness.api.trustedClock.syncPromise; + + expect(elements.clockWarning.hidden).toBe(true); + expect(elements.clockWarningText.textContent).toBe(''); + expect(elements.clockWarning.classList.toggle).not.toHaveBeenCalledWith('show', true); + + await harness.completePendingRequests(); + await expect(initialSync).resolves.toBe(true); + + expect(harness.api.trustedClock.status).toBe('synced'); + expect(elements.clockWarning.hidden).toBe(true); + expect(elements.clockWarning.classList.toggle).not.toHaveBeenCalledWith('show', true); + }); + + it('shows the cached warning once the first sync fails instead of before it settles', async () => { + const elements = createWarningElements(); + const harness = createHarness({ + elements, + storageValues: { + [STORAGE_KEY]: createCachedClock({ + offsetMs: 12_000, + localWallAtSyncMs: SERVER_BASE_MS - 60 * 60 * 1000, + }), + }, + }); + + harness.queueSamples([20, 40, 60].map((rttMs) => ({ reject: true, rttMs }))); + harness.api.trustedClock.start(); + const initialSync = harness.api.trustedClock.syncPromise; + expect(elements.clockWarning.hidden).toBe(true); + + await harness.completePendingRequests(); + await expect(initialSync).resolves.toBe(false); + + expect(harness.api.trustedClock.status).toBe('cached'); + expect(elements.clockWarning.hidden).toBe(false); + expect(elements.clockWarning.classList.toggle).toHaveBeenLastCalledWith('show', true); + expect(elements.clockWarningText.textContent).not.toBe(''); + }); + it('shows a stale warning after 24 hours during periodic and offline status refreshes', async () => { const elements = createWarningElements(); const harness = createHarness({ elements });