chore: reduce design pipeline to runtime asset checks
This commit is contained in:
@@ -1,131 +0,0 @@
|
||||
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,68 +0,0 @@
|
||||
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')
|
||||
})
|
||||
@@ -1,65 +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 { 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)
|
||||
})
|
||||
@@ -40,19 +40,21 @@ test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
|
||||
)
|
||||
})
|
||||
|
||||
test('真实 schema v3 注册表覆盖直接资产与三类正式生成 owner', async () => {
|
||||
test('真实 schema v3 注册表覆盖应用资产与保留生成资产', 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'))
|
||||
const retained = JSON.parse(await readFile(path.join(realManifestsDirectory, 'retained-generated-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',
|
||||
'design-pipeline/manifests/retained-generated-runtime-assets.json',
|
||||
])
|
||||
assert.deepEqual(auth.imports, [])
|
||||
assert.equal(retained.assets.length, 11)
|
||||
assert(retained.assets.every((asset) => asset.provenance === 'committed-binary'))
|
||||
assert(retained.assets.every((asset) => asset.rebuildable === false))
|
||||
await validateRuntimeAssetRegistry(
|
||||
path.join(realManifestsDirectory, 'runtime-assets.json'),
|
||||
workspaceDirectory,
|
||||
|
||||
@@ -1,31 +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 { 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')
|
||||
}
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
class AssetQualityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def spec(self):
|
||||
return {
|
||||
"id": "test-button",
|
||||
"outputPixels": {"width": 32, "height": 16},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 2, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
}
|
||||
|
||||
def clean_image(self):
|
||||
image = Image.new("RGBA", (32, 16), (0, 0, 0, 0))
|
||||
for y in range(2, 14):
|
||||
for x in range(2, 30):
|
||||
image.putpixel((x, y), (150, 20, 12, 255))
|
||||
return image
|
||||
|
||||
def save(self, image, name="asset.png", include_srgb=True):
|
||||
path = self.root / name
|
||||
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(), 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(
|
||||
colors=16,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
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(), 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(), 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(), 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(), 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(), 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, 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,85 +0,0 @@
|
||||
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,115 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
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 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):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
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)))
|
||||
|
||||
def test_safe_center_stretch_preserves_caps_and_exact_size(self):
|
||||
image = Image.new("RGBA", (30, 10), (150, 20, 10, 255))
|
||||
for y in range(10):
|
||||
for x in range(5):
|
||||
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)
|
||||
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)))
|
||||
self.assertEqual((150, 20, 10, 255), output.getpixel((30, 10)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((56, 10)))
|
||||
|
||||
def test_sanitizes_only_forbidden_edge_artifacts(self):
|
||||
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 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": {"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,
|
||||
"paletteColors": 8,
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user