Files
Genarrative/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
T

506 lines
15 KiB
TypeScript

import { expect, test } from 'vitest';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getNextPlatformCategorySortMode,
getPlatformCategoryKindFilter,
getPlatformCategoryKindFilterOption,
getPlatformCategoryPrimaryMetric,
getPlatformCategorySortOption,
getPlatformPublicEntries,
getPlatformRankingMetric,
getPlatformRankingMetricValue,
getPlatformRankingTabConfig,
matchesPlatformCategoryKindFilter,
parsePlatformEntryTimestamp,
PLATFORM_CATEGORY_KIND_FILTERS,
PLATFORM_CATEGORY_SORT_OPTIONS,
PLATFORM_RANKING_TABS,
type PlatformCategoryKindFilter,
type PlatformCategorySortMode,
selectAdjacentPlatformRecommendEntry,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import type {
PlatformJumpHopGalleryCard,
PlatformPuzzleGalleryCard,
PlatformWoodenFishGalleryCard,
} from './rpgEntryWorldPresentation';
function buildPuzzleEntry(
overrides: Partial<PlatformPuzzleGalleryCard> = {},
): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
workId: 'puzzle-work',
profileId: 'shared-profile',
publicWorkCode: 'PZ-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
worldName: '星桥拼图',
subtitle: '拼图副标题',
summaryText: '星桥机关摘要',
coverImageSrc: null,
themeTags: ['星桥', '机关'],
visibility: 'published',
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
...overrides,
};
}
function buildJumpHopEntry(
overrides: Partial<PlatformJumpHopGalleryCard> = {},
): PlatformJumpHopGalleryCard {
return {
sourceType: 'jump-hop',
workId: 'jump-hop-work',
profileId: 'shared-profile',
publicWorkCode: 'JH-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '跳一跳作者',
worldName: '星桥跳一跳',
subtitle: '跳一跳副标题',
summaryText: '跳一跳摘要',
coverImageSrc: null,
themeTags: ['跳跃'],
visibility: 'published',
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
...overrides,
};
}
function buildWoodenFishEntry(
overrides: Partial<PlatformWoodenFishGalleryCard> = {},
): PlatformWoodenFishGalleryCard {
return {
sourceType: 'wooden-fish',
workId: 'wooden-fish-work',
profileId: 'shared-profile',
publicWorkCode: 'WF-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '木鱼作者',
worldName: '星桥木鱼',
subtitle: '木鱼副标题',
summaryText: '木鱼摘要',
coverImageSrc: null,
themeTags: ['敲木鱼'],
visibility: 'published',
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
...overrides,
};
}
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'shared-profile',
publicWorkCode: 'CW-SHARED',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
authorDisplayName: 'RPG 作者',
worldName: '星桥 RPG',
subtitle: 'RPG 副标题',
summaryText: 'RPG 摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
test('public gallery ViewModel keeps play kinds distinct in card keys', () => {
expect(buildPublicGalleryCardKey(buildPuzzleEntry())).toBe(
'puzzle:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildJumpHopEntry())).toBe(
'jump-hop:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildWoodenFishEntry())).toBe(
'wooden-fish:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildRpgEntry())).toBe(
'rpg:user-1:shared-profile',
);
});
test('public gallery ViewModel dedupes merged public entries by latest source', () => {
const oldPuzzle = buildPuzzleEntry({
worldName: '旧拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const latestPuzzle = buildPuzzleEntry({
worldName: '新拼图',
updatedAt: '2026-05-02T00:00:00.000Z',
});
expect(getPlatformPublicEntries([oldPuzzle], [latestPuzzle])).toEqual([
latestPuzzle,
]);
const categoryGroups = buildPublicCategoryGroups([oldPuzzle], [latestPuzzle]);
expect(categoryGroups.find((group) => group.tag === '星桥')).toEqual({
tag: '星桥',
entries: [latestPuzzle],
});
});
test('public gallery ViewModel builds recommend feed from general public entries', () => {
const featuredPuzzle = buildPuzzleEntry({
profileId: 'shared',
worldName: '精选旧拼图',
});
const latestPuzzle = buildPuzzleEntry({
profileId: 'shared',
worldName: '最新拼图',
});
const edutainmentPuzzle = buildPuzzleEntry({
profileId: 'edutainment',
themeTags: ['寓教于乐'],
});
const jumpHopEntry = buildJumpHopEntry({ profileId: 'jump-hop' });
expect(
buildPlatformRecommendFeedEntries(
[featuredPuzzle, edutainmentPuzzle],
[latestPuzzle, jumpHopEntry],
),
).toEqual([latestPuzzle, jumpHopEntry]);
expect(
dedupePlatformPublicGalleryEntries([featuredPuzzle, latestPuzzle]),
).toEqual([latestPuzzle]);
});
test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => {
const firstEntry = buildPuzzleEntry({ profileId: 'first' });
const secondEntry = buildJumpHopEntry({ profileId: 'second' });
const thirdEntry = buildWoodenFishEntry({ profileId: 'third' });
const entries = [firstEntry, secondEntry, thirdEntry];
expect(selectPlatformRecommendFeedWindow([], 'missing')).toEqual({
activeEntry: null,
activeEntryKey: null,
activeIndex: -1,
nextEntry: null,
previousEntry: null,
});
expect(selectPlatformRecommendFeedWindow([firstEntry], null)).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: null,
previousEntry: null,
});
expect(
selectPlatformRecommendFeedWindow(
entries,
buildPublicGalleryCardKey(secondEntry),
),
).toEqual({
activeEntry: secondEntry,
activeEntryKey: buildPublicGalleryCardKey(secondEntry),
activeIndex: 1,
nextEntry: thirdEntry,
previousEntry: firstEntry,
});
expect(selectPlatformRecommendFeedWindow(entries, 'missing')).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: secondEntry,
previousEntry: thirdEntry,
});
expect(selectPlatformRecommendFeedWindow(entries, null)).toEqual({
activeEntry: firstEntry,
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
activeIndex: 0,
nextEntry: secondEntry,
previousEntry: thirdEntry,
});
});
test('public gallery ViewModel selects adjacent recommend entry without self-loop', () => {
const onlyEntry = buildPuzzleEntry({ profileId: 'only' });
const nextEntry = buildJumpHopEntry({ profileId: 'next' });
expect(
selectAdjacentPlatformRecommendEntry([onlyEntry], 1, 'missing'),
).toBeNull();
expect(
selectAdjacentPlatformRecommendEntry(
[onlyEntry, nextEntry],
1,
buildPublicGalleryCardKey(onlyEntry),
),
).toBe(nextEntry);
expect(
selectAdjacentPlatformRecommendEntry([onlyEntry, nextEntry], -1, 'missing'),
).toBe(nextEntry);
});
test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => {
const nameMatch = buildPuzzleEntry({
profileId: 'name-match',
publicWorkCode: 'PZ-OLDER',
worldName: '星桥拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const codeMatch = buildPuzzleEntry({
profileId: 'code-match',
publicWorkCode: 'PZ-XING-QIAO',
worldName: '海雾机关',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const jumpHopCodeMatch = buildJumpHopEntry({
profileId: 'jump-code-match',
publicWorkCode: 'JH-XING-QIAO',
worldName: '海雾跳跃',
});
const woodenFishCodeMatch = buildWoodenFishEntry({
profileId: 'wooden-code-match',
publicWorkCode: 'WF-DEEP-CALM',
worldName: '静心木鱼',
});
expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual(
[nameMatch, codeMatch],
);
expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([
codeMatch,
]);
expect(
filterPlatformWorkSearchResults([jumpHopCodeMatch], 'jh xing-qiao'),
).toEqual([jumpHopCodeMatch]);
expect(
filterPlatformWorkSearchResults([woodenFishCodeMatch], 'wf deep_calm'),
).toEqual([woodenFishCodeMatch]);
});
test('public gallery ViewModel keeps source kinds behind one category filter seam', () => {
const jumpHopEntry = buildJumpHopEntry();
const woodenFishEntry = buildWoodenFishEntry();
const rpgEntry = buildRpgEntry();
expect(getPlatformCategoryKindFilter(jumpHopEntry)).toBe('jump-hop');
expect(getPlatformCategoryKindFilter(woodenFishEntry)).toBe('wooden-fish');
expect(getPlatformCategoryKindFilter(rpgEntry)).toBe('custom-world');
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe(
false,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe(
false,
);
});
test('public gallery ViewModel exposes category filter and sort option interface', () => {
expect(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER).toBe('all');
expect(DEFAULT_PLATFORM_CATEGORY_SORT_MODE).toBe('composite');
expect(PLATFORM_CATEGORY_KIND_FILTERS.map((option) => option.label)).toEqual([
'全部',
'拼图',
'抓鹅',
'方洞',
'视觉',
'汪汪',
'大鱼',
'跳跃',
'木鱼',
'RPG',
]);
expect(PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => option.label)).toEqual([
'综合',
'最新',
'游玩',
'点赞',
]);
expect(getPlatformCategoryKindFilterOption('match3d')).toEqual({
id: 'match3d',
label: '抓鹅',
});
expect(getPlatformCategorySortOption('latest')).toEqual({
id: 'latest',
label: '最新',
});
expect(
getPlatformCategoryKindFilterOption(
'unknown' as PlatformCategoryKindFilter,
),
).toEqual({ id: 'all', label: '全部' });
expect(
getPlatformCategorySortOption('unknown' as PlatformCategorySortMode),
).toEqual({ id: 'composite', label: '综合' });
expect(getNextPlatformCategorySortMode('composite')).toBe('latest');
expect(getNextPlatformCategorySortMode('latest')).toBe('play');
expect(getNextPlatformCategorySortMode('play')).toBe('like');
expect(getNextPlatformCategorySortMode('like')).toBe('composite');
expect(
getNextPlatformCategorySortMode('unknown' as PlatformCategorySortMode),
).toBe('composite');
});
test('public gallery ViewModel ranks entries by selected metric', () => {
const playWinner = buildJumpHopEntry({
profileId: 'play-winner',
playCount: 100,
remixCount: 1,
likeCount: 1,
recentPlayCount7d: 1,
});
const remixWinner = buildPuzzleEntry({
profileId: 'remix-winner',
playCount: 2,
remixCount: 50,
likeCount: 2,
recentPlayCount7d: 2,
});
const recentWinner = buildPuzzleEntry({
profileId: 'recent-winner',
playCount: 3,
remixCount: 3,
likeCount: 3,
recentPlayCount7d: 30,
});
const likeWinner = buildWoodenFishEntry({
profileId: 'like-winner',
playCount: 4,
remixCount: 4,
likeCount: 40,
recentPlayCount7d: 4,
});
const entries = [recentWinner, remixWinner, likeWinner, playWinner];
expect(DEFAULT_PLATFORM_RANKING_TAB).toBe('hot');
expect(PLATFORM_RANKING_TABS.map((tab) => tab.label)).toEqual([
'热门榜',
'改造榜',
'新品榜',
'点赞榜',
]);
expect(getPlatformRankingTabConfig('new')).toEqual({
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
});
expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner);
expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner);
expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner);
expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner);
expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40);
expect(getPlatformRankingMetric(recentWinner, 'new')).toEqual({
label: '近7日',
value: 30,
});
expect(getPlatformRankingMetric(playWinner, 'hot')).toEqual({
label: '游玩',
value: 100,
});
});
test('public gallery ViewModel sorts category entries and exposes primary metric', () => {
const latestEntry = buildWoodenFishEntry({
profileId: 'latest',
playCount: 1,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
});
const playEntry = buildJumpHopEntry({
profileId: 'play',
playCount: 100,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const likeEntry = buildPuzzleEntry({
profileId: 'like',
playCount: 1,
likeCount: 20,
recentPlayCount7d: 0,
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
});
const compositeEntry = buildPuzzleEntry({
profileId: 'composite',
playCount: 30,
remixCount: 30,
likeCount: 30,
recentPlayCount7d: 30,
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const entries = [likeEntry, latestEntry, compositeEntry, playEntry];
expect(sortPlatformCategoryEntries(entries, 'latest')[0]).toBe(latestEntry);
expect(sortPlatformCategoryEntries(entries, 'play')[0]).toBe(playEntry);
expect(sortPlatformCategoryEntries(entries, 'like')[0]).toBe(compositeEntry);
expect(sortPlatformCategoryEntries(entries, 'composite')[0]).toBe(
compositeEntry,
);
expect(getPlatformCategoryPrimaryMetric(likeEntry)).toEqual({
label: '点赞',
value: 20,
});
expect(
getPlatformCategoryPrimaryMetric(
buildPuzzleEntry({ likeCount: 0, recentPlayCount7d: 8, playCount: 2 }),
),
).toEqual({ label: '近7日', value: 8 });
});
test('public gallery ViewModel filters entries published on the local day', () => {
const now = new Date(2026, 5, 3, 12);
const todayEntry = buildPuzzleEntry({
profileId: 'today',
publishedAt: new Date(2026, 5, 3, 8).toISOString(),
});
const yesterdayEntry = buildPuzzleEntry({
profileId: 'yesterday',
publishedAt: new Date(2026, 5, 2, 8).toISOString(),
});
const unpublishedEntry = buildPuzzleEntry({
profileId: 'unpublished',
publishedAt: null,
});
expect(
filterTodayPublishedEntries(
[yesterdayEntry, todayEntry, unpublishedEntry],
now,
),
).toEqual([todayEntry]);
});
test('public gallery ViewModel parses backend numeric timestamps', () => {
expect(parsePlatformEntryTimestamp('1778457601.234567Z')).toBe(
1778457601234.567,
);
});