Files
Genarrative/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts
2026-04-21 18:27:46 +08:00

306 lines
9.1 KiB
TypeScript

import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
CustomWorldSessionRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js';
import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js';
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
type StoredProfileEntry = CustomWorldLibraryEntry<CustomWorldProfileRecord>;
type SeedSessionRecord = CustomWorldSessionRecord & { userId: string };
function cloneRepositoryValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function ensureProfileRecord(
profileId: string,
profile: Record<string, unknown>,
): CustomWorldProfileRecord {
return {
...cloneRepositoryValue(profile),
id: profileId,
} as CustomWorldProfileRecord;
}
function buildProfileEntry(params: {
userId: string;
profileId: string;
profile: Record<string, unknown>;
authorDisplayName: string;
visibility: 'draft' | 'published';
updatedAt: string;
publishedAt: string | null;
}) {
const profileRecord = ensureProfileRecord(params.profileId, params.profile);
const metadata = extractCustomWorldLibraryMetadata(profileRecord);
return {
ownerUserId: params.userId,
profileId: params.profileId,
profile: profileRecord,
visibility: params.visibility,
publishedAt: params.visibility === 'published' ? params.publishedAt : null,
updatedAt: params.updatedAt,
authorDisplayName: params.authorDisplayName || '玩家',
worldName: metadata.worldName,
subtitle: metadata.subtitle,
summaryText: metadata.summaryText,
coverImageSrc: metadata.coverImageSrc,
themeMode: metadata.themeMode,
playableNpcCount: metadata.playableNpcCount,
landmarkCount: metadata.landmarkCount,
} satisfies StoredProfileEntry;
}
function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard {
const { profile: _profile, ...card } = entry;
return cloneRepositoryValue(card);
}
function sortEntriesByUpdatedAt<T extends { updatedAt: string }>(entries: T[]) {
return [...entries].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
}
/**
* 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口,
* 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。
*/
export function createInMemoryRpgWorldRepositoryPorts(options?: {
sessionRecords?: SeedSessionRecord[];
profileEntries?: Array<CustomWorldLibraryEntry<CustomWorldProfileRecord>>;
}) {
const sessionsByUser = new Map<string, Map<string, CustomWorldSessionRecord>>();
const profilesByUser = new Map<string, Map<string, StoredProfileEntry>>();
const ensureSessionBucket = (userId: string) => {
const currentBucket = sessionsByUser.get(userId);
if (currentBucket) {
return currentBucket;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
const ensureProfileBucket = (userId: string) => {
const currentBucket = profilesByUser.get(userId);
if (currentBucket) {
return currentBucket;
}
const nextBucket = new Map<string, StoredProfileEntry>();
profilesByUser.set(userId, nextBucket);
return nextBucket;
};
const listOwnEntries = (userId: string) =>
sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) =>
cloneRepositoryValue(entry),
);
options?.sessionRecords?.forEach((record) => {
ensureSessionBucket(record.userId).set(
record.sessionId,
cloneRepositoryValue(record),
);
});
options?.profileEntries?.forEach((entry) => {
ensureProfileBucket(entry.ownerUserId).set(
entry.profileId,
cloneRepositoryValue(entry),
);
});
const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = {
async listSessions(userId: string) {
return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map(
(record) => cloneRepositoryValue(record),
);
},
async getSession(userId: string, sessionId: string) {
const record = ensureSessionBucket(userId).get(sessionId) ?? null;
return record ? cloneRepositoryValue(record) : null;
},
async upsertSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
) {
const nextSession = cloneRepositoryValue({
...session,
userId,
sessionId,
});
ensureSessionBucket(userId).set(sessionId, nextSession);
return cloneRepositoryValue(nextSession);
},
};
const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = {
async listOwnProfiles(userId: string) {
return listOwnEntries(userId);
},
async upsertOwnProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const bucket = ensureProfileBucket(userId);
const currentEntry = bucket.get(profileId);
const now = new Date().toISOString();
const nextEntry = buildProfileEntry({
userId,
profileId,
profile,
authorDisplayName:
authorDisplayName || currentEntry?.authorDisplayName || '玩家',
visibility: currentEntry?.visibility ?? 'draft',
updatedAt: now,
publishedAt:
currentEntry?.visibility === 'published'
? currentEntry.publishedAt || now
: null,
});
bucket.set(profileId, nextEntry);
return {
entry: cloneRepositoryValue(nextEntry),
entries: await listOwnEntries(userId),
};
},
async syncProfileFromSnapshot(
userId: string,
profileId: string,
profile: Record<string, unknown>,
syncedAt: string,
) {
const bucket = ensureProfileBucket(userId);
const currentEntry = bucket.get(profileId);
bucket.set(
profileId,
buildProfileEntry({
userId,
profileId,
profile,
authorDisplayName: currentEntry?.authorDisplayName || '玩家',
visibility: currentEntry?.visibility ?? 'draft',
updatedAt: syncedAt,
publishedAt:
currentEntry?.visibility === 'published'
? currentEntry.publishedAt || syncedAt
: null,
}),
);
},
async softDeleteOwnProfile(userId: string, profileId: string) {
ensureProfileBucket(userId).delete(profileId);
return listOwnEntries(userId);
},
async publishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const bucket = ensureProfileBucket(userId);
const currentEntry = bucket.get(profileId);
if (!currentEntry) {
return null;
}
const now = new Date().toISOString();
const nextEntry = buildProfileEntry({
userId,
profileId,
profile: currentEntry.profile,
authorDisplayName:
authorDisplayName || currentEntry.authorDisplayName || '玩家',
visibility: 'published',
updatedAt: now,
publishedAt: now,
});
bucket.set(profileId, nextEntry);
return {
entry: cloneRepositoryValue(nextEntry),
entries: await listOwnEntries(userId),
};
},
async unpublishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const bucket = ensureProfileBucket(userId);
const currentEntry = bucket.get(profileId);
if (!currentEntry) {
return null;
}
const now = new Date().toISOString();
const nextEntry = buildProfileEntry({
userId,
profileId,
profile: currentEntry.profile,
authorDisplayName:
authorDisplayName || currentEntry.authorDisplayName || '玩家',
visibility: 'draft',
updatedAt: now,
publishedAt: null,
});
bucket.set(profileId, nextEntry);
return {
entry: cloneRepositoryValue(nextEntry),
entries: await listOwnEntries(userId),
};
},
async listPublishedGallery() {
return [...profilesByUser.values()]
.flatMap((bucket) => [...bucket.values()])
.filter((entry) => entry.visibility === 'published')
.sort((left, right) => {
const publishedAtDiff = (right.publishedAt || '').localeCompare(
left.publishedAt || '',
);
if (publishedAtDiff !== 0) {
return publishedAtDiff;
}
return right.updatedAt.localeCompare(left.updatedAt);
})
.map((entry) => toGalleryCard(entry));
},
async getPublishedGalleryDetail(ownerUserId: string, profileId: string) {
const entry = ensureProfileBucket(ownerUserId).get(profileId);
if (!entry || entry.visibility !== 'published') {
return null;
}
return cloneRepositoryValue(entry);
},
};
return {
rpgAgentSessionRepository,
rpgWorldProfileRepository,
};
}