"""测试活动打印板成员(Active Plate Member)的 3MF 输出行为。 验证内容: - write_output_3mf 正确保留配置中指定的打印板 G-code 成员 - ZIP 压缩级别参数生效并正确裁剪 - 预览图拼合时仅使用前 9 个唯一输入文件 """ from __future__ import annotations import hashlib import tempfile import unittest import zipfile from pathlib import Path from a1_swap_mod_packer.builder import ( normalized_zip_compress_level, preview_members_for_gcode_member, resolve_output_gcode_member, select_preview_sources, write_output_3mf, ) from a1_swap_mod_packer.metadata import read_model_settings_gcode_members from a1_swap_mod_packer.models import BuildOptions, PlateSource try: from PIL import Image except Exception: # pragma: no cover Image = None class ActivePlateMemberTest(unittest.TestCase): """测试写入输出 3MF 时活动打印板成员的正确保留与压缩行为。""" def test_write_output_preserves_configured_plate_member(self) -> None: """验证 write_output_3mf 保留配置中指定打印板对应的 G-code 成员。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "plate2.3mf" output_3mf = root / "out.3mf" self.write_plate2_archive(source_3mf) # 创建含 plate_2 的测试 3MF # 打开源归档,校验 plate_2 被正确识别 with zipfile.ZipFile(source_3mf, "r") as archive: self.assertEqual(read_model_settings_gcode_members(archive), ["Metadata/plate_2.gcode"]) self.assertEqual(resolve_output_gcode_member(archive, "Metadata/plate_2.gcode"), "Metadata/plate_2.gcode") self.assertEqual( preview_members_for_gcode_member("Metadata/plate_2.gcode"), {"Metadata/plate_2.png", "Metadata/plate_2_small.png"}, ) gcode_bytes = b"G1 X1\n" # 替换用的 G-code 内容 sources = [ PlateSource( source_3mf=source_3mf, member_name="Metadata/plate_2.gcode", gcode_text="G1 X0\n", ) ] options = BuildOptions( swap_gcode="", output_3mf=output_3mf, add_preview_label=False, apply_gcode_patches=False, ) md5 = write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) # 执行打包输出 # 校验输出归档中仅包含 plate_2 的 G-code,且 MD5 正确 with zipfile.ZipFile(output_3mf, "r") as archive: names = set(archive.namelist()) self.assertIn("Metadata/plate_2.gcode", names) self.assertIn("Metadata/plate_2.gcode.md5", names) self.assertNotIn("Metadata/plate_1.gcode", names) self.assertEqual(archive.read("Metadata/plate_2.gcode"), gcode_bytes) self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5) self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest()) def test_write_output_honors_zip_compression_level(self) -> None: """验证 ZIP 压缩级别参数生效:压缩级别越高,压缩后文件越小。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "plate2.3mf" level_1_output = root / "level1.3mf" level_7_output = root / "level7.3mf" self.write_plate2_archive(source_3mf) sources = [ PlateSource( source_3mf=source_3mf, member_name="Metadata/plate_2.gcode", gcode_text="G1 X0\n", ) ] # 大量重复 G-code 以便观察压缩效果 gcode_bytes = (b"G1 X123.456 Y789.012 E0.03456 ; repeated movement\n" * 20000) # 分别以压缩级别 1 和 7 输出 for output_3mf, zip_level in ((level_1_output, 1), (level_7_output, 7)): options = BuildOptions( swap_gcode="", output_3mf=output_3mf, add_preview_label=False, apply_gcode_patches=False, zip_compress_level=zip_level, ) write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) # 比较两个输出文件的压缩后大小:级别 7 应更小 with zipfile.ZipFile(level_1_output, "r") as level_1_archive, zipfile.ZipFile(level_7_output, "r") as level_7_archive: level_1_info = level_1_archive.getinfo("Metadata/plate_2.gcode") level_7_info = level_7_archive.getinfo("Metadata/plate_2.gcode") self.assertEqual(level_1_info.compress_type, zipfile.ZIP_DEFLATED) self.assertEqual(level_7_info.compress_type, zipfile.ZIP_DEFLATED) self.assertLess(level_7_info.compress_size, level_1_info.compress_size) def test_zip_compression_level_is_clamped(self) -> None: """验证压缩级别参数被正确裁剪到合法范围 [1, 9]。""" self.assertEqual(normalized_zip_compress_level(None), 7) # 默认值 → 7 self.assertEqual(normalized_zip_compress_level(0), 1) # 低于下限 → 1 self.assertEqual(normalized_zip_compress_level(10), 9) # 超出上限 → 9 def test_preview_composite_uses_first_nine_unique_input_files(self) -> None: """验证预览图拼合仅使用前 9 个不重复的输入文件,第 10 个被排除。""" if Image is None: self.skipTest("Pillow is not installed") # 10 种不同颜色,用于验证拼合结果 colors = [ (255, 0, 0, 255), (0, 0, 255, 255), (255, 255, 0, 255), (255, 0, 255, 255), (0, 255, 255, 255), (128, 0, 0, 255), (0, 0, 128, 255), (128, 128, 0, 255), (128, 0, 128, 255), (255, 128, 0, 255), ] with tempfile.TemporaryDirectory() as directory: root = Path(directory) sources: list[PlateSource] = [] # 为每种颜色创建一个独立的 3MF 源文件 for index, color in enumerate(colors): source_3mf = root / f"source_{index}.3mf" self.write_plate2_archive(source_3mf, image_color=color, image_size=300) sources.append( PlateSource( source_3mf=source_3mf, member_name="Metadata/plate_2.gcode", gcode_text="G1 X0\n", ) ) # 验证 select_preview_sources 返回恰好 9 个源 selected = select_preview_sources(sources) self.assertEqual(len(selected), 9) self.assertEqual(selected[-1].source_3mf.name, "source_8.3mf") output_3mf = root / "out.3mf" options = BuildOptions( swap_gcode="", output_3mf=output_3mf, add_preview_label=True, # 启用预览标签生成 apply_gcode_patches=False, ) write_output_3mf(sources[0].source_3mf, output_3mf, b"G1 X1\n", sources, options) # 读取拼合后的预览图 with zipfile.ZipFile(output_3mf, "r") as archive: image_bytes = archive.read("Metadata/plate_2.png") import io # 在拼合图像的 9 个采样点处检测颜色 image = Image.open(io.BytesIO(image_bytes)).convert("RGBA") sampled_colors = { image.getpixel((50, 50)), image.getpixel((150, 50)), image.getpixel((250, 50)), image.getpixel((50, 150)), image.getpixel((150, 150)), image.getpixel((250, 150)), image.getpixel((50, 250)), image.getpixel((150, 250)), image.getpixel((250, 250)), } # 前 9 种颜色全部出现,第 10 种不出现 self.assertTrue(set(colors[:9]).issubset(sampled_colors)) self.assertNotIn(colors[9], sampled_colors) @staticmethod def write_plate2_archive(path: Path, image_color: tuple[int, int, int, int] | None = None, image_size: int = 128) -> None: """创建仅含 plate_2 有效 G-code 成员及其预览图的 3MF 测试归档。 Args: path: 输出 3MF 文件路径 image_color: 预览图填充颜色(RGBA),None 则写入占位字节 image_size: 预览图尺寸(正方形) """ model_settings = """ """ slice_info = """ """ with zipfile.ZipFile(path, "w") as archive: archive.writestr("Metadata/model_settings.config", model_settings) archive.writestr("Metadata/slice_info.config", slice_info) archive.writestr("Metadata/plate_2.gcode", "G1 X0\n") archive.writestr("Metadata/plate_2.gcode.md5", "old") if image_color is None or Image is None: # 无 Pillow 或不指定颜色 → 写入占位字节 archive.writestr("Metadata/plate_2.png", b"png") archive.writestr("Metadata/plate_2_small.png", b"png") else: import io # 生成纯色 PNG 预览图及其缩略图 image = Image.new("RGBA", (image_size, image_size), image_color) output = io.BytesIO() image.save(output, format="PNG") archive.writestr("Metadata/plate_2.png", output.getvalue()) small = image.resize((max(1, image_size // 2), max(1, image_size // 2))) output = io.BytesIO() small.save(output, format="PNG") archive.writestr("Metadata/plate_2_small.png", output.getvalue()) if __name__ == "__main__": unittest.main()