初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
SECTOR_BYTES = 512
|
||||
PARTITION_LBA = 1
|
||||
PARTITION_SECTORS = 8192
|
||||
RESERVED_SECTORS = 1
|
||||
FAT_COUNT = 2
|
||||
FAT_SECTORS = 32
|
||||
ROOT_ENTRIES = 512
|
||||
ROOT_SECTORS = ROOT_ENTRIES * 32 // SECTOR_BYTES
|
||||
DATA_RELATIVE_SECTOR = RESERVED_SECTORS + FAT_COUNT * FAT_SECTORS + ROOT_SECTORS
|
||||
|
||||
|
||||
def build_fixture(path: Path, config_file: bytes) -> Path:
|
||||
if len(config_file) != 64 * 1024:
|
||||
raise ValueError("配置文件必须为 64 KiB")
|
||||
image = bytearray((PARTITION_LBA + PARTITION_SECTORS) * SECTOR_BYTES)
|
||||
image[510:512] = b"\x55\xaa"
|
||||
partition_entry = 446
|
||||
image[partition_entry + 4] = 0x06
|
||||
struct.pack_into("<II", image, partition_entry + 8, PARTITION_LBA, PARTITION_SECTORS)
|
||||
|
||||
boot = PARTITION_LBA * SECTOR_BYTES
|
||||
image[boot : boot + 3] = b"\xeb\x3c\x90"
|
||||
image[boot + 3 : boot + 11] = b"MSDOS5.0"
|
||||
struct.pack_into("<H", image, boot + 11, SECTOR_BYTES)
|
||||
image[boot + 13] = 1
|
||||
struct.pack_into("<H", image, boot + 14, RESERVED_SECTORS)
|
||||
image[boot + 16] = FAT_COUNT
|
||||
struct.pack_into("<H", image, boot + 17, ROOT_ENTRIES)
|
||||
struct.pack_into("<H", image, boot + 19, PARTITION_SECTORS)
|
||||
image[boot + 21] = 0xF8
|
||||
struct.pack_into("<H", image, boot + 22, FAT_SECTORS)
|
||||
image[boot + 510 : boot + 512] = b"\x55\xaa"
|
||||
|
||||
fat = bytearray(FAT_SECTORS * SECTOR_BYTES)
|
||||
struct.pack_into("<HH", fat, 0, 0xFFF8, 0xFFFF)
|
||||
clusters = len(config_file) // SECTOR_BYTES
|
||||
for index in range(clusters):
|
||||
cluster = 2 + index
|
||||
struct.pack_into("<H", fat, cluster * 2, 0xFFFF if index == clusters - 1 else cluster + 1)
|
||||
first_fat = boot + RESERVED_SECTORS * SECTOR_BYTES
|
||||
image[first_fat : first_fat + len(fat)] = fat
|
||||
image[first_fat + len(fat) : first_fat + 2 * len(fat)] = fat
|
||||
|
||||
root = boot + (RESERVED_SECTORS + FAT_COUNT * FAT_SECTORS) * SECTOR_BYTES
|
||||
image[root : root + 11] = b"MSCCFG BIN"
|
||||
image[root + 11] = 0x20
|
||||
struct.pack_into("<H", image, root + 26, 2)
|
||||
struct.pack_into("<I", image, root + 28, len(config_file))
|
||||
data = boot + DATA_RELATIVE_SECTOR * SECTOR_BYTES
|
||||
image[data : data + len(config_file)] = config_file
|
||||
path.write_bytes(image)
|
||||
return path
|
||||
|
||||
|
||||
def first_fat_offset() -> int:
|
||||
return (PARTITION_LBA + RESERVED_SECTORS) * SECTOR_BYTES
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
|
||||
from matrix_image_editor.config import ( # noqa: E402
|
||||
HEADER_BYTES,
|
||||
SLOT_BYTES,
|
||||
ImageConfigError,
|
||||
create_config_file,
|
||||
read_config_file,
|
||||
update_config_file,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def sample_config() -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"product": "matrix-screen-controller-walnutpi",
|
||||
"software_version": "1.0.3",
|
||||
"account": {"username": "matrix", "password": "password123"},
|
||||
"wifi": {"ssid": "测试 WiFi", "password": "wifi-pass-123"},
|
||||
"ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []},
|
||||
}
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_round_trip_and_update_inactive_slot(self) -> None:
|
||||
original = create_config_file(sample_config())
|
||||
config, generation, slot = read_config_file(original)
|
||||
self.assertEqual((generation, slot), (1, 0))
|
||||
config["wifi"]["ssid"] = "新网络"
|
||||
updated = update_config_file(original, config)
|
||||
verified, generation, slot = read_config_file(updated)
|
||||
self.assertEqual((generation, slot, verified["wifi"]["ssid"]), (2, 1, "新网络"))
|
||||
self.assertEqual(updated[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES], original[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES])
|
||||
|
||||
def test_corrupt_active_slot_falls_back_and_both_corrupt_fail(self) -> None:
|
||||
original = create_config_file(sample_config())
|
||||
updated = bytearray(update_config_file(original, sample_config()))
|
||||
updated[HEADER_BYTES + SLOT_BYTES + 60] ^= 0x01
|
||||
_config, generation, slot = read_config_file(bytes(updated))
|
||||
self.assertEqual((generation, slot), (1, 0))
|
||||
updated[HEADER_BYTES + 60] ^= 0x01
|
||||
with self.assertRaises(ImageConfigError):
|
||||
read_config_file(bytes(updated))
|
||||
|
||||
def test_rejects_unknown_product_schema_and_fields(self) -> None:
|
||||
for field, value in (("product", "wrong"), ("schema_version", 2)):
|
||||
config = sample_config()
|
||||
config[field] = value
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
config = sample_config()
|
||||
config["unexpected"] = True
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
|
||||
def test_utf8_ssid_and_utf16_secret_lengths_match_contract(self) -> None:
|
||||
config = sample_config()
|
||||
config["wifi"]["ssid"] = "中" * 10
|
||||
validate_config(config)
|
||||
config["wifi"]["ssid"] = "中" * 11
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
config = sample_config()
|
||||
config["account"]["password"] = "😀" * 4
|
||||
validate_config(config)
|
||||
|
||||
def test_static_ipv4_requires_same_subnet_and_valid_dns(self) -> None:
|
||||
config = sample_config()
|
||||
config["ipv4"] = {
|
||||
"mode": "static",
|
||||
"address": "192.168.10.20",
|
||||
"prefix": 24,
|
||||
"gateway": "192.168.10.1",
|
||||
"dns": ["1.1.1.1", "8.8.8.8"],
|
||||
}
|
||||
validate_config(config)
|
||||
config["ipv4"]["gateway"] = "192.168.11.1"
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
|
||||
def test_project_codec_cross_compatibility_when_repository_is_present(self) -> None:
|
||||
project_codec = SOURCE_ROOT.parents[2] / "核桃派软件源代码" / "scripts" / "image_config.py"
|
||||
if not project_codec.is_file():
|
||||
self.skipTest("独立源码副本中没有主工程交叉校验器")
|
||||
spec = importlib.util.spec_from_file_location("project_image_config", project_codec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
ours = create_config_file(copy.deepcopy(sample_config()))
|
||||
theirs = module.create_config_file(copy.deepcopy(sample_config()))
|
||||
self.assertEqual(module.read_config_file(ours)[0], read_config_file(theirs)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit # noqa: E402
|
||||
from matrix_image_editor.gui import MainWindow # noqa: E402
|
||||
from test_config import sample_config # noqa: E402
|
||||
|
||||
|
||||
class GuiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.window = MainWindow()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.window.close()
|
||||
|
||||
def test_passwords_are_hidden_and_dhcp_disables_static_fields(self) -> None:
|
||||
self.assertEqual(self.window.account_password.echoMode(), QLineEdit.EchoMode.Password)
|
||||
self.assertEqual(self.window.wifi_password.echoMode(), QLineEdit.EchoMode.Password)
|
||||
for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns):
|
||||
self.assertFalse(widget.isEnabled())
|
||||
self.window.mode.setCurrentIndex(1)
|
||||
for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns):
|
||||
self.assertTrue(widget.isEnabled())
|
||||
|
||||
def test_loaded_values_collect_without_changing_image_version(self) -> None:
|
||||
config = sample_config()
|
||||
self.window._apply_loaded(Path("sample.img"), config)
|
||||
self.window.username.setText("new_user")
|
||||
collected = self.window._collect()
|
||||
self.assertEqual(collected["software_version"], config["software_version"])
|
||||
self.assertEqual(collected["account"]["username"], "new_user")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from fixture_image import build_fixture, first_fat_offset # noqa: E402
|
||||
from test_config import sample_config # noqa: E402
|
||||
from matrix_image_editor.config import create_config_file # noqa: E402
|
||||
from matrix_image_editor.cli import run_command # noqa: E402
|
||||
from matrix_image_editor.fat16 import Fat16Disk, Fat16Error # noqa: E402
|
||||
from matrix_image_editor.operations import ( # noqa: E402
|
||||
ImageOperationError,
|
||||
copy_write_and_verify,
|
||||
read_config,
|
||||
write_and_verify,
|
||||
)
|
||||
from matrix_image_editor.paths import ImagePathError, cleanup_new_destination, validate_new_destination # noqa: E402
|
||||
|
||||
|
||||
class ImageOperationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="镜像编辑器-")
|
||||
self.root = Path(self.temporary.name)
|
||||
self.image = build_fixture(self.root / "原 镜像.img", create_config_file(sample_config()))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_read_write_sidecar_and_reopen(self) -> None:
|
||||
config = read_config(self.image)
|
||||
config["wifi"]["ssid"] = "中文 新 WiFi"
|
||||
progress: list[int] = []
|
||||
verified = write_and_verify(self.image, config, progress.append)
|
||||
self.assertEqual(verified["wifi"]["ssid"], "中文 新 WiFi")
|
||||
sidecar = Path(str(self.image) + ".sha256")
|
||||
raw = sidecar.read_bytes()
|
||||
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
|
||||
self.assertEqual(raw, f"{hashlib.sha256(self.image.read_bytes()).hexdigest()} {self.image.name}\n".encode("utf-8"))
|
||||
self.assertEqual(progress[-1], 100)
|
||||
|
||||
def test_rejects_bad_sidecar_forms_and_content(self) -> None:
|
||||
sidecar = Path(str(self.image) + ".sha256")
|
||||
digest = hashlib.sha256(self.image.read_bytes()).hexdigest()
|
||||
invalid = [
|
||||
b"\xef\xbb\xbf" + f"{digest} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest.upper()} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} wrong.img\n".encode("utf-8"),
|
||||
f"{'0' * 64} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} {self.image.name}\r\n".encode("utf-8"),
|
||||
]
|
||||
for payload in invalid:
|
||||
with self.subTest(payload=payload[:12]):
|
||||
sidecar.write_bytes(payload)
|
||||
with self.assertRaises(ImageOperationError):
|
||||
read_config(self.image)
|
||||
|
||||
def test_copy_apply_preserves_source_and_cleans_failure(self) -> None:
|
||||
original = self.image.read_bytes()
|
||||
config = sample_config()
|
||||
config["account"]["username"] = "new_user"
|
||||
target, verified = copy_write_and_verify(self.image, self.root / "新 镜像.img", config)
|
||||
self.assertEqual(verified["account"]["username"], "new_user")
|
||||
self.assertEqual(self.image.read_bytes(), original)
|
||||
self.assertTrue(Path(str(target) + ".sha256").is_file())
|
||||
|
||||
failed = self.root / "失败.img"
|
||||
wrong_version = sample_config()
|
||||
wrong_version["software_version"] = "9.9.9"
|
||||
with self.assertRaises(ImageOperationError):
|
||||
copy_write_and_verify(self.image, failed, wrong_version)
|
||||
self.assertFalse(failed.exists())
|
||||
self.assertFalse(Path(str(failed) + ".sha256").exists())
|
||||
|
||||
def test_path_rejections(self) -> None:
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.image)
|
||||
existing = self.root / "existing.img"
|
||||
existing.write_bytes(b"")
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, existing)
|
||||
orphan = self.root / "orphan.img"
|
||||
Path(str(orphan) + ".sha256").write_text("orphan", encoding="utf-8")
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, orphan)
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.root / "wrong.txt")
|
||||
if os.name == "nt":
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.root / "CON.img")
|
||||
|
||||
def test_fat_cycle_and_partition_bounds_are_rejected(self) -> None:
|
||||
cyclic = self.root / "cycle.img"
|
||||
cyclic.write_bytes(self.image.read_bytes())
|
||||
with cyclic.open("r+b") as stream:
|
||||
stream.seek(first_fat_offset() + 2 * 2)
|
||||
stream.write(struct.pack("<H", 2))
|
||||
with Fat16Disk(cyclic, writable=False) as disk:
|
||||
with self.assertRaises(Fat16Error):
|
||||
disk.read_file("MSCCFG.BIN")
|
||||
|
||||
truncated = self.root / "truncated.img"
|
||||
truncated.write_bytes(self.image.read_bytes()[:-512])
|
||||
with self.assertRaises(Fat16Error):
|
||||
Fat16Disk(truncated, writable=False)
|
||||
|
||||
def test_cli_contract_and_apply(self) -> None:
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(run_command(["--validate", str(self.image)]), 0)
|
||||
document = json.loads(output.getvalue())
|
||||
self.assertEqual(
|
||||
set(document),
|
||||
{"valid", "SoftwareVersion", "Username", "Ssid", "Mode", "Address"},
|
||||
)
|
||||
config_path = self.root / "config.json"
|
||||
config_path.write_text(json.dumps(sample_config(), ensure_ascii=False), encoding="utf-8-sig")
|
||||
target = self.root / "cli-new.img"
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(run_command(["--apply", str(self.image), str(config_path), str(target)]), 0)
|
||||
self.assertTrue(target.is_file())
|
||||
self.assertTrue(Path(str(target) + ".sha256").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user