Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions frontend/src/pages/playtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Playtest

核验台只负责一件事:用角色造型中已经确认的动作帧,真实操控当前角色。

## 入口

`/playtest/:characterId/:outfitId`,可选 `?actionId=` 指定打开时绑定的动作。页面通过
`@/entities` 的 `characterApis.get` 读取 `Character`,后端地址沿用 `VITE_API_BASE_URL`。

读取失败或造型不存在时显示错误,不回退到任何内置数据。仓库里也不存放演示素材,正式路径的
每一帧都来自后端。

## 页面边界

Playtest 位于 `pages/playtest`,只依赖 `@/entities` 的 Character 公开类型与读取接口,以及
`@/shared/api` 的错误类型。页面不导入 features、不碰 Workflow 与 Generation,也不修改
Character、Outfit、Action、Frame 或后端数据。这几条由 `playtest-boundaries.test.ts` 看着。

`createPlaytestModel` 是页面内的窄适配器,只留下动作 ID、名称、类型、帧图片和播放时长。
播放顺序按 `Frame.index` 排定,不用数组下标——后端整棵下发资产树,数组顺序没有契约保证。
帧自己的 `durationMs` 优先,缺失时才按所属 Action 的 `fps` 换算;两者都不可用时使用第三级
兜底 `DEFAULT_FRAME_DURATION_MS` 的 100ms。审核、导出、时间线和生成字段不进入运行时。

## 操控

- 页面绑定当前造型下全部有帧的动作,点击动作名可以直接切换。
- 按住 A / D 或 ← / → 时,角色按 150 px/s 连续移动;存在 walk 动作时切到首个 walk 动作,
找不到时保持当前动作、移动照常。
- 松开所有横向按键后,存在 idle 动作时切回首个 idle 动作,找不到时保持当前动作。
- 动作帧按各自时长循环,角色位移使用 `requestAnimationFrame` 的真实时间差计算,两者互不
耦合。
- 舞台按实际宽度限制移动范围;窗口尺寸变化时重新计算。

视觉复用 livedemo 的白色透明棋盘画布、悬浮控制胶囊和动作状态层级,代码使用项目现有的
React、TypeScript 与 Tailwind。顶栏悬浮不占布局高度,页面自己让出 `pt-24` 的避让空间。
99 changes: 99 additions & 0 deletions frontend/src/pages/playtest/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { AppRoutes } from '@/app'
import { createProjectAssetsBackend } from '@/test/project-assets-backend'

afterEach(() => {
cleanup()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})

/**
* 停掉动画循环,让断言只看某一次输入之后的静止状态。
* 逐帧推进本身由 runtime 的纯函数测试覆盖,不靠页面测试的时序碰运气。
*/
function freezeAnimationFrame() {
vi.stubGlobal('requestAnimationFrame', () => 0)
vi.stubGlobal('cancelAnimationFrame', () => undefined)
}

function renderPlaytest(path: string, fetchFn?: typeof globalThis.fetch) {
freezeAnimationFrame()
vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test')
vi.stubGlobal('fetch', fetchFn ?? createProjectAssetsBackend().fetch)
return render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
)
}

function stageFrameUrl() {
return screen.getByRole('region', { name: '试玩舞台' }).querySelector('img')?.getAttribute('src')
}

