071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
335 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|