补充创作者主页与关注粉丝工程文档 (#638)
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。 桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。 工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。 验证: - 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。 - npm run check:encoding 通过。 - npm run check:doc-index 通过。 - git diff --check 与暂存差异检查通过。 - 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。 Reviewed-on: #638 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #638.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/** 隔离创作者数据库的真实浏览器验收。先运行 test-creator-runtime.py,再启动同库 dev:web。
|
||||
* CREATOR_PLAYWRIGHT_DIR 指向临时安装的 playwright;浏览器、截图与登录信息均不入库。
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const state = JSON.parse(fs.readFileSync('.app/dev-stack.json', 'utf8'));
|
||||
assert.equal(state.database, 'creator-hub-validation');
|
||||
const web = state.services.web.url;
|
||||
const api = state.services['api-server'].url;
|
||||
for (const url of [web, api]) assert.equal(new URL(url).hostname, '127.0.0.1');
|
||||
const runtimeRequire = process.env.CREATOR_PLAYWRIGHT_DIR
|
||||
? createRequire(
|
||||
path.resolve(process.env.CREATOR_PLAYWRIGHT_DIR, 'package.json'),
|
||||
)
|
||||
: require;
|
||||
const { chromium } = runtimeRequire('playwright');
|
||||
async function json(endpoint, token, method = 'GET', body) {
|
||||
const response = await fetch(api + endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
assert.ok(response.ok, `${method} ${endpoint}: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data ?? result;
|
||||
}
|
||||
async function login(n) {
|
||||
return json('/api/auth/entry', null, 'POST', {
|
||||
purePhoneNumber: String(13900006000 + n),
|
||||
password: 'Creator-test-2026',
|
||||
});
|
||||
}
|
||||
async function waitRows(page, count) {
|
||||
await page.waitForFunction(
|
||||
(n) => document.querySelectorAll('.creator-list > li').length === n,
|
||||
count,
|
||||
);
|
||||
}
|
||||
(async () => {
|
||||
const [a, b] = await Promise.all([login(1), login(2)]);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const errors = [];
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
});
|
||||
await context.addInitScript(
|
||||
(token) =>
|
||||
localStorage.setItem('genarrative.auth.access-token.v1', token),
|
||||
a.token,
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(web + '/creators');
|
||||
await page.locator('.game-card').first().waitFor();
|
||||
assert.equal(await page.locator('.game-card').count(), 48);
|
||||
assert.deepEqual(
|
||||
await page
|
||||
.getByRole('navigation', { name: '平台导航', exact: true })
|
||||
.getByRole('button')
|
||||
.allTextContents(),
|
||||
['创作', '项目', '游戏', '创作者主页', '我的'],
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('.creator-profile')
|
||||
.getByRole('button', { name: '取消关注' })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: '/tmp/creator-home-desktop.png' });
|
||||
await page.locator('.game-card').first().click();
|
||||
await page.locator('.game-detail-page').waitFor();
|
||||
await page.locator('.game-detail-page .creator-user-link').click();
|
||||
await page.locator('.creator-counts').waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('id'), a.user.id);
|
||||
await page
|
||||
.getByRole('navigation', { name: '平台导航', exact: true })
|
||||
.getByRole('button', { name: '创作者主页' })
|
||||
.click();
|
||||
assert.equal(new URL(page.url()).search, ''); // 同舞台切回本人,URL 改变也会重绘。
|
||||
await page.locator('.creator-counts a').first().click();
|
||||
await waitRows(page, 20);
|
||||
await page.getByRole('button', { name: '加载更多' }).click();
|
||||
await waitRows(page, 40);
|
||||
await page.locator('.platform-tab-panel').evaluate((el) => {
|
||||
el.scrollTop = 650;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
await page.locator('.creator-list .creator-user-link').nth(7).click();
|
||||
await page.locator('.creator-counts').waitFor();
|
||||
await page.goBack();
|
||||
await waitRows(page, 40);
|
||||
assert.ok(
|
||||
await page
|
||||
.locator('.platform-tab-panel')
|
||||
.evaluate((el) => el.scrollTop > 200),
|
||||
);
|
||||
const first = page.locator('.creator-list > li').first();
|
||||
const target = new URL(
|
||||
await first.locator('a').getAttribute('href'),
|
||||
web,
|
||||
).searchParams.get('id');
|
||||
await first.getByRole('button', { name: '取消关注' }).click();
|
||||
await first.getByRole('button', { name: '重新关注' }).waitFor();
|
||||
await first.getByRole('button', { name: '重新关注' }).click();
|
||||
await first.getByRole('button', { name: '取消关注' }).waitFor();
|
||||
await first.getByRole('button', { name: '取消关注' }).click();
|
||||
await first.getByRole('button', { name: '重新关注' }).waitFor();
|
||||
await page.getByRole('button', { name: '刷新列表' }).click();
|
||||
await page.waitForFunction(
|
||||
(id) =>
|
||||
!document.querySelector(`.creator-list a[href="/creators?id=${id}"]`),
|
||||
target,
|
||||
);
|
||||
await json(`/api/creators/${target}/follow`, a.token, 'PUT');
|
||||
// 同一个列表舞台切换到粉丝,验证双向独立和移除确认。
|
||||
await page
|
||||
.getByRole('navigation', { name: '关系列表' })
|
||||
.getByRole('link', { name: '粉丝', exact: true })
|
||||
.click();
|
||||
await page.getByRole('heading', { name: /我的粉丝/ }).waitFor();
|
||||
await waitRows(page, 20);
|
||||
const fan = page.locator('.creator-list > li').first();
|
||||
const fanId = new URL(
|
||||
await fan.locator('a').getAttribute('href'),
|
||||
web,
|
||||
).searchParams.get('id');
|
||||
await fan.getByRole('button', { name: '取消关注' }).click();
|
||||
await fan.getByRole('button', { name: '回关' }).waitFor();
|
||||
await fan.getByRole('button', { name: '回关' }).click();
|
||||
await fan.getByRole('button', { name: '取消关注' }).waitFor();
|
||||
await fan.getByRole('button', { name: '移除粉丝' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click();
|
||||
await fan.getByRole('button', { name: '移除粉丝' }).click();
|
||||
await dialog.getByRole('button', { name: '移除粉丝', exact: true }).click();
|
||||
await page.waitForFunction(
|
||||
(id) =>
|
||||
!document.querySelector(`.creator-list a[href="/creators?id=${id}"]`),
|
||||
fanId,
|
||||
);
|
||||
const relation = await json(`/api/creators/${fanId}/relationship`, a.token);
|
||||
assert.equal(relation.relationship.isFollowing, true);
|
||||
assert.equal(relation.relationship.isFollowedBy, false);
|
||||
console.log(
|
||||
'PASS desktop: navigation, author/game links, pagination/back/scroll, unfollow retention, follow-back and remove',
|
||||
);
|
||||
// 上一步可能移除了 B 的入边,重建只读列表验收所需的双方关系。
|
||||
await json(`/api/creators/${a.user.id}/follow`, b.token, 'PUT');
|
||||
await json(`/api/creators/${b.user.id}/follow`, a.token, 'PUT');
|
||||
// 他人两类列表只有主页链接,不读取访问者与列表用户的关系。
|
||||
for (const tab of ['following', 'followers']) {
|
||||
const requests = [];
|
||||
const record = (request) => {
|
||||
if (request.url().includes('/relationship'))
|
||||
requests.push(request.url());
|
||||
};
|
||||
page.on('request', record);
|
||||
await page.goto(`${web}/creators/connections?id=${b.user.id}&tab=${tab}`);
|
||||
await page.locator('.creator-list > li').first().waitFor();
|
||||
assert.equal(await page.locator('.creator-list button').count(), 0);
|
||||
assert.equal(requests.length, 0);
|
||||
page.off('request', record);
|
||||
await page.locator('.creator-list a').first().click();
|
||||
await page.locator('.creator-counts').waitFor();
|
||||
}
|
||||
await page.goto(`${web}/creators?id=${b.user.id}`);
|
||||
await page.locator('.creator-relation button').first().waitFor();
|
||||
const follow = page.locator('.creator-relation button').first();
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.creator-relation button')?.disabled,
|
||||
);
|
||||
const oldLabel = await follow.innerText();
|
||||
await follow.click();
|
||||
await page.waitForFunction(
|
||||
(label) =>
|
||||
document.querySelector('.creator-relation button')?.textContent !==
|
||||
label &&
|
||||
!document.querySelector('.creator-relation button')?.disabled,
|
||||
oldLabel,
|
||||
);
|
||||
await follow.click();
|
||||
console.log(
|
||||
'PASS public lists: both read-only, no relationship requests, all users link to creator pages',
|
||||
);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(web + '/creators');
|
||||
await page.locator('.creator-profile').waitFor();
|
||||
const welcome = page.getByRole('button', { name: '好', exact: true });
|
||||
if (await welcome.isVisible()) await welcome.click();
|
||||
assert.deepEqual(
|
||||
await page
|
||||
.getByRole('navigation', { name: '移动平台导航' })
|
||||
.getByRole('button')
|
||||
.allTextContents(),
|
||||
['游戏', '创作者主页', '我的'],
|
||||
);
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: '/tmp/creator-home-mobile.png' });
|
||||
await page.locator('.creator-counts a').last().click();
|
||||
await page.locator('.creator-list > li').first().waitFor();
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: '/tmp/creator-followers-mobile.png' });
|
||||
await page.locator('.creator-list a').first().focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.creator-counts').waitFor();
|
||||
const guest = await browser.newContext({
|
||||
viewport: { width: 375, height: 812 },
|
||||
});
|
||||
const guestPage = await guest.newPage();
|
||||
await guestPage.goto(`${web}/creators?id=${b.user.id}`);
|
||||
await guestPage.locator('.creator-profile').waitFor();
|
||||
const guestWelcome = guestPage.getByRole('button', {
|
||||
name: '好',
|
||||
exact: true,
|
||||
});
|
||||
if (await guestWelcome.isVisible()) await guestWelcome.click();
|
||||
for (const tab of ['following', 'followers']) {
|
||||
await guestPage.goto(
|
||||
`${web}/creators/connections?id=${b.user.id}&tab=${tab}`,
|
||||
);
|
||||
await guestPage.locator('.creator-list > li').first().waitFor();
|
||||
if (await guestWelcome.isVisible()) await guestWelcome.click();
|
||||
assert.equal(await guestPage.locator('.creator-list button').count(), 0);
|
||||
await guestPage
|
||||
.getByRole('button', { name: '返回', exact: true })
|
||||
.click();
|
||||
await guestPage.locator('.creator-profile').waitFor();
|
||||
assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id);
|
||||
}
|
||||
await guestPage.getByRole('button', { name: '关注', exact: true }).click();
|
||||
await guestPage.getByRole('dialog').waitFor();
|
||||
assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS mobile 375px: three navigation entries, no horizontal overflow, keyboard links, guest login without follow',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user