Allow local env files to reliably override authentication feature flags (SMS/WeChat) by whitelisting keys in scripts/dev-utils.mjs and adding a unit test. Add SMS checks to scripts/check-api-server-env.mjs. Make server config.parse_bool tolerant of shell-wrapped quoted values (e.g. '"true"') and add tests so SMS_AUTH_ENABLED is parsed correctly when shells supply quotes. Update docs to clarify SMS env behaviour, restart requirements, and add guidance + a CSS fallback for old mobile browsers (QQ/X5) so public cover images render even when aspect-ratio is unsupported. Also include related frontend test and component adjustments and add puzzle onboarding handlers/endpoints in server-rs/crates/api-server/src/puzzle.rs.
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
import { expect, test } from 'vitest';
|
|
|
|
import {
|
|
buildPlatformWorldDisplayTags,
|
|
buildPuzzleWorkCoverSlides,
|
|
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
|
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
|
formatPlatformWorkDisplayName,
|
|
formatPlatformWorkDisplayTags,
|
|
formatPlatformWorldTime,
|
|
isEdutainmentGalleryEntry,
|
|
isVisualNovelGalleryEntry,
|
|
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
|
mapVisualNovelWorkToPlatformGalleryCard,
|
|
type PlatformEdutainmentGalleryCard,
|
|
type PlatformPuzzleGalleryCard,
|
|
resolvePlatformPublicWorkCode,
|
|
resolvePlatformWorldFallbackCoverImage,
|
|
} from './rpgEntryWorldPresentation';
|
|
|
|
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
|
|
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
|
|
});
|
|
|
|
test('formatPlatformWorldTime keeps full year for iso date strings', () => {
|
|
expect(formatPlatformWorldTime('2026-04-25T12:00:00.000Z')).toBe(
|
|
'2026-04-25',
|
|
);
|
|
});
|
|
|
|
test('formatPlatformWorldTime uses utc calendar date for zulu time', () => {
|
|
expect(formatPlatformWorldTime('2026-04-25T00:30:00.000Z')).toBe(
|
|
'2026-04-25',
|
|
);
|
|
});
|
|
|
|
test('formatPlatformWorldTime keeps fallback text for invalid values', () => {
|
|
expect(formatPlatformWorldTime(null)).toBe('未发布');
|
|
expect(formatPlatformWorldTime('not-a-date')).toBe('not-a-date');
|
|
});
|
|
|
|
test('platform work display text limits names and tags by character count', () => {
|
|
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
|
|
'热门高分拼图超长',
|
|
);
|
|
expect(
|
|
formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签']),
|
|
).toEqual(['超长机关', '星桥']);
|
|
});
|
|
|
|
test('platform public cards use play type reference images as cover fallback', () => {
|
|
const puzzleCard: PlatformPuzzleGalleryCard = {
|
|
sourceType: 'puzzle',
|
|
workId: 'puzzle-work-1',
|
|
profileId: 'puzzle-profile-1',
|
|
publicWorkCode: 'PZ-PUZZLE1',
|
|
ownerUserId: 'user-1',
|
|
authorDisplayName: '玩家',
|
|
worldName: '机关拼图',
|
|
subtitle: '拼图关卡',
|
|
summaryText: '公开作品',
|
|
coverImageSrc: '/generated-puzzle-assets/session/cover/image.png',
|
|
themeTags: ['拼图'],
|
|
playCount: 1,
|
|
remixCount: 0,
|
|
likeCount: 0,
|
|
visibility: 'published',
|
|
publishedAt: '2026-05-18T00:00:00.000Z',
|
|
updatedAt: '2026-05-18T00:00:00.000Z',
|
|
};
|
|
|
|
expect(resolvePlatformWorldFallbackCoverImage(puzzleCard)).toBe(
|
|
'/creation-type-references/puzzle.webp',
|
|
);
|
|
});
|
|
|
|
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
|
|
const slides = buildPuzzleWorkCoverSlides({
|
|
workId: 'work-1',
|
|
profileId: 'profile-1',
|
|
ownerUserId: 'user-1',
|
|
authorDisplayName: '玩家',
|
|
levelName: '第一关',
|
|
summary: '拼图摘要',
|
|
themeTags: ['拼图'],
|
|
coverImageSrc: '/cover.png',
|
|
publicationStatus: 'published',
|
|
updatedAt: '2026-04-25T00:00:00.000Z',
|
|
publishedAt: '2026-04-25T00:00:00.000Z',
|
|
publishReady: true,
|
|
levels: [
|
|
{
|
|
levelId: 'level-1',
|
|
levelName: '石桥',
|
|
pictureDescription: '石桥画面',
|
|
selectedCandidateId: 'candidate-2',
|
|
coverImageSrc: '/level-1-cover.png',
|
|
coverAssetId: null,
|
|
generationStatus: 'ready',
|
|
candidates: [
|
|
{
|
|
candidateId: 'candidate-1',
|
|
imageSrc: '/level-1-a.png',
|
|
assetId: 'asset-1',
|
|
prompt: '',
|
|
sourceType: 'generated',
|
|
selected: false,
|
|
},
|
|
{
|
|
candidateId: 'candidate-2',
|
|
imageSrc: '/level-1-b.png',
|
|
assetId: 'asset-2',
|
|
prompt: '',
|
|
sourceType: 'generated',
|
|
selected: false,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
levelId: 'level-2',
|
|
levelName: '星港',
|
|
pictureDescription: '星港画面',
|
|
selectedCandidateId: null,
|
|
coverImageSrc: '/level-2-cover.png',
|
|
coverAssetId: null,
|
|
generationStatus: 'ready',
|
|
candidates: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(slides).toEqual([
|
|
{
|
|
id: 'level-1',
|
|
imageSrc: '/level-1-b.png',
|
|
label: '石桥',
|
|
},
|
|
{
|
|
id: 'level-2',
|
|
imageSrc: '/level-2-cover.png',
|
|
label: '星港',
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('maps visual novel work to platform gallery card with VN public code', () => {
|
|
const card = mapVisualNovelWorkToPlatformGalleryCard({
|
|
runtimeKind: 'visual-novel',
|
|
profileId: 'vn-profile-demo-12345678',
|
|
ownerUserId: 'user-1',
|
|
title: '雨夜终章',
|
|
description: '失踪列车上的选择。',
|
|
coverImageSrc: '/vn-cover.png',
|
|
tags: ['悬疑', '列车'],
|
|
publishStatus: 'published',
|
|
publishReady: true,
|
|
playCount: 7,
|
|
updatedAt: '2026-05-07T00:00:00.000Z',
|
|
publishedAt: '2026-05-07T00:00:00.000Z',
|
|
});
|
|
|
|
expect(isVisualNovelGalleryEntry(card)).toBe(true);
|
|
expect(card.publicWorkCode).toBe('VN-12345678');
|
|
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
|
|
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
|
|
});
|
|
|
|
test('keeps baby object match public card code and template label intact', () => {
|
|
const card: PlatformEdutainmentGalleryCard = {
|
|
sourceType: 'edutainment',
|
|
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
|
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
|
workId: 'baby-object-match-work-1',
|
|
profileId: 'baby-object-match-profile-1',
|
|
sourceSessionId: 'baby-object-match-session-1',
|
|
publicWorkCode: 'EDU-BABY01',
|
|
ownerUserId: 'user-1',
|
|
authorDisplayName: '陶泥儿主',
|
|
worldName: '宝贝识物水果篮',
|
|
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
|
summaryText: '将物品放入对应的篮子里。',
|
|
coverImageSrc: null,
|
|
themeTags: ['寓教于乐'],
|
|
playCount: 3,
|
|
remixCount: 0,
|
|
likeCount: 1,
|
|
recentPlayCount7d: 3,
|
|
visibility: 'published',
|
|
publishedAt: '2026-05-11T10:00:00.000Z',
|
|
updatedAt: '2026-05-11T10:00:00.000Z',
|
|
};
|
|
|
|
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
|
expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01');
|
|
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']);
|
|
});
|
|
|
|
test('maps baby object match draft to edutainment public card', () => {
|
|
const card = mapBabyObjectMatchDraftToPlatformGalleryCard({
|
|
draftId: 'baby-object-draft-1',
|
|
profileId: 'baby-object-profile-12345678',
|
|
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
|
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
|
workTitle: '宝贝识物水果篮',
|
|
workDescription: '苹果和香蕉识物分类',
|
|
itemNames: ['苹果', '香蕉'],
|
|
itemAssets: [
|
|
{
|
|
itemId: 'baby-object-item-1',
|
|
itemName: '苹果',
|
|
imageSrc: '/apple.png',
|
|
assetObjectId: null,
|
|
generationProvider: 'placeholder',
|
|
prompt: '苹果',
|
|
},
|
|
{
|
|
itemId: 'baby-object-item-2',
|
|
itemName: '香蕉',
|
|
imageSrc: '/banana.png',
|
|
assetObjectId: null,
|
|
generationProvider: 'placeholder',
|
|
prompt: '香蕉',
|
|
},
|
|
],
|
|
visualPackage: null,
|
|
themeTags: ['寓教于乐', '宝贝识物'],
|
|
publicationStatus: 'published',
|
|
createdAt: '2026-05-11T10:00:00.000Z',
|
|
updatedAt: '2026-05-11T12:00:00.000Z',
|
|
publishedAt: '2026-05-11T12:00:00.000Z',
|
|
});
|
|
|
|
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
|
expect(card.publicWorkCode).toBe('BO-12345678');
|
|
expect(card.coverImageSrc).toBe('/apple.png');
|
|
expect(card.themeTags[0]).toBe('寓教于乐');
|
|
});
|