Files
Genarrative/apps/mobile-shell/src/host-bridge/badge.test.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

315 lines
8.6 KiB
TypeScript

import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_BADGE_COUNT_MAX,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { setMobileAppBadgeCount } from './badge';
type NotificationPermissionStatus = Awaited<
ReturnType<typeof Notifications.getPermissionsAsync>
>;
const BADGE_GRANTED_PERMISSION = {
status: 'granted',
granted: true,
canAskAgain: true,
expires: 'never',
ios: {
status: 2,
allowsBadge: true,
},
} as unknown as NotificationPermissionStatus;
const BADGE_DENIED_PERMISSION = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
ios: {
status: 1,
allowsBadge: false,
},
} as unknown as NotificationPermissionStatus;
vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
setBadgeCountAsync: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
}));
function request(payload?: unknown): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'badge-request',
method: 'app.setBadgeCount',
payload,
};
}
function setPlatformOS(os: 'ios' | 'android') {
(Platform as { OS: 'ios' | 'android' }).OS = os;
}
beforeEach(() => {
setPlatformOS('ios');
vi.mocked(Notifications.getPermissionsAsync).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
vi.mocked(Notifications.setBadgeCountAsync).mockReset();
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true);
});
describe('mobile app badge helper', () => {
test('sets and clears the iOS app badge count through Expo Notifications', async () => {
expect(
await setMobileAppBadgeCount(
request({
count: 12,
}),
),
).toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'badge-request',
ok: true,
result: true,
});
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(12);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
expect(
await setMobileAppBadgeCount(
request({
count: 0,
}),
),
).toMatchObject({
ok: true,
result: true,
});
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(0);
});
test('requests iOS badge permission before updating when it is missing', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_GRANTED_PERMISSION,
);
await expect(
setMobileAppBadgeCount(
request({
count: 3,
}),
),
).resolves.toMatchObject({
ok: true,
result: true,
});
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
ios: {
allowAlert: false,
allowBadge: true,
allowSound: false,
},
});
expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(3);
});
test('rejects denied iOS badge permission before touching the system badge', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge permission denied',
});
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('maps current badge permission lookup failures to a stable HostBridge error', async () => {
const error = new Error('native badge permission failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge permission unavailable',
});
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for permission.current',
);
warnSpy.mockRestore();
});
test('maps badge permission request failures to a stable HostBridge error', async () => {
const error = new Error('native badge permission request failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
BADGE_DENIED_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(
error,
);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge permission unavailable',
});
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for permission.request',
);
warnSpy.mockRestore();
});
test('maps native badge update false results to a stable HostBridge error', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge update unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for update.rejected',
);
warnSpy.mockRestore();
});
test('maps native badge update rejections to a stable HostBridge error', async () => {
const error = new Error('native badge failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(error);
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'app badge update unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for update.set_count',
);
warnSpy.mockRestore();
});
test('rejects invalid badge counts before touching the system badge', async () => {
for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) {
await expect(
setMobileAppBadgeCount(
request({
count,
}),
),
).rejects.toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
}
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
test('rejects missing badge payload before touching the system badge', async () => {
await expect(setMobileAppBadgeCount(request({}))).rejects.toThrowError(
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
test('returns unsupported on Android before validating payload or touching badge APIs', async () => {
setPlatformOS('android');
await expect(
setMobileAppBadgeCount(
request({
count: 1,
}),
),
).rejects.toThrowError(
'app badge count is only supported on iOS mobile shell',
);
await expect(
setMobileAppBadgeCount(
request({
count: HOST_BRIDGE_BADGE_COUNT_MAX + 1,
}),
),
).rejects.toThrowError(
'app badge count is only supported on iOS mobile shell',
);
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
});
});