refactor: 收口公开作品 ViewModel
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildPlatformRankingEntries,
|
||||
buildPublicCategoryGroups,
|
||||
buildPublicGalleryCardKey,
|
||||
filterPlatformWorkSearchResults,
|
||||
filterTodayPublishedEntries,
|
||||
getPlatformCategoryKindFilter,
|
||||
getPlatformCategoryPrimaryMetric,
|
||||
getPlatformPublicEntries,
|
||||
getPlatformRankingMetricValue,
|
||||
matchesPlatformCategoryKindFilter,
|
||||
parsePlatformEntryTimestamp,
|
||||
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 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 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(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);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user