mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 20:32:21 +08:00
test: trim redundant test coverage
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Stub only the delay API; these tests cover group-settle notification granularity.
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
|
||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||
@@ -22,7 +21,7 @@ const node = (name: string) =>
|
||||
},
|
||||
}) as unknown as ResolvedProxyMember
|
||||
|
||||
const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
let settles = 0
|
||||
let unsubscribe: () => void
|
||||
@@ -34,90 +33,26 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
afterEach(() => unsubscribe())
|
||||
|
||||
describe('group settle notifications', () => {
|
||||
test('a single test settles the group once', async () => {
|
||||
// A single-proxy retest must invalidate delay sorting.
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('a batch settles the group once, however many proxies it covered', async () => {
|
||||
// A batch announces once to avoid reordering rows as individual results arrive.
|
||||
const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`))
|
||||
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 4)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('an unsubscribed listener stops being called', async () => {
|
||||
unsubscribe()
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
})
|
||||
|
||||
test('two listeners on one group both hear it', async () => {
|
||||
// Multiple views must not replace one another's group subscriptions.
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('a single test inside a running batch waits for the batch', async () => {
|
||||
// Nested batches must wait for the outermost settle before announcing.
|
||||
describe('group delay completion', () => {
|
||||
test('notifies once after a batch settles', async () => {
|
||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
||||
|
||||
const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await delayManager.checkDelay(node('single') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
expect(settles).toBe(0)
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await flush()
|
||||
|
||||
await batch
|
||||
await nextFrame()
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('unsubscribing one listener leaves the others subscribed', async () => {
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('another group is not disturbed', async () => {
|
||||
test('notifies only listeners for the completed group', async () => {
|
||||
let other = 0
|
||||
const stop = delayManager.addGroupListener('other', () => {
|
||||
other += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
await flush()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(other).toBe(0)
|
||||
|
||||
+12
-60
@@ -6,22 +6,6 @@ const listen = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen }))
|
||||
|
||||
/** A `listen` whose registrations only resolve when the test says so. */
|
||||
const deferredListen = () => {
|
||||
const resolvers: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
registrations: resolvers,
|
||||
settleAll: () => resolvers.forEach((resolve) => resolve()),
|
||||
}
|
||||
}
|
||||
|
||||
/** Let every already-resolved promise in the chain run. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,8 +13,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('subscribeVergeEvents', () => {
|
||||
it('reports subscribed only once every listener is live', async () => {
|
||||
const { registrations, settleAll } = deferredListen()
|
||||
it('reports readiness only after every listener is registered', async () => {
|
||||
const registrations: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
registrations.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
subscribeVergeEvents(
|
||||
@@ -41,48 +31,16 @@ describe('subscribeVergeEvents', () => {
|
||||
onSubscribed,
|
||||
)
|
||||
|
||||
expect(registrations).toHaveLength(2)
|
||||
registrations[0]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).not.toHaveBeenCalled()
|
||||
|
||||
settleAll()
|
||||
registrations[1]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
expect(onSubscribed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not report subscribed after teardown', async () => {
|
||||
// Registration outliving the caller is the whole reason this is tracked here; resyncing
|
||||
// for a subscription that no longer exists would fetch into a cache nobody reads.
|
||||
const { settleAll } = deferredListen()
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
const teardown = subscribeVergeEvents(
|
||||
{ 'verge://run-state-changed': () => {} },
|
||||
onSubscribed,
|
||||
)
|
||||
teardown()
|
||||
|
||||
settleAll()
|
||||
await flush()
|
||||
expect(onSubscribed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports subscribed when a listener fails to register', async () => {
|
||||
// A resync is most needed when a subscription is missing, not least.
|
||||
listen.mockRejectedValue(new Error('no such event'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
subscribeVergeEvents(
|
||||
{ 'verge://run-state-changed': () => {} },
|
||||
onSubscribed,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(onSubscribed).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('delivers event payloads to the matching handler', async () => {
|
||||
it('delivers a payload to its matching handler', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>()
|
||||
listen.mockImplementation(
|
||||
(name: string, handler: (event: unknown) => void) => {
|
||||
@@ -91,16 +49,10 @@ describe('subscribeVergeEvents', () => {
|
||||
},
|
||||
)
|
||||
const onRunState = vi.fn()
|
||||
const onNotice = vi.fn()
|
||||
|
||||
subscribeVergeEvents({
|
||||
'verge://run-state-changed': onRunState,
|
||||
'verge://notice-message': onNotice,
|
||||
})
|
||||
|
||||
subscribeVergeEvents({ 'verge://run-state-changed': onRunState })
|
||||
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
|
||||
|
||||
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
|
||||
expect(onNotice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundNoticeText,
|
||||
errorDetail,
|
||||
getSnapshotNotices,
|
||||
hideNotice,
|
||||
showNotice,
|
||||
} from '@/services/notice-service'
|
||||
|
||||
/** Return the last notice text that is actually rendered. */
|
||||
function renderedDetail(): string | undefined {
|
||||
const notices = getSnapshotNotices()
|
||||
const notice = notices[notices.length - 1]
|
||||
if (typeof notice?.message === 'string') return notice.message
|
||||
const slot = notice?.i18n?.params?.message
|
||||
return typeof slot === 'string' ? slot : undefined
|
||||
}
|
||||
|
||||
/** Return the rendered failure notice fields. */
|
||||
function renderedExplanation() {
|
||||
const notices = getSnapshotNotices()
|
||||
const i18n = notices[notices.length - 1]?.i18n
|
||||
const readString = (value: unknown) =>
|
||||
typeof value === 'string' ? value : undefined
|
||||
|
||||
return {
|
||||
outerKey: i18n?.key,
|
||||
explanationKey: readString(i18n?.params?.prefixKey),
|
||||
prefix: readString(i18n?.params?.prefix),
|
||||
detail: readString(i18n?.params?.message),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
|
||||
})
|
||||
|
||||
describe('command failures reaching a notice', () => {
|
||||
it('keeps the detail when the failure is the message', () => {
|
||||
showNotice.error({
|
||||
code: 'CORE_START_FAILED',
|
||||
detail: 'connection refused',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail when a translation key comes first', () => {
|
||||
showNotice.error('settings.feedback.errors.clash.startFailed', {
|
||||
code: 'CORE_START_FAILED',
|
||||
detail: 'connection refused',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail when real translation params come first', () => {
|
||||
showNotice.error(
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
{ name: 'mihomo' },
|
||||
{ code: 'CORE_START_FAILED', detail: 'connection refused' },
|
||||
)
|
||||
|
||||
expect(renderedDetail()).toContain('connection refused')
|
||||
})
|
||||
|
||||
it('keeps the detail for a failure that carries no code', () => {
|
||||
showNotice.error('settings.feedback.errors.clash.startFailed', {
|
||||
detail: 'disk full',
|
||||
})
|
||||
|
||||
expect(renderedDetail()).toContain('disk full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('system proxy failures the user can be told about', () => {
|
||||
it('explains a fallback to a direct connection', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_DIRECT_FALLBACK',
|
||||
detail: 'service could not set the proxy',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.directFallback',
|
||||
prefix: undefined,
|
||||
detail: 'service could not set the proxy',
|
||||
})
|
||||
})
|
||||
|
||||
it('explains a guard that gave up', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('says nothing at all about a failure a dialog is already reporting', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
|
||||
detail: 'admin privileges required to modify system proxy',
|
||||
})
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
|
||||
detail: 'the core is still a sidecar',
|
||||
})
|
||||
|
||||
expect(getSnapshotNotices()).toEqual([])
|
||||
})
|
||||
|
||||
it('explains the cause even when the caller names the operation', () => {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.restartFailed',
|
||||
{
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
},
|
||||
)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the caller key in place for a code nothing explains', () => {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.restartFailed',
|
||||
{
|
||||
code: 'SOME_UNMAPPED_CODE',
|
||||
detail: 'connection refused',
|
||||
},
|
||||
)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'layout.components.serviceMigration.errors.restartFailed',
|
||||
prefix: undefined,
|
||||
detail: 'connection refused',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves literal caller text in place, mapped code or not', () => {
|
||||
showNotice.error('Could not reconnect the profile', {
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: undefined,
|
||||
prefix: 'Could not reconnect the profile',
|
||||
detail: 'the proxy could not be reasserted',
|
||||
})
|
||||
})
|
||||
|
||||
it('explains a guard that gave up', () => {
|
||||
showNotice.error({
|
||||
code: 'SYSPROXY_GUARD_STOPPED',
|
||||
detail: 'service refused three times running',
|
||||
})
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
|
||||
prefix: undefined,
|
||||
detail: 'service refused three times running',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic message for a code nothing maps', () => {
|
||||
showNotice.error({ code: 'SYSPROXY_NOT_A_REAL_CODE', detail: 'boom' })
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.prefixedRaw',
|
||||
explanationKey: 'shared.feedback.errors.operationFailed',
|
||||
prefix: undefined,
|
||||
detail: 'boom',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundNoticeText', () => {
|
||||
it('leaves text that fits exactly at the limit alone', () => {
|
||||
const exact = 'x'.repeat(500)
|
||||
|
||||
expect(boundNoticeText(exact)).toBe(exact)
|
||||
})
|
||||
|
||||
it('marks text it had to shorten', () => {
|
||||
expect(boundNoticeText('x'.repeat(501))).toBe(`${'x'.repeat(500)}…`)
|
||||
})
|
||||
|
||||
it('does not cut a surrogate pair in half', () => {
|
||||
expect(boundNoticeText('🙂'.repeat(600))).toBe(`${'🙂'.repeat(500)}…`)
|
||||
})
|
||||
|
||||
it('does not mistake a long message for a translation key', () => {
|
||||
const message = `a.${'b'.repeat(400)}`
|
||||
showNotice.error(message)
|
||||
|
||||
expect(renderedExplanation()).toEqual({
|
||||
outerKey: 'shared.feedback.notices.raw',
|
||||
explanationKey: undefined,
|
||||
prefix: undefined,
|
||||
detail: message,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leave a space before the ellipsis', () => {
|
||||
expect(boundNoticeText(`${'x'.repeat(498)} yyy`)).toBe(
|
||||
`${'x'.repeat(498)}…`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorDetail', () => {
|
||||
it('reads the detail out of a command failure', () => {
|
||||
expect(errorDetail({ code: 'X', detail: 'legacy TLS not supported' })).toBe(
|
||||
'legacy TLS not supported',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes plain strings through untouched', () => {
|
||||
expect(errorDetail('something broke')).toBe('something broke')
|
||||
})
|
||||
|
||||
it('still reads an Error, which not every failure path replaces', () => {
|
||||
expect(errorDetail(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user