describe('PlaytestPage', () => {
it('loads the routed character through the Character API', async () => {
renderPlaytest('/playtest/51/outfit-default')

expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy()
expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy()
expect(screen.getByRole('button', { name: '绑定动作:呼吸待机' })).toBeTruthy()
expect(screen.getByRole('button', { name: '绑定动作:行走' })).toBeTruthy()
})

it('plays frames in backend index order, not array order', async () => {
renderPlaytest('/playtest/51/outfit-default')

expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy()
// 后端给的 walk 帧顺序是 index 2、0、1;照数组播会从 walk-03 起步。
fireEvent.click(screen.getByRole('button', { name: '绑定动作:行走' }))

expect(stageFrameUrl()).toBe('https://cdn.windup.test/walk-01.png')
})

it('starts on the idle action when the route names none', async () => {
renderPlaytest('/playtest/51/outfit-default')

expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy()
expect(stageFrameUrl()).toBe('https://cdn.windup.test/idle-01.png')
})

it('opens on the action named by the route query', async () => {
renderPlaytest('/playtest/51/outfit-default?actionId=walk')

expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy()
expect(stageFrameUrl()).toBe('https://cdn.windup.test/walk-01.png')
})

it('reports a missing outfit instead of falling back to another one', async () => {
renderPlaytest('/playtest/51/outfit-missing')

expect(await screen.findByText('找不到指定造型,无法进入试玩。')).toBeTruthy()
})

it('maps the business not-found code to a stable message', async () => {
renderPlaytest('/playtest/9999/outfit-default')

expect(await screen.findByText('角色不存在')).toBeTruthy()
})

it('does not mislabel a transport failure as not found', async () => {
renderPlaytest('/playtest/51/outfit-default', () =>
Promise.reject(new TypeError('network unavailable')),
)

expect(await screen.findByText('角色读取失败')).toBeTruthy()
})

it('shows an empty stage for an outfit whose actions have no frames', async () => {
renderPlaytest('/playtest/52/outfit-draft')

expect(await screen.findByRole('heading', { name: '52 · 未命名造型' })).toBeTruthy()
expect(screen.getByText('暂无可播放帧')).toBeTruthy()
})
})
84 changes: 76 additions & 8 deletions frontend/src/pages/playtest/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,81 @@
import { PageContainer } from '@/shared/ui'
import { useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router'

/** 核验台。 */
import { characterApis, type Character } from '@/entities'
import { ApiError } from '@/shared/api'

import { PlaytestWorkbench } from './workbench'

interface PageData {
character: Character | null
error: string | null
loading: boolean
}

const initialPageData: PageData = { character: null, error: null, loading: false }

/**
* 后端把「角色不存在」表达成 HTTP 200 里的业务码 404,真正的传输失败才落在 status 上。
* 两者都要认:只看其中一个,会把找不到的角色显示成读取失败,或者反过来。
*/
function isNotFoundError(error: unknown): boolean {
return error instanceof ApiError && (error.code === 404 || error.status === 404)
}

/**
* 核验台:用角色造型里已经确认的动作帧真实操控角色。
* 页面只读 Character,不写回资产树,也不参与生成与审核。
* 读不到角色时直接报错,不退回任何内置数据。
*/
export function PlaytestPage() {
const { characterId, outfitId } = useParams()
const [searchParams] = useSearchParams()
const initialActionId = searchParams.get('actionId')
const [data, setData] = useState<PageData>(initialPageData)

useEffect(() => {
if (characterId === undefined) return

let cancelled = false
setData({ ...initialPageData, loading: true })
void characterApis.get(characterId).then(
(character) => {
if (!cancelled) setData({ character, error: null, loading: false })
},
(error: unknown) => {
if (!cancelled) {
setData({
...initialPageData,
error: isNotFoundError(error) ? '角色不存在' : '角色读取失败',
})
}
},
)

return () => {
Comment thread
huyanxius marked this conversation as resolved.
cancelled = true
}
}, [characterId])

if (characterId === undefined || outfitId === undefined)
return <PlaytestPageMessage>Playtest 路由参数不完整</PlaytestPageMessage>
if (data.error !== null) return <PlaytestPageMessage>{data.error}</PlaytestPageMessage>
if (data.loading || data.character === null)
return <PlaytestPageMessage>加载 Playtest 数据中</PlaytestPageMessage>

return (
<PlaytestWorkbench
character={data.character}
outfitId={outfitId}
initialActionId={initialActionId}
/>
)
}

function PlaytestPageMessage({ children }: { children: string }) {
return (
<PageContainer>
<section className="border border-dashed border-slate-300 p-6">
<h1 className="font-medium">核验台</h1>
<p className="mt-2 text-sm text-slate-500">本次只提交模块划分与接口,页面实现进后续 PR。</p>
</section>
</PageContainer>
<main aria-label="Playtest" className="grid min-h-screen place-items-center bg-[#dfe3df] p-6">
<p className="text-sm font-medium text-[#3d443f]">{children}</p>
</main>
)
}
119 changes: 119 additions & 0 deletions frontend/src/pages/playtest/playtest-boundaries.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: 这种一般不用提交,边界通过文档/注释/良好的目录组织一般就能体现了,不用单独写测试代码来保证

Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/// <reference types="node" />

import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'

const playtestDirectory = fileURLToPath(new URL('.', import.meta.url))
const sourceDirectory = fileURLToPath(new URL('../../', import.meta.url))
const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url))

