视觉审核完成30%

This commit is contained in:
rain
2026-07-16 18:12:59 +08:00
parent cb25317412
commit 23cfd365a1
89 changed files with 3740 additions and 403 deletions
@@ -0,0 +1,39 @@
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 three portable masters', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
assert.equal(manifest.schemaVersion, 1)
assert.equal(manifest.page, 'G01')
assert.equal(manifest.status, 'candidates-pending-user-selection')
assert.equal(manifest.candidates.length, 3)
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, /1024x1536/)
assert.deepEqual(candidate.sourcePixels, { width: 1024, height: 1536 })
assert.ok(['chroma-key', 'opaque-paper'].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`)
}
}
})
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/)
})
@@ -0,0 +1,72 @@
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,30 @@
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')))
})
+100
View File
@@ -0,0 +1,100 @@
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
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"):
path = self.root / name
image.save(path, format="PNG")
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.assertEqual([], report["errors"])
self.assertEqual(32, report["width"])
self.assertEqual(16, report["height"])
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.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.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.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.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.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.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.assertIn("maxBytes", self.error_text(report))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,64 @@
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_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,92 @@
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_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 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",
"source": "source.png",
"output": "output.png",
"outputPixels": {"width": 60, "height": 30},
"alpha": {"transparentOuterPadding": 1},
"processing": {
"capWidth": 4,
"keyColor": "#00FF00",
"keyTolerance": 80,
"paletteColors": 8,
},
}
build_asset(asset, self.root)
with Image.open(output_path) 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)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,49 @@
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()