506 lines
15 KiB
TypeScript
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,
|
|
);
|
|
});
|