Files
Genarrative/apps/mobile-shell/src/host-bridge/notifications.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

335 lines
10 KiB
TypeScript

import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
showMobileHostBridgeLocalNotification,
showMobileLocalNotification,
} from './notifications';
type NotificationPermissionStatus = Awaited<
ReturnType<typeof Notifications.getPermissionsAsync>
>;
const GRANTED_NOTIFICATION_PERMISSION = {
status: 'granted',
granted: true,
canAskAgain: true,
expires: 'never',
} as NotificationPermissionStatus;
const PROVISIONAL_NOTIFICATION_PERMISSION = {
status: 'undetermined',
granted: false,
canAskAgain: true,
expires: 'never',
ios: {
status: 'provisional',
},
} as unknown as NotificationPermissionStatus;
const DENIED_NOTIFICATION_PERMISSION = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
} as NotificationPermissionStatus;
vi.mock('expo-notifications', () => ({
AndroidImportance: {
DEFAULT: 'default',
},
IosAuthorizationStatus: {
PROVISIONAL: 'provisional',
},
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
setNotificationChannelAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
}));
function request(payload?: unknown): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'notification-request',
method: 'notification.showLocal',
payload,
};
}
function setPlatformOS(os: 'ios' | 'android') {
(Platform as { OS: 'ios' | 'android' }).OS = os;
}
beforeEach(() => {
vi.mocked(Notifications.getPermissionsAsync).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.scheduleNotificationAsync).mockReset();
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue(
'notification-1',
);
vi.mocked(Notifications.setNotificationChannelAsync).mockReset();
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null);
setPlatformOS('ios');
});
describe('mobile local notification helpers', () => {
test('uses existing granted or provisional permission without prompting', async () => {
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).resolves.toEqual(
HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
);
expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce(
PROVISIONAL_NOTIFICATION_PERMISSION,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).resolves.toEqual(
HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
});
test('requests alert-only permission when current permission is missing', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).resolves.toEqual(
HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
);
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
});
test('rejects delivery when permission remains denied', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).rejects.toMatchObject({
code: 'host_error',
message: 'notification permission denied',
});
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
});
test('rejects delivery when current permission lookup is unavailable', async () => {
const error = new Error('native permission failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).rejects.toMatchObject({
code: 'host_error',
message: 'notification permission unavailable',
});
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile notification failed for permission.current',
);
warnSpy.mockRestore();
});
test('rejects delivery when permission request is unavailable', async () => {
const error = new Error('native permission failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(
error,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).rejects.toMatchObject({
code: 'host_error',
message: 'notification permission unavailable',
});
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile notification failed for permission.request',
);
warnSpy.mockRestore();
});
test('schedules iOS notification without channel trigger', async () => {
await showMobileLocalNotification({
title: '生成完成',
body: '作品已准备好',
});
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
body: '作品已准备好',
},
trigger: null,
});
});
test('uses fixed Android local notification channel', async () => {
setPlatformOS('android');
await showMobileLocalNotification({ title: '生成完成' });
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
},
trigger: {
channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
},
});
});
test('maps Android channel setup failures to stable delivery errors', async () => {
const error = new Error('native channel failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
setPlatformOS('android');
vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce(
error,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).rejects.toMatchObject({
code: 'host_error',
message: 'notification delivery unavailable',
});
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile notification failed for delivery.schedule',
);
warnSpy.mockRestore();
});
test('maps native notification schedule failures to stable delivery errors', async () => {
const error = new Error('native schedule failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(
error,
);
await expect(
showMobileLocalNotification({ title: '生成完成' }),
).rejects.toMatchObject({
code: 'host_error',
message: 'notification delivery unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile notification failed for delivery.schedule',
);
warnSpy.mockRestore();
});
test('normalizes HostBridge payload and wraps structured delivery result', async () => {
const response = await showMobileHostBridgeLocalNotification(
request({
title: ' 生成完成 ',
body: ' 作品已准备好 可以试玩 ',
}),
);
expect(response).toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'notification-request',
ok: true,
result: HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
});
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
body: '作品已准备好 可以试玩',
},
trigger: null,
});
});
test('rejects invalid HostBridge notification payload before system calls', async () => {
await expect(
showMobileHostBridgeLocalNotification(
request({
title: '生成\n完成',
}),
),
).rejects.toMatchObject({
code: 'invalid_request',
message: 'title is required',
});
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
});
});