/**
* 依赖只向下走:pages → features → entities → shared。
* Playtest 只读已确认的角色资产,用不到 features,因此这里连 features 一起挡住——
* 一旦有人想在核验台里推进流程或改资产,会先在这条测试上停下来讨论,而不是默默接上去。
*/
const forbiddenLayers = ['app', 'pages', 'features'] as const

/** 架构规则:entities 统一从 `@/entities` 这一个门进,不走模块内部路径。 */
const allowedEntityEntry = '@/entities'

/** 核验台不接受随代码入库的素材:正式路径的每一帧都必须来自后端。 */
const forbiddenAssetExtensions = /\.(?:png|jpe?g|gif|webp|avif|svg|mp4|webm)$/i

function filesUnder(directory: string, keep: (name: string) => boolean): readonly string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) return filesUnder(path, keep)
return entry.isFile() && keep(entry.name) ? [path] : []
})
}

function sourceFiles(): readonly string[] {
return filesUnder(
playtestDirectory,
(name) => /\.(?:ts|tsx)$/.test(name) && !name.includes('.test.'),
)
}

function moduleSpecifiers(source: string): readonly string[] {
return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map(
(match) => match[1] ?? '',
)
}

/**
* 把 `@/x` 与相对路径统一折算成相对 src 的位置。
* 只匹配别名字面量是拦不住的:`../../../features/workflow-controller` 解析到同一个地方。
*/
function sourceEntry(file: string, dependency: string): string | null {
const target = dependency.startsWith('@/')
? resolve(sourceDirectory, dependency.slice('@/'.length))
: dependency.startsWith('.')
? resolve(dirname(file), dependency)
: null
if (target === null) return null

const entry = relative(sourceDirectory, target)
if (entry.startsWith('..') || isAbsolute(entry)) return null

return entry.replaceAll('\\', '/')
}

/** 把相对路径也折算成 entities 内部路径,避免用 `../../entities/character` 绕过别名检查。 */
function entityEntry(file: string, dependency: string): string | null {
if (dependency === allowedEntityEntry) return ''
if (dependency.startsWith(`${allowedEntityEntry}/`)) {
return dependency.slice(`${allowedEntityEntry}/`.length)
}
if (!dependency.startsWith('.')) return null

const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency))
if (relativeEntry.startsWith('..') || isAbsolute(relativeEntry)) return null

return relativeEntry.replaceAll('\\', '/').replace(/\/index$/, '')
}

describe('Playtest 架构边界', () => {
it('确实扫到了源码', () => {
// 目录改名或后缀过滤写错时,下面几条会全部空跑而依然变绿。
expect(sourceFiles().length).toBeGreaterThan(0)
})

it('不向上或同层依赖', () => {
const playtestEntry = relative(sourceDirectory, playtestDirectory).replaceAll('\\', '/')

for (const file of sourceFiles()) {
const offenders = moduleSpecifiers(readFileSync(file, 'utf8')).filter((dependency) => {
const entry = sourceEntry(file, dependency)
if (entry === null) return false
// playtest 目录内部互相引用不算跨层,它本身就在 pages 下。
if (entry === playtestEntry.replace(/\/$/, '') || entry.startsWith(playtestEntry)) {
return false
}
return forbiddenLayers.some((layer) => entry === layer || entry.startsWith(`${layer}/`))
})

expect(offenders, `${file} 依赖了上层或同层模块`).toEqual([])
}
})

it('只从 @/entities 这一个门进业务模块', () => {
for (const file of sourceFiles()) {
const deepEntries = moduleSpecifiers(readFileSync(file, 'utf8'))
.map((dependency) => ({ dependency, entry: entityEntry(file, dependency) }))
.filter((candidate) => candidate.entry !== null && candidate.entry !== '')

expect(
deepEntries.map((candidate) => candidate.dependency),
`${file} 绕过了 @/entities`,
).toEqual([])
}
})

it('目录里不存放任何素材文件', () => {
// 拦住把演示角色的帧图连同代码一起提交进主干的做法。
expect(filesUnder(playtestDirectory, (name) => forbiddenAssetExtensions.test(name))).toEqual([])
})
})
Loading