test(游戏共创): e2e 跟进改动说明/授权继承新语义并补贡献归集断言
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s
三个发布 helper 补衍生作品必填 changeSummary(theme 脚本那 6 行是另一 session 已写好的,随本次一并提交);lineage 新增衍生作品 PUT 提升被拒 409 FORK_AUTHORIZATION_INHERITED 且档位不变、入口禁用态与未登录文案断言;新增贡献归集段(不变式 + 三链路传递 + 非作者 403 + 匿名 401)
This commit is contained in:
@@ -329,6 +329,7 @@ async function publishToPublic({
|
||||
stamp,
|
||||
tag,
|
||||
label,
|
||||
changeSummary,
|
||||
}) {
|
||||
const zip = await buildZip(`visual-${tag}-${stamp}`);
|
||||
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
@@ -341,6 +342,8 @@ async function publishToPublic({
|
||||
packageFileCount: zip.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
// 衍生作品必填「本次核心改动说明」;根节点不传(传了也会被忽略)。
|
||||
...(changeSummary ? { changeSummary } : {}),
|
||||
},
|
||||
});
|
||||
const versionId = created.data?.versionId;
|
||||
@@ -451,6 +454,12 @@ async function createPublishedNode({
|
||||
stamp,
|
||||
tag: `visual-${label}`,
|
||||
label,
|
||||
// 衍生节点(有父)必填核心改动说明:截图里的「每一代差异」用它渲染。
|
||||
...(parent
|
||||
? {
|
||||
changeSummary: `视觉夹具:节点 ${label} 相对 ${parent.label} 的核心改动说明`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const detail = await publicDetail(gameId);
|
||||
check(
|
||||
@@ -458,7 +467,18 @@ async function createPublishedNode({
|
||||
detail.data?.status === 'published',
|
||||
`status=${detail.data?.status ?? '∅'}`,
|
||||
);
|
||||
if (promoteTo) {
|
||||
// 新语义:衍生作品在创建时**继承**父作品档位(创建后忽略请求里的档位、母版才允许 PUT 提升)。
|
||||
if (parent) {
|
||||
const childView = await ownerGame(owner.token, gameId);
|
||||
const parentView = await ownerGame(parent.token, parent.gameId);
|
||||
const childTier = childView?.forkAuthorization ?? null;
|
||||
const parentTier = parentView?.forkAuthorization ?? null;
|
||||
check(
|
||||
`造树:衍生节点 ${label} 继承父档位(${childTier} === ${parentTier})`,
|
||||
childTier !== null && childTier === parentTier,
|
||||
`child=${childTier} parent=${parentTier}`,
|
||||
);
|
||||
} else if (promoteTo) {
|
||||
const promoted = await promoteForkAuthorization({
|
||||
token: owner.token,
|
||||
gameId,
|
||||
@@ -466,7 +486,7 @@ async function createPublishedNode({
|
||||
key: `visual-promote-${label}-${stamp}`,
|
||||
});
|
||||
check(
|
||||
`造树:节点 ${label} 授权=${promoteTo}`,
|
||||
`造树:母版节点 ${label} 授权提升=${promoteTo}`,
|
||||
promoted.status === 200,
|
||||
brief(promoted),
|
||||
);
|
||||
|
||||
@@ -80,8 +80,9 @@ const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作
|
||||
const LINEAGE_PARENT_UNAVAILABLE =
|
||||
process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用';
|
||||
// 详情页「改造这个作品」入口(392cb3bb0):标签与文案取自 GameDetailPage.tsx:430-490。
|
||||
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? 'Fork 这个作品';
|
||||
const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可 Fork';
|
||||
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? '一键 Fork 共创';
|
||||
const REMIX_LOGIN_LABEL =
|
||||
process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后一键 Fork';
|
||||
const REMIX_STEPS_TEXT =
|
||||
process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作';
|
||||
const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程';
|
||||
@@ -351,6 +352,9 @@ async function promoteForkAuthorization({
|
||||
}
|
||||
|
||||
/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。
|
||||
///
|
||||
/// `changeSummary` 只在**衍生作品**上传(§3.4:衍生作品发布必填「本次核心改动说明」,
|
||||
/// 母版传了也会被忽略)——调用方按 `fork` 是否存在决定要不要带。
|
||||
async function publishToPublic({
|
||||
token,
|
||||
admin,
|
||||
@@ -361,6 +365,7 @@ async function publishToPublic({
|
||||
stamp,
|
||||
tag,
|
||||
label,
|
||||
changeSummary,
|
||||
}) {
|
||||
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
method: 'POST',
|
||||
@@ -372,6 +377,7 @@ async function publishToPublic({
|
||||
packageFileCount: zip.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
...(changeSummary ? { changeSummary } : {}),
|
||||
},
|
||||
});
|
||||
const versionId = created.data?.versionId;
|
||||
@@ -784,7 +790,11 @@ async function runBrowserStep({
|
||||
const bump = (key) => () => {
|
||||
counters[key] += 1;
|
||||
};
|
||||
document.addEventListener('pointerdown', bump('pointerdown'), true);
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
bump('pointerdown'),
|
||||
true,
|
||||
);
|
||||
document.addEventListener('mousedown', bump('mousedown'), true);
|
||||
document.addEventListener('click', bump('click'), true);
|
||||
window.__lineageMouseProbe = counters;
|
||||
@@ -857,17 +867,76 @@ async function runBrowserStep({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
check(
|
||||
'浏览器视觉:授权 forbidden 的作品详情页没有「Fork 这个作品」入口',
|
||||
// 新前端:forbidden 作品的入口**照常渲染**,但为禁用态(aria-disabled + --blocked),
|
||||
// 点它只给一句说明、不展开获取面板。这里先点一次再把可观测量喂给断言。
|
||||
const blockedEntry = page
|
||||
.locator('.co-creation-fork-primary--blocked')
|
||||
.first();
|
||||
if ((await blockedEntry.count()) > 0) {
|
||||
// aria-disabled 的元素会被 Playwright 的可操作性检查拦下(element is not enabled);
|
||||
// 这里测的正是应用自己的禁用分支,因此用 force 绕过检查、把点击真的发出去。
|
||||
await blockedEntry.click({ force: true }).catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
const blockedTitle =
|
||||
(await page
|
||||
.getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true })
|
||||
.count()) === 0 &&
|
||||
(await page
|
||||
.getByRole('button', { name: REMIX_LOGIN_LABEL, exact: true })
|
||||
.count()) === 0,
|
||||
`forbidden=${forbiddenGameId}`,
|
||||
.locator('.co-creation-fork-primary--blocked')
|
||||
.first()
|
||||
.getAttribute('title')
|
||||
.catch(() => null)) ?? '';
|
||||
const blockedPanelCount = await page
|
||||
.locator('#game-fork-remix-panel')
|
||||
.count();
|
||||
check(
|
||||
'浏览器视觉:授权 forbidden 的作品入口为禁用态(aria-disabled + --blocked + 原因 title),点它不展开面板',
|
||||
(await page
|
||||
.locator('.co-creation-fork-primary[aria-disabled="true"]')
|
||||
.count()) >= 1 &&
|
||||
(await page.locator('.co-creation-fork-primary--blocked').count()) >=
|
||||
1 &&
|
||||
blockedTitle.includes('无法 Fork 这部作品') &&
|
||||
blockedPanelCount === 0,
|
||||
`forbidden=${forbiddenGameId} title=${blockedTitle.slice(0, 40)} panel=${blockedPanelCount}`,
|
||||
);
|
||||
|
||||
// 未登录访客:入口文案是另一套(登录后一键 Fork),点击走登录门禁而不是展开面板。
|
||||
const anonymousContext = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
try {
|
||||
const anonymousPage = await anonymousContext.newPage();
|
||||
await anonymousPage.goto(`${WEB}/games/detail?id=${parentId}`, {
|
||||
waitUntil: 'commit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
const anonymousEntry = anonymousPage.getByRole('button', {
|
||||
name: REMIX_LOGIN_LABEL,
|
||||
exact: true,
|
||||
});
|
||||
const anonymousEntryVisible = await visible(anonymousEntry, 60_000);
|
||||
check(
|
||||
'浏览器视觉:未登录访客看到的是「登录后一键 Fork」入口',
|
||||
anonymousEntryVisible,
|
||||
`label=${REMIX_LOGIN_LABEL}`,
|
||||
);
|
||||
if (anonymousEntryVisible) {
|
||||
await anonymousEntry.first().click();
|
||||
await anonymousPage.waitForTimeout(800);
|
||||
check(
|
||||
'浏览器视觉:未登录点击该入口走登录门禁(不展开获取面板)',
|
||||
(await anonymousPage
|
||||
.getByRole('tab', { name: '密码登录' })
|
||||
.count()) >= 1 &&
|
||||
(await anonymousPage
|
||||
.locator('#game-fork-remix-panel')
|
||||
.count()) === 0,
|
||||
`loginTab=${await anonymousPage.getByRole('tab', { name: '密码登录' }).count()} panel=${await anonymousPage.locator('#game-fork-remix-panel').count()}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await anonymousContext.close().catch(() => {});
|
||||
}
|
||||
|
||||
// 父作品软删除后:树仍在,但父节点内容不得再出现。
|
||||
const deleted = await deleteParent();
|
||||
check(
|
||||
@@ -1074,6 +1143,38 @@ async function main() {
|
||||
if (!childId) process.exit(1);
|
||||
|
||||
const childZip = await buildZip(`child-${suffix}`);
|
||||
const childChangeSummary = 'E2E:把跳台改成三段,并重画全部背景与角色立绘';
|
||||
|
||||
// 衍生作品必填「本次核心改动说明」(§3.4):缺失与超短都必须失败关闭(400 + 稳定码),
|
||||
// 且都不写库——先各试一次(用不同的 Idempotency-Key,失败不消耗版本号),
|
||||
// 再带合法说明走完整发布链路,两头都钉住。
|
||||
for (const [tag, summary, expectedCode] of [
|
||||
['missing', undefined, 'FORK_CHANGE_SUMMARY_REQUIRED'],
|
||||
['too-short', '改了', 'FORK_CHANGE_SUMMARY_INVALID'],
|
||||
]) {
|
||||
const rejected = await api(
|
||||
`/api/game-distribution/games/${childId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: forker.token,
|
||||
headers: { 'Idempotency-Key': `lineage-child-summary-${tag}-${stamp}` },
|
||||
body: {
|
||||
packageSha256: childZip.sha256,
|
||||
packageBytes: childZip.bytes.length,
|
||||
packageFileCount: childZip.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: childMetadata,
|
||||
...(summary === undefined ? {} : { changeSummary: summary }),
|
||||
},
|
||||
},
|
||||
);
|
||||
check(
|
||||
`衍生作品版本:核心改动说明${tag === 'missing' ? '缺失' : '超短'} → 400 ${expectedCode}`,
|
||||
rejected.status === 400 && rejected.error?.code === expectedCode,
|
||||
brief(rejected),
|
||||
);
|
||||
}
|
||||
|
||||
await publishToPublic({
|
||||
token: forker.token,
|
||||
admin,
|
||||
@@ -1084,7 +1185,21 @@ async function main() {
|
||||
stamp,
|
||||
tag: 'child',
|
||||
label: '子作品',
|
||||
changeSummary: childChangeSummary,
|
||||
});
|
||||
const childPublicVersion = (await publicDetail(childId)).data?.currentVersion;
|
||||
check(
|
||||
'衍生作品当前版本公开下发 changeSummary',
|
||||
childPublicVersion?.changeSummary === childChangeSummary,
|
||||
`changeSummary=${String(childPublicVersion?.changeSummary ?? '∅').slice(0, 40)}`,
|
||||
);
|
||||
const parentDetailVersion = (await publicDetail(parentId)).data
|
||||
?.currentVersion;
|
||||
check(
|
||||
'母版当前版本不发 changeSummary(null,不是省略键)',
|
||||
parentDetailVersion?.changeSummary === null,
|
||||
`changeSummary=${JSON.stringify(parentDetailVersion?.changeSummary ?? '∅')}`,
|
||||
);
|
||||
|
||||
// C 段:真实树形状(锚点 = 子作品)
|
||||
const tree = await lineageOf(childId);
|
||||
@@ -1233,6 +1348,12 @@ async function main() {
|
||||
tag: `rich-${label}`,
|
||||
label: `H) 节点 ${label}`,
|
||||
quiet: true,
|
||||
// 衍生节点(有父)必填核心改动说明;根节点不传(传了也会被忽略)。
|
||||
...(parent
|
||||
? {
|
||||
changeSummary: `H) 节点 ${label} 相对 ${parent.label} 的核心改动说明`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const detail = await publicDetail(gameId);
|
||||
check(
|
||||
@@ -1240,7 +1361,38 @@ async function main() {
|
||||
published.versionId !== null && detail.data?.status === 'published',
|
||||
`versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`,
|
||||
);
|
||||
if (promoteTo) {
|
||||
// 新语义:**衍生作品在创建时继承父作品档位**(创建时快照),创建后该请求里的档位字段一律被忽略;
|
||||
// 母版(根)才允许用 PUT 提升。这里按「根 → 提升 / 衍生 → 断言继承」分流。
|
||||
if (parent) {
|
||||
const childView = await ownerGame(owner.token, gameId);
|
||||
const parentView = await ownerGame(parent.owner.token, parent.gameId);
|
||||
const childTier = childView.game?.forkAuthorization ?? null;
|
||||
const parentTier = parentView.game?.forkAuthorization ?? null;
|
||||
check(
|
||||
`H) 衍生节点 ${label} 创建时继承父作品档位(child=${childTier} === parent=${parentTier})`,
|
||||
childTier !== null && childTier === parentTier,
|
||||
`child=${childTier} parent=${parentTier}`,
|
||||
);
|
||||
if (promoteTo && promoteTo !== childTier) {
|
||||
// 新语义(实测确认):衍生作品创建后**禁止**再用 PUT 提升——稳定错误码 FORK_AUTHORIZATION_INHERITED,
|
||||
// 且档位必须保持不变(失败关闭)。这里按实测口径钉死。
|
||||
const probe = await promoteForkAuthorization({
|
||||
token: owner.token,
|
||||
gameId,
|
||||
key: `lineage-rich-probe-promote-${label}-${stamp}`,
|
||||
to: promoteTo,
|
||||
});
|
||||
const after = await ownerGame(owner.token, gameId);
|
||||
const tierAfter = after.game?.forkAuthorization ?? null;
|
||||
check(
|
||||
`H) 衍生节点 ${label} 的 PUT 提升被拒(409 FORK_AUTHORIZATION_INHERITED)且档位不变`,
|
||||
probe.status === 409 &&
|
||||
(probe.error?.code ?? '') === 'FORK_AUTHORIZATION_INHERITED' &&
|
||||
tierAfter === childTier,
|
||||
`status=${probe.status} code=${probe.error?.code ?? '∅'} tier=${childTier}→${tierAfter}`,
|
||||
);
|
||||
}
|
||||
} else if (promoteTo) {
|
||||
const promoted = await promoteForkAuthorization({
|
||||
token: owner.token,
|
||||
gameId,
|
||||
@@ -1248,7 +1400,7 @@ async function main() {
|
||||
to: promoteTo,
|
||||
});
|
||||
check(
|
||||
`H) 节点 ${label} 共创授权提升为 ${promoteTo}(供后续继续 Fork)`,
|
||||
`H) 母版节点 ${label} 共创授权提升为 ${promoteTo}(母版仍可提升)`,
|
||||
promoted.status === 200 &&
|
||||
(promoted.data?.game?.forkAuthorization ?? null) === promoteTo,
|
||||
brief(promoted),
|
||||
@@ -1423,6 +1575,173 @@ async function main() {
|
||||
`[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`,
|
||||
);
|
||||
}
|
||||
// ---------- I 段:贡献归集 /api/game-distribution/games/{game_id}/contribution(作者视角) ----------
|
||||
// 契约(读实现 + DTO 注释):Bearer;data 直接是
|
||||
// { gameId, own{playCount}, inherited{playCount}, total{playCount},
|
||||
// byGeneration[{generation,gameCount,own,inherited,total}],
|
||||
// directChildren[{gameId,generation,own,inherited,total}], nodeCount, truncated, truncatedReason }
|
||||
// 不变式:total = own + inherited;inherited = Σ byGeneration.total = Σ directChildren.total;
|
||||
// nodeCount = Σ byGeneration.gameCount(**不含根自身**);非作者按「不存在」处理。
|
||||
if (RICH_TREE) {
|
||||
const totals = (node) => node?.playCount ?? null;
|
||||
// 先记几笔游玩,让不变式里的数字不是全 0(计数端点是公开路由;失败只记 NOTE,不作为本票的断言)
|
||||
const playResults = [];
|
||||
for (const [label, node, times] of [
|
||||
['R', richNodes.R, 1],
|
||||
['E', richNodes.E, 2],
|
||||
['F1', richNodes.F1, 1],
|
||||
]) {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
const response = await api(
|
||||
`/api/game-distribution/games/${node.gameId}/plays`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
body: { clientId: `lineage-e2e-${label}-${index}-${stamp}` },
|
||||
},
|
||||
);
|
||||
playResults.push(`${label}:${response.status}`);
|
||||
}
|
||||
}
|
||||
note(`游玩计数探针(让贡献不变式非零):${playResults.join(' ')}`);
|
||||
|
||||
const contributionResponse = await api(
|
||||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||||
{ token: author.token },
|
||||
);
|
||||
const contribution =
|
||||
contributionResponse.data?.contribution ??
|
||||
contributionResponse.data ??
|
||||
null;
|
||||
check(
|
||||
'I) 作者可读贡献归集(200)',
|
||||
contributionResponse.status === 200 && contribution !== null,
|
||||
brief(contributionResponse),
|
||||
);
|
||||
const own = totals(contribution?.own);
|
||||
const inherited = totals(contribution?.inherited);
|
||||
const total = totals(contribution?.total);
|
||||
check(
|
||||
'I) 不变式:total = own + inherited',
|
||||
own !== null && inherited !== null && total === own + inherited,
|
||||
`own=${own} inherited=${inherited} total=${total}`,
|
||||
);
|
||||
const children = contribution?.directChildren ?? [];
|
||||
const sumChildren = children.reduce(
|
||||
(sum, child) => sum + (totals(child.total) ?? 0),
|
||||
0,
|
||||
);
|
||||
check(
|
||||
'I) 不变式:inherited = Σ directChildren.total',
|
||||
inherited === sumChildren && children.length === 3,
|
||||
`inherited=${inherited} Σchildren=${sumChildren} children=${children
|
||||
.map((child) => `${child.gameId.slice(-6)}:${totals(child.total)}`)
|
||||
.join(',')}`,
|
||||
);
|
||||
const generations = contribution?.byGeneration ?? [];
|
||||
const sumGenerationTotals = generations.reduce(
|
||||
(sum, entry) => sum + (totals(entry.total) ?? 0),
|
||||
0,
|
||||
);
|
||||
const sumGenerationCounts = generations.reduce(
|
||||
(sum, entry) => sum + (entry.gameCount ?? 0),
|
||||
0,
|
||||
);
|
||||
check(
|
||||
'I) 不变式:inherited = Σ byGeneration.total',
|
||||
inherited === sumGenerationTotals,
|
||||
`inherited=${inherited} Σgen=${sumGenerationTotals} gen=${JSON.stringify(
|
||||
generations.map((entry) => [
|
||||
entry.generation,
|
||||
entry.gameCount,
|
||||
totals(entry.total),
|
||||
]),
|
||||
)}`,
|
||||
);
|
||||
check(
|
||||
'I) 不变式:nodeCount = Σ byGeneration.gameCount = 5(不含根自身)',
|
||||
contribution?.nodeCount === sumGenerationCounts &&
|
||||
contribution?.nodeCount === 5,
|
||||
`nodeCount=${contribution?.nodeCount} ΣgameCount=${sumGenerationCounts}`,
|
||||
);
|
||||
check(
|
||||
'I) 小树不截断(truncated=false / truncatedReason=null)',
|
||||
contribution?.truncated === false &&
|
||||
(contribution?.truncatedReason ?? null) === null,
|
||||
`truncated=${contribution?.truncated} reason=${contribution?.truncatedReason ?? 'null'}`,
|
||||
);
|
||||
|
||||
// 三层链路的传递性:R.inherited = B1.total + C1.total + F1.total;B1.total = B1.own + D.total
|
||||
const b1ContributionResponse = await api(
|
||||
`/api/game-distribution/games/${richNodes.B1.gameId}/contribution`,
|
||||
{ token: author.token },
|
||||
);
|
||||
const b1Contribution =
|
||||
b1ContributionResponse.data?.contribution ??
|
||||
b1ContributionResponse.data ??
|
||||
null;
|
||||
const dOfB1 = (b1Contribution?.directChildren ?? []).find(
|
||||
(child) => child.gameId === richNodes.D.gameId,
|
||||
);
|
||||
const findChild = (id) => children.find((child) => child.gameId === id);
|
||||
check(
|
||||
'I) 链路传递:B1.total = B1.own + D.total',
|
||||
totals(b1Contribution?.total) ===
|
||||
(totals(b1Contribution?.own) ?? NaN) + (totals(dOfB1?.total) ?? NaN),
|
||||
`B1.own=${totals(b1Contribution?.own)} D.total=${totals(dOfB1?.total)} B1.total=${totals(b1Contribution?.total)}`,
|
||||
);
|
||||
check(
|
||||
'I) 链路传递:R.inherited = B1.total + C1.total + F1.total',
|
||||
inherited ===
|
||||
['B1', 'C1', 'F1'].reduce(
|
||||
(sum, key) =>
|
||||
sum + (totals(findChild(richNodes[key].gameId)?.total) ?? NaN),
|
||||
0,
|
||||
),
|
||||
`R.inherited=${inherited} Σ=${['B1', 'C1', 'F1']
|
||||
.map(
|
||||
(key) =>
|
||||
`${key}:${totals(findChild(richNodes[key].gameId)?.total)}`,
|
||||
)
|
||||
.join(',')}`,
|
||||
);
|
||||
// E 属作者乙(forker)——贡献归集是**作者视角**接口,非作者会 403,所以用它的 owner token 查。
|
||||
const eContributionResponse = await api(
|
||||
`/api/game-distribution/games/${richNodes.E.gameId}/contribution`,
|
||||
{ token: forker.token },
|
||||
);
|
||||
const eContribution =
|
||||
eContributionResponse.data?.contribution ??
|
||||
eContributionResponse.data ??
|
||||
null;
|
||||
check(
|
||||
'I) 最深一层 E:无子代(nodeCount=0、inherited=0、total=own)',
|
||||
eContribution?.nodeCount === 0 &&
|
||||
totals(eContribution?.inherited) === 0 &&
|
||||
totals(eContribution?.total) === totals(eContribution?.own),
|
||||
`nodeCount=${eContribution?.nodeCount} own=${totals(eContribution?.own)} inherited=${totals(eContribution?.inherited)} total=${totals(eContribution?.total)}`,
|
||||
);
|
||||
|
||||
const foreignContribution = await api(
|
||||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||||
{ token: forker.token },
|
||||
);
|
||||
check(
|
||||
'I) 非作者读贡献归集被拒(403 FORBIDDEN「游戏 owner 不匹配」,不泄露计数)',
|
||||
foreignContribution.status === 403 &&
|
||||
(foreignContribution.error?.code ?? '') === 'FORBIDDEN' &&
|
||||
!foreignContribution.text.includes('playCount'),
|
||||
brief(foreignContribution),
|
||||
);
|
||||
const anonymousContribution = await api(
|
||||
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
|
||||
);
|
||||
check(
|
||||
'I) 匿名读贡献归集 → 401',
|
||||
anonymousContribution.status === 401,
|
||||
brief(anonymousContribution),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// G 段:Fork 取件通道(M2a)
|
||||
|
||||
@@ -249,6 +249,8 @@ async function buildReleaseZip(marker) {
|
||||
}
|
||||
|
||||
/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。
|
||||
///
|
||||
/// `changeSummary` 只在**衍生作品**上传:它是衍生作品发布新版本的必填项(母版忽略)。
|
||||
async function publishToPublic({
|
||||
token,
|
||||
admin,
|
||||
@@ -259,6 +261,7 @@ async function publishToPublic({
|
||||
tag,
|
||||
label,
|
||||
quiet = false,
|
||||
changeSummary,
|
||||
}) {
|
||||
const zip = await buildReleaseZip(`theme-${tag}-${stamp}`);
|
||||
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
@@ -271,6 +274,7 @@ async function publishToPublic({
|
||||
packageFileCount: zip.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
...(changeSummary ? { changeSummary } : {}),
|
||||
},
|
||||
});
|
||||
const versionId = created.data?.versionId;
|
||||
@@ -561,6 +565,8 @@ async function main() {
|
||||
tag: 'child',
|
||||
label: '衍生作品 C',
|
||||
quiet: true,
|
||||
// 衍生作品必填「本次核心改动说明」,母版忽略该字段。
|
||||
changeSummary: 'E2E:主题用例里的衍生作品核心改动说明',
|
||||
});
|
||||
check(
|
||||
'衍生作品 C 已公开可读',
|
||||
|
||||
Reference in New Issue
Block a user