接口开始5%

This commit is contained in:
rain
2026-07-22 17:31:33 +08:00
parent eced3d1e6e
commit 9b0ad62df4
426 changed files with 20111 additions and 28472 deletions
@@ -0,0 +1,131 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const baseAsset = () => ({
id: 'test-asset',
source: 'docs/design/assets/source.png',
output: 'static/assets/output.png',
sourcePixels: { width: 1536, height: 3840 },
outputPixels: { width: 1440, height: 3600 },
quality: { maxBytes: 7000000, colorSpace: 'sRGB' },
})
// schema v3 以 processing.mode 为严格判别字段。每个分支只允许自身真正消费的
// 参数,避免给不透明背景伪造透明边、色键或切片字段来“凑齐”旧结构。
const chromaAsset = () => ({
...baseAsset(),
alpha: { required: true, transparentOuterPadding: 6, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
processing: {
mode: 'chroma-stretch',
capWidth: 380,
keyColor: '#00FF00',
keyTolerance: 96,
},
})
const opaqueResizeAsset = () => ({
...baseAsset(),
processing: { mode: 'opaque-resize', resample: 'lanczos', outputMode: 'RGB' },
})
const opaqueCoverAsset = () => ({
...baseAsset(),
processing: {
mode: 'opaque-cover-crop',
resample: 'lanczos',
anchor: 'center',
outputMode: 'RGB',
},
})
const warmFrameAsset = () => ({
...baseAsset(),
alpha: { required: true, transparentOuterPadding: 0, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: false, forbidLightFringe: false, premultipliedAlphaCheck: true },
processing: {
mode: 'warm-gold-frame-extract',
borderBand: 110,
redGreenMin: 15,
greenBlueMin: 12,
redBlueMin: 35,
redMaxExclusive: 245,
blueMaxExclusive: 180,
alphaOffset: 25,
alphaScale: 6,
outputMode: 'RGBA',
},
})
const manifestWith = (asset) => ({
schemaVersion: 3,
kind: 'asset-build-manifest',
family: 'test-family-v3',
assets: [asset],
})
test('接受四种职责严格分离的 schema v3 处理模式', () => {
for (const factory of [chromaAsset, opaqueResizeAsset, opaqueCoverAsset, warmFrameAsset]) {
const manifest = manifestWith(factory())
assert.equal(validateAssetBuildManifest(manifest, workspace), manifest)
}
})
test('拒绝没有 processing.mode 的旧 schema v3 形状', () => {
const asset = chromaAsset()
delete asset.processing.mode
assert.throws(() => validateAssetBuildManifest(manifestWith(asset), workspace), /processing\.mode/i)
})
test('不透明分支拒绝透明度、边缘和其他模式的参数', () => {
const withAlpha = opaqueResizeAsset()
withAlpha.alpha = chromaAsset().alpha
assert.throws(() => validateAssetBuildManifest(manifestWith(withAlpha), workspace), /unknown field/i)
const withAnchor = opaqueResizeAsset()
withAnchor.processing.anchor = 'center'
assert.throws(() => validateAssetBuildManifest(manifestWith(withAnchor), workspace), /unknown field/i)
const withColorKey = opaqueCoverAsset()
withColorKey.processing.keyColor = '#00FF00'
assert.throws(() => validateAssetBuildManifest(manifestWith(withColorKey), workspace), /unknown field/i)
})
test('透明分支拒绝缺失 alpha/edge 及不属于自身的处理字段', () => {
const missingAlpha = chromaAsset()
delete missingAlpha.alpha
assert.throws(() => validateAssetBuildManifest(manifestWith(missingAlpha), workspace), /alpha/i)
const missingEdge = warmFrameAsset()
delete missingEdge.edge
assert.throws(() => validateAssetBuildManifest(manifestWith(missingEdge), workspace), /edge/i)
const wrongProcessing = warmFrameAsset()
wrongProcessing.processing.capWidth = 380
assert.throws(() => validateAssetBuildManifest(manifestWith(wrongProcessing), workspace), /unknown field/i)
})
test('拒绝重复输出、越界路径、未知字段和未知模式', () => {
const duplicate = manifestWith(chromaAsset())
duplicate.assets.push({ ...chromaAsset(), id: 'duplicate-output' })
assert.throws(() => validateAssetBuildManifest(duplicate, workspace), /duplicate output/i)
const escaped = manifestWith(opaqueResizeAsset())
escaped.assets[0].output = '../outside.png'
assert.throws(() => validateAssetBuildManifest(escaped, workspace), /escapes workspace/i)
const unknownField = manifestWith(opaqueResizeAsset())
unknownField.assets[0].logicalSlot = 'page'
assert.throws(() => validateAssetBuildManifest(unknownField, workspace), /unknown field/i)
const unknownMode = manifestWith(opaqueResizeAsset())
unknownMode.assets[0].processing.mode = 'future-magic'
assert.throws(() => validateAssetBuildManifest(unknownMode, workspace), /unsupported processing\.mode/i)
})
@@ -1,47 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json')
const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs')
test('G01 background candidate manifest preserves four portable masters and selects the approved long background', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
assert.equal(manifest.schemaVersion, 1)
assert.equal(manifest.page, 'G01')
assert.equal(manifest.status, 'long-flagship-selected-for-genealogy-module')
assert.equal(manifest.selectedCandidateId, 'g01-list-background-long-flagship')
assert.equal(manifest.runtimeOutput, 'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')
assert.equal(manifest.candidates.length, 4)
const ids = new Set()
for (const candidate of manifest.candidates) {
assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`)
ids.add(candidate.id)
assert.match(candidate.prompt, /\d+x\d+/)
assert.ok(candidate.sourcePixels.width > 0 && candidate.sourcePixels.height > 0)
assert.ok(['chroma-key', 'opaque-paper', 'opaque-paper-resize'].includes(candidate.processingMode))
for (const key of ['master', 'generatedOutput']) {
const absolute = path.resolve(workspace, candidate[key])
const relative = path.relative(workspace, absolute)
assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`)
}
}
const selected = manifest.candidates.find(({ id }) => id === manifest.selectedCandidateId)
assert.deepEqual(selected.sourcePixels, { width: 1536, height: 3840 })
assert.deepEqual(selected.outputPixels, { width: 1440, height: 3600 })
assert.equal(selected.processingMode, 'opaque-paper-resize')
})
test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => {
const wrapper = await readFile(wrapperPath, 'utf8')
assert.doesNotMatch(wrapper, /from ['"]sharp['"]/)
assert.match(wrapper, /build_g01_backgrounds\.py/)
assert.match(wrapper, /candidates\?\.length !== 4/)
})
@@ -1,72 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateManifest } from '../scripts/manifest-v2.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const validAsset = () => ({
id: 'a01-primary-button-v2',
assetClass: 'fixed-bitmap',
source: 'static/assets/foundation/opaque/a01-primary-button.png',
output: 'static/assets/foundation/transparent/a01-primary-button-v2.png',
logicalSlot: { widthRpx: 622, heightRpx: 92 },
outputPixels: { width: 1866, height: 276 },
render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false },
alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' },
processing: { trim: 8, capWidth: 180 },
runtime: { selector: '.login-submit', imageSelector: '.button-skin img' },
consumers: ['pages/auth/a01-entry.vue']
})
const validManifest = () => ({
schemaVersion: 2,
page: 'A01',
runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 },
assets: [validAsset()]
})
test('accepts a complete manifest v2', () => {
const manifest = validManifest()
assert.equal(validateManifest(manifest, workspace), manifest)
})
test('rejects duplicate asset ids', () => {
const manifest = validManifest()
manifest.assets.push(validAsset())
assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i)
})
test('rejects paths that escape the workspace', () => {
const manifest = validManifest()
manifest.assets[0].output = '../outside.png'
assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i)
})
test('rejects a fixed bitmap whose output ratio differs from its slot', () => {
const manifest = validManifest()
manifest.assets[0].outputPixels.height = 300
assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i)
})
test('rejects scaleToFill for uniform-only assets', () => {
const manifest = validManifest()
manifest.assets[0].render.uniMode = 'scaleToFill'
assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i)
})
test('rejects unknown asset classes', () => {
const manifest = validManifest()
manifest.assets[0].assetClass = 'mystery'
assert.throws(() => validateManifest(manifest, workspace), /assetClass/i)
})
test('rejects assets without consumers', () => {
const manifest = validManifest()
manifest.assets[0].consumers = []
assert.throws(() => validateManifest(manifest, workspace), /consumers/i)
})
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
import { resolvePythonExecutable, runPythonCommand } from '../scripts/python-runtime.mjs'
test('显式 PYTHON 配置优先于所有自动发现路径', () => {
const selected = resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: {
PYTHON: 'D:\\tools\\python.exe',
LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local',
},
platform: 'win32',
pathExists: () => true,
canRun: candidate => candidate === 'D:\\tools\\python.exe',
})
assert.equal(selected, 'D:\\tools\\python.exe')
})
test('Windows 环境在虚拟环境缺失时选择 Python Manager 的真实入口', () => {
const managerPython = path.win32.join(
'C:\\Users\\tester\\AppData\\Local',
'Python',
'bin',
'python.exe',
)
const selected = resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local' },
platform: 'win32',
pathExists: candidate => candidate === managerPython,
canRun: candidate => candidate === managerPython,
})
assert.equal(selected, managerPython)
})
test('所有候选解释器均不可用时给出可执行的中文修复指引', () => {
assert.throws(
() => resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: {},
platform: 'win32',
pathExists: () => false,
canRun: () => false,
}),
/ Python.*PYTHON.*\.venv/s,
)
})
test('统一执行器始终把禁止字节码缓存参数放在首位', () => {
let invocation
runPythonCommand({
executable: 'python-test',
args: ['script.py', '--flag'],
cwd: 'C:\\repo',
spawn: (command, args, options) => {
invocation = { command, args, options }
return { status: 0 }
},
})
assert.equal(invocation.command, 'python-test')
assert.deepEqual(invocation.args, ['-B', 'script.py', '--flag'])
assert.equal(invocation.options.cwd, 'C:\\repo')
})
@@ -0,0 +1,65 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const readManifest = async (name) => JSON.parse(await readFile(
path.join(pipelineDirectory, 'manifests', name),
'utf8',
))
test('长页面背景只由一份清单拥有六张正式输出', async () => {
const manifest = await readManifest('page-backgrounds-v3.json')
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.family, 'page-backgrounds-v3')
assert.deepEqual(
manifest.assets.map(({ id }) => id),
[
'genealogy-page-background-long',
'tree-page-background-long',
'family-page-background-long',
'records-page-background-long',
'notification-page-background-long',
'profile-page-background-long',
],
)
assert.equal(manifest.assets[0].processing.mode, 'opaque-resize')
for (const asset of manifest.assets.slice(1)) {
assert.equal(asset.processing.mode, 'opaque-cover-crop')
}
})
test('G01 空态边框拥有独立的暖金边框提取清单', async () => {
const manifest = await readManifest('g01-state-frame-v3.json')
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.family, 'g01-state-frame-v3')
assert.equal(manifest.assets.length, 1)
assert.equal(manifest.assets[0].id, 'g01-empty-panel-frame')
assert.equal(manifest.assets[0].processing.mode, 'warm-gold-frame-extract')
assert.equal(manifest.assets[0].processing.borderBand, 110)
})
test('新构建入口不再保留候选构建和 Sharp 专用命令', async () => {
const packageJson = JSON.parse(await readFile(path.join(pipelineDirectory, 'package.json'), 'utf8'))
assert.equal(
packageJson.scripts['build:page-backgrounds'],
'node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json',
)
assert.equal(
packageJson.scripts['build:g01-state-frame'],
'node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json',
)
assert.equal(packageJson.scripts['build:g01-background-candidates'], undefined)
assert.equal(packageJson.scripts['build:module-page-backgrounds'], undefined)
assert.equal(packageJson.scripts['build:g01-empty-frame'], undefined)
assert.equal(packageJson.dependencies?.sharp, undefined)
})
@@ -0,0 +1,142 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { expandRuntimeAssetInventory } from '../scripts/runtime-asset-inventory.mjs'
const createWorkspace = async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-assets-'))
await mkdir(path.join(workspace, 'design-pipeline', 'manifests'), { recursive: true })
return workspace
}
const writeManifest = async (workspace, name, value) => {
const filePath = path.join(workspace, 'design-pipeline', 'manifests', name)
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
return filePath
}
const directAsset = (id, output) => ({
id,
output,
width: 96,
height: 96,
alpha: true,
bytes: 100,
sha256: 'a'.repeat(64),
provenance: 'committed-binary',
rebuildable: false,
})
const runtimeManifest = (imports = [], assets = []) => ({
schemaVersion: 1,
kind: 'runtime-asset-inventory',
scope: 'auth',
imports,
assets,
})
test('展开直接资产与单一导入且不复制物理规格', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const imported = await writeManifest(
workspace,
'shared.json',
runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]),
)
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(['design-pipeline/manifests/shared.json'], [directAsset('auth', 'static/assets/auth.png')]),
)
const result = await expandRuntimeAssetInventory(root, workspace)
assert.deepEqual(result.assets.map(({ id }) => id).sort(), ['auth', 'shared'])
assert(result.manifests.includes(imported))
})
test('拒绝缺失的导入文件', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const root = await writeManifest(workspace, 'auth.json', runtimeManifest(['design-pipeline/manifests/missing.json']))
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /missing manifest/i)
})
test('拒绝循环导入', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const first = await writeManifest(workspace, 'first.json', runtimeManifest(['design-pipeline/manifests/second.json']))
await writeManifest(workspace, 'second.json', runtimeManifest(['design-pipeline/manifests/first.json']))
await assert.rejects(() => expandRuntimeAssetInventory(first, workspace), /import cycle/i)
})
test('拒绝同一清单被重复导入', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest([
'design-pipeline/manifests/shared.json',
'design-pipeline/manifests/shared.json',
]),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate import/i)
})
test('拒绝不同清单拥有同一正式输出', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/same.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(
['design-pipeline/manifests/shared.json'],
[directAsset('auth', 'static/assets/same.png')],
),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate output/i)
})
test('拒绝展开图中的重复资产 id', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('same-id', 'static/assets/shared.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(
['design-pipeline/manifests/shared.json'],
[directAsset('same-id', 'static/assets/auth.png')],
),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate asset id/i)
})
test('直接资产只接受 committed-binary 且拒绝未知字段', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const wrongProvenance = directAsset('wrong-provenance', 'static/assets/wrong.png')
wrongProvenance.provenance = 'manual-copy'
const wrongRoot = await writeManifest(workspace, 'wrong.json', runtimeManifest([], [wrongProvenance]))
await assert.rejects(() => expandRuntimeAssetInventory(wrongRoot, workspace), /committed-binary/i)
const unknownField = directAsset('unknown-field', 'static/assets/unknown.png')
unknownField.runtimeSelector = '.page'
const unknownRoot = await writeManifest(workspace, 'unknown.json', runtimeManifest([], [unknownField]))
await assert.rejects(() => expandRuntimeAssetInventory(unknownRoot, workspace), /unknown field/i)
})
test('运行时清单本身拒绝未知字段', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifest = runtimeManifest()
manifest.consumerList = []
const root = await writeManifest(workspace, 'auth.json', manifest)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /unknown field/i)
})
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { validateRuntimeAssetRegistry } from '../scripts/runtime-asset-inventory.mjs'
const runtimeManifest = (scope, imports = []) => ({
schemaVersion: 1,
kind: 'runtime-asset-inventory',
scope,
imports,
assets: [],
})
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspaceDirectory = path.resolve(pipelineDirectory, '..')
const realManifestsDirectory = path.join(pipelineDirectory, 'manifests')
test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const write = (name, value) => writeFile(path.join(manifestsDirectory, name), `${JSON.stringify(value)}\n`, 'utf8')
await write('runtime-assets.json', runtimeManifest('schema-v3'))
await write('orphan.json', runtimeManifest('orphan'))
await assert.rejects(
() => validateRuntimeAssetRegistry(
path.join(manifestsDirectory, 'runtime-assets.json'),
workspace,
manifestsDirectory,
),
/unregistered manifest/i,
)
})
test('真实 schema v3 注册表覆盖直接资产与三类正式生成 owner', async () => {
const registry = JSON.parse(await readFile(path.join(realManifestsDirectory, 'runtime-assets.json'), 'utf8'))
const auth = JSON.parse(await readFile(path.join(realManifestsDirectory, 'auth-runtime-assets.json'), 'utf8'))
assert.equal(registry.scope, 'schema-v3')
assert.deepEqual(registry.imports, [
'design-pipeline/manifests/auth-runtime-assets.json',
'design-pipeline/manifests/application-runtime-assets.json',
'design-pipeline/manifests/shared-scroll-skins-v3.json',
'design-pipeline/manifests/page-backgrounds-v3.json',
'design-pipeline/manifests/g01-state-frame-v3.json',
])
assert.deepEqual(auth.imports, [])
await validateRuntimeAssetRegistry(
path.join(realManifestsDirectory, 'runtime-assets.json'),
workspaceDirectory,
realManifestsDirectory,
)
})
test('注册表根 scope 不是 schema-v3 时拒绝验证', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-scope-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const root = path.join(manifestsDirectory, 'runtime-assets.json')
await writeFile(root, `${JSON.stringify(runtimeManifest('auth'))}\n`, 'utf8')
await assert.rejects(
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
/root scope must be schema-v3/i,
)
})
test('未声明的旧格式或未知 kind 清单不能被静默跳过', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-unknown-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const root = path.join(manifestsDirectory, 'runtime-assets.json')
await writeFile(root, `${JSON.stringify(runtimeManifest('schema-v3'))}\n`, 'utf8')
await writeFile(
path.join(manifestsDirectory, 'unknown.json'),
`${JSON.stringify({ schemaVersion: 1, kind: 'legacy-owner', outputs: [] })}\n`,
'utf8',
)
await assert.rejects(
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
/undeclared legacy manifest/i,
)
})
@@ -1,30 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateManifest } from '../scripts/manifest-v2.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
test('declares the approved A01 scroll-skin family', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
validateManifest(manifest, workspace)
assert.equal(manifest.assets.length, 4)
assert.deepEqual(
manifest.assets.map(({ id }) => id),
['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3']
)
assert.deepEqual(
manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]),
[[1866, 276], [1866, 300], [1770, 246], [1860, 1560]]
)
assert.equal(manifest.assets[2].assetClass, 'nine-slice')
assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice')
assert.equal(manifest.assets[3].render.uniMode, 'aspectFit')
assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue')))
})
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
test('共享卷轴清单只维护四张正式生成资产', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.kind, 'asset-build-manifest')
assert.equal(manifest.family, 'shared-scroll-skins-v3')
assert.deepEqual(
manifest.assets.map(({ id }) => id),
['shared-scroll-primary-v3', 'shared-scroll-secondary-v3', 'shared-scroll-toast-v3', 'shared-scroll-dialog-v3'],
)
for (const asset of manifest.assets) {
assert.deepEqual(
Object.keys(asset).sort(),
['alpha', 'edge', 'id', 'output', 'outputPixels', 'processing', 'quality', 'source', 'sourcePixels'],
)
assert.equal(asset.processing.mode, 'chroma-stretch')
}
})
+20 -11
View File
@@ -3,7 +3,7 @@ import tempfile
import unittest
from pathlib import Path
from PIL import Image
from PIL import Image, PngImagePlugin
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
@@ -37,19 +37,23 @@ class AssetQualityTests(unittest.TestCase):
image.putpixel((x, y), (150, 20, 12, 255))
return image
def save(self, image, name="asset.png"):
def save(self, image, name="asset.png", include_srgb=True):
path = self.root / name
image.save(path, format="PNG")
png_info = PngImagePlugin.PngInfo()
if include_srgb:
png_info.add(b"sRGB", b"\x00")
image.save(path, format="PNG", pnginfo=png_info)
return path
def error_text(self, report):
return " ".join(report["errors"])
def test_accepts_clean_rgba_asset(self):
report = analyze_asset(self.save(self.clean_image()), self.spec())
report = analyze_asset(self.save(self.clean_image()), self.spec(), self.root)
self.assertEqual([], report["errors"])
self.assertEqual(32, report["width"])
self.assertEqual(16, report["height"])
self.assertEqual("asset.png", report["path"])
def test_accepts_indexed_png_with_real_transparency(self):
indexed = self.clean_image().quantize(
@@ -57,44 +61,49 @@ class AssetQualityTests(unittest.TestCase):
method=Image.Quantize.FASTOCTREE,
dither=Image.Dither.NONE,
)
report = analyze_asset(self.save(indexed), self.spec())
report = analyze_asset(self.save(indexed), self.spec(), self.root)
self.assertEqual([], report["errors"])
self.assertEqual("P", report["mode"])
def test_rejects_wrong_dimensions(self):
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec())
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec(), self.root)
self.assertIn("dimensions", self.error_text(report))
def test_rejects_opaque_outer_padding(self):
image = self.clean_image()
image.putpixel((0, 0), (120, 30, 20, 255))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("outer padding", self.error_text(report))
def test_rejects_visible_green_residue(self):
image = self.clean_image()
image.putpixel((16, 8), (0, 255, 0, 255))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("chroma residue", self.error_text(report))
def test_rejects_light_partially_transparent_fringe(self):
image = self.clean_image()
image.putpixel((2, 8), (250, 250, 250, 128))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("light fringe", self.error_text(report))
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
image = self.clean_image()
image.putpixel((0, 0), (255, 255, 255, 0))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("transparent RGB", self.error_text(report))
def test_rejects_file_over_max_bytes(self):
spec = self.spec()
spec["quality"]["maxBytes"] = 8
report = analyze_asset(self.save(self.clean_image()), spec)
report = analyze_asset(self.save(self.clean_image()), spec, self.root)
self.assertIn("maxBytes", self.error_text(report))
def test_rejects_missing_declared_srgb_metadata(self):
path = self.save(self.clean_image(), include_srgb=False)
report = analyze_asset(path, self.spec(), self.root)
self.assertIn("sRGB", self.error_text(report))
if __name__ == "__main__":
unittest.main()
@@ -1,71 +0,0 @@
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png
class G01BackgroundBuilderTests(unittest.TestCase):
def config(self):
return {
"dominanceStart": 30,
"dominanceEnd": 220,
"despillAllowance": 10,
}
def test_pure_green_becomes_clean_transparency(self):
source = Image.new("RGB", (2, 1), (0, 255, 0))
output = process_chroma_image(source, self.config())
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
def test_warm_artwork_remains_opaque(self):
source = Image.new("RGB", (1, 1), (210, 190, 150))
output = process_chroma_image(source, self.config())
self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0)))
def test_antialiased_green_edge_is_translucent_and_despilled(self):
source = Image.new("RGB", (1, 1), (100, 200, 90))
output = process_chroma_image(source, self.config())
red, green, blue, alpha = output.getpixel((0, 0))
self.assertGreater(alpha, 0)
self.assertLess(alpha, 255)
self.assertLessEqual(green, max(red, blue) + 10)
def test_opaque_master_is_normalized_to_rgba_without_transparency(self):
source = Image.new("RGB", (1, 1), (240, 235, 220))
output = process_opaque_image(source)
self.assertEqual("RGBA", output.mode)
self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0)))
def test_opaque_master_can_be_resized_to_locked_runtime_dimensions(self):
source = Image.new("RGB", (2, 3), (240, 235, 220))
output = process_opaque_image(source, (4, 6))
self.assertEqual((4, 6), output.size)
self.assertEqual("RGBA", output.mode)
self.assertEqual((240, 235, 220, 255), output.getpixel((3, 5)))
def test_saved_png_declares_srgb_color_profile(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "asset.png"
save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path)
with Image.open(path) as opened:
self.assertIn("srgb", opened.info)
def test_saved_png_bytes_are_deterministic(self):
with tempfile.TemporaryDirectory() as directory:
first = Path(directory) / "first.png"
second = Path(directory) / "second.png"
image = Image.new("RGBA", (2, 2), (240, 235, 220, 255))
save_png(image, first)
save_png(image, second)
self.assertEqual(first.read_bytes(), second.read_bytes())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
import hashlib
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_raster_assets import (
build_opaque_cover_crop,
build_opaque_resize,
extract_warm_gold_frame,
save_png,
)
class RasterAssetBuilderTests(unittest.TestCase):
"""锁定迁移后的两类背景处理与 G01 暖金边框提取语义。"""
def test_opaque_resize_uses_locked_output_size_and_rgb(self):
source = Image.new("RGBA", (2, 3), (120, 80, 40, 128))
output = build_opaque_resize(source, (4, 6))
self.assertEqual((4, 6), output.size)
self.assertEqual("RGB", output.mode)
def test_cover_crop_centers_the_scaled_source(self):
source = Image.new("RGB", (2, 1), (255, 0, 0))
source.putpixel((1, 0), (0, 0, 255))
output = build_opaque_cover_crop(source, (2, 2))
self.assertEqual((2, 2), output.size)
self.assertEqual("RGB", output.mode)
self.assertNotEqual(output.getpixel((0, 0)), output.getpixel((1, 0)))
def test_warm_gold_frame_keeps_only_the_edge_band(self):
source = Image.new("RGBA", (5, 5), (180, 140, 80, 255))
config = {
"borderBand": 1,
"redGreenMin": 15,
"greenBlueMin": 12,
"redBlueMin": 35,
"redMaxExclusive": 245,
"blueMaxExclusive": 180,
"alphaOffset": 25,
"alphaScale": 6,
}
output = extract_warm_gold_frame(source, config)
self.assertGreater(output.getpixel((0, 2))[3], 0)
self.assertEqual((0, 0, 0, 0), output.getpixel((2, 2)))
def test_warm_gold_frame_rejects_non_gold_and_clears_hidden_rgb(self):
source = Image.new("RGBA", (1, 1), (100, 150, 100, 255))
config = {
"borderBand": 1,
"redGreenMin": 15,
"greenBlueMin": 12,
"redBlueMin": 35,
"redMaxExclusive": 245,
"blueMaxExclusive": 180,
"alphaOffset": 25,
"alphaScale": 6,
}
output = extract_warm_gold_frame(source, config)
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
def test_png_save_is_srgb_and_byte_deterministic(self):
with tempfile.TemporaryDirectory() as directory:
first = Path(directory) / "first.png"
second = Path(directory) / "second.png"
image = Image.new("RGB", (4, 4), (230, 220, 200))
save_png(image, first)
save_png(image, second)
self.assertEqual(first.read_bytes(), second.read_bytes())
self.assertEqual(
hashlib.sha256(first.read_bytes()).hexdigest(),
hashlib.sha256(second.read_bytes()).hexdigest(),
)
with Image.open(first) as opened:
self.assertIn("srgb", opened.info)
if __name__ == "__main__":
unittest.main()
@@ -1,3 +1,5 @@
import hashlib
import json
import sys
import tempfile
import unittest
@@ -8,7 +10,13 @@ from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center
from asset_quality import analyze_asset
from build_scroll_skins import (
build_asset,
remove_chroma_background,
sanitize_output_edges,
stretch_safe_center,
)
class BuildScrollSkinsTests(unittest.TestCase):
@@ -20,9 +28,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
def test_chroma_background_becomes_transparent_black(self):
image = Image.new("RGB", (4, 2), (0, 255, 0))
image.putpixel((1, 0), (180, 30, 20))
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
@@ -33,13 +39,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
image.putpixel((x, y), (220, 170, 40, 255))
image.putpixel((29 - x, y), (220, 170, 40, 255))
output = stretch_safe_center(
image,
output_size=(60, 20),
cap_width=5,
padding=2,
)
output = stretch_safe_center(image, output_size=(60, 20), cap_width=5, padding=2)
self.assertEqual((60, 20), output.size)
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
@@ -50,28 +50,28 @@ class BuildScrollSkinsTests(unittest.TestCase):
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
image.putpixel((0, 0), (0, 255, 0, 128))
image.putpixel((1, 0), (250, 250, 250, 96))
cleaned = sanitize_output_edges(image)
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
def test_build_asset_honors_declared_palette_size(self):
source = Image.new("RGB", (32, 16), (0, 255, 0))
for y in range(1, 15):
for x in range(1, 31):
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
source_path = self.root / "source.png"
output_path = self.root / "output.png"
source.save(source_path)
asset = {
"id": "palette-test",
def asset(self, asset_id):
"""返回与现行判别式 schema 一致的最小透明卷轴测试资产。"""
return {
"id": asset_id,
"source": "source.png",
"output": "output.png",
"sourcePixels": {"width": 32, "height": 16},
"outputPixels": {"width": 60, "height": 30},
"alpha": {"transparentOuterPadding": 1},
"alpha": {"required": True, "transparentOuterPadding": 1, "cornerMaxAlpha": 0},
"edge": {
"forbidChromaResidue": True,
"forbidLightFringe": True,
"premultipliedAlphaCheck": True,
},
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
"processing": {
"mode": "chroma-stretch",
"capWidth": 4,
"keyColor": "#00FF00",
"keyTolerance": 80,
@@ -79,14 +79,37 @@ class BuildScrollSkinsTests(unittest.TestCase):
},
}
build_asset(asset, self.root)
def save_test_source(self):
source = Image.new("RGB", (32, 16), (0, 255, 0))
for y in range(1, 15):
for x in range(1, 31):
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
source.save(self.root / "source.png")
with Image.open(output_path) as output:
def test_build_asset_honors_declared_palette_size(self):
self.save_test_source()
build_asset(self.asset("palette-test"), self.root)
with Image.open(self.root / "output.png") as output:
visible_colors = {
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
}
self.assertLessEqual(len(visible_colors), 8)
def test_same_input_produces_identical_bytes_and_report_twice(self):
self.save_test_source()
asset = self.asset("deterministic-test")
snapshots = []
for _ in range(2):
build_asset(asset, self.root)
output = self.root / "output.png"
snapshots.append(
(
hashlib.sha256(output.read_bytes()).hexdigest(),
json.dumps(analyze_asset(output, asset, self.root), ensure_ascii=False, sort_keys=True),
)
)
self.assertEqual(snapshots[0], snapshots[1])
if __name__ == "__main__":
unittest.main()
@@ -1,49 +0,0 @@
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image, ImageDraw
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from rebuild_a01_buttons import rebuild_button
class RebuildA01ButtonTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
def make_source(self):
image = Image.new("RGBA", (60, 30), (0, 255, 0, 255))
draw = ImageDraw.Draw(image)
draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255))
draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255))
draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255))
return image
def test_rebuilds_three_slice_into_clean_transparent_canvas(self):
source = self.root / "source.png"
output = self.root / "output.png"
self.make_source().save(source)
rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10)
with Image.open(output) as image:
self.assertEqual("RGBA", image.mode)
self.assertEqual((180, 60), image.size)
self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0)))
self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59)))
visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8]
self.assertFalse(
any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels)
)
self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0])
self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0])
if __name__ == "__main__":
unittest.main()