feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿

This commit is contained in:
agent committed 2026-07-24 10:57:25 +00:00
1 parent 6f5b0c60be
commit 2b01e5f589
60 files changed
+16869

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
"""CLI 测试 — monkeypatch 传输工厂,验证 JSON 输出与退出码。"""
import json
import pytest
from PIL import Image
from ppclock import cli
class FakeTransport:
instances = []
def __init__(self, address=None, timeout=10.0):
self.epd_writes = []
self.rxtx_writes = []
self.address = address
FakeTransport.instances.append(self)
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def write_epd(self, data, response=True):
self.epd_writes.append(data)
async def write_rxtx(self, data, response=True):
self.rxtx_writes.append(data)
async def request_device_id(self, timeout=8.0):
return "DEVICE12345678"
async def delay(self, seconds):
pass
@pytest.fixture(autouse=True)
def fake_transport(monkeypatch):
FakeTransport.instances.clear()
monkeypatch.setattr(cli, "_make_transport", lambda args: FakeTransport(args.mac, args.timeout))
yield FakeTransport
def run_cli(capsys, argv):
code = cli.main(argv)
out = capsys.readouterr().out.strip()
return code, json.loads(out) if out.startswith("{") else out
class TestJsonContract:
def test_mode_json(self, capsys):
code, out = run_cli(capsys, ["--json", "mode", "clock1"])
assert code == 0
assert out["ok"] is True and out["cmd"] == "mode"
t = FakeTransport.instances[0]
assert t.rxtx_writes == [bytes.fromhex("e10201"), bytes([0xE2])]
def test_time_json(self, capsys):
code, out = run_cli(capsys, ["--json", "time", "2026-07-24T15:30:00"])
assert code == 0 and out["ok"]
t = FakeTransport.instances[0]
assert t.rxtx_writes[0][0] == 0xDD
assert t.rxtx_writes[-1] == bytes([0xE2])
def test_error_json_contract(self, capsys):
"""错误 schema:ok=false + error 字段,退出码 1"""
code, out = run_cli(capsys, ["--json", "countdown"])
assert code == 1
assert out["ok"] is False and "error" in out
def test_mac_passthrough(self, capsys):
run_cli(capsys, ["--json", "--mac", "AA:BB:CC:DD:EE:FF", "clear"])
assert FakeTransport.instances[0].address == "AA:BB:CC:DD:EE:FF"
class TestCommands:
def test_clear(self, capsys):
code, _ = run_cli(capsys, ["--json", "clear"])
assert code == 0
assert FakeTransport.instances[0].epd_writes[-1] == bytes([0x01])
def test_image_upload(self, capsys, tmp_path):
p = tmp_path / "white.png"
Image.new("RGB", (400, 300), (255, 255, 255)).save(p)
code, out = run_cli(capsys, ["--json", "image", str(p), "--mono"])
assert code == 0
t = FakeTransport.instances[0]
assert len(t.epd_writes) == 63 + 1
assert t.epd_writes[-1] == bytes([0x01])
assert out["data"]["bytes_bw"] == 15000
def test_image_slot_and_color(self, capsys, tmp_path):
p = tmp_path / "red.png"
Image.new("RGB", (400, 300), (255, 0, 0)).save(p)
code, out = run_cli(capsys, ["--json", "image", str(p), "--slot", "2"])
assert code == 0
t = FakeTransport.instances[0]
assert t.rxtx_writes[0] == bytes([0xEA, 0x02])
assert out["data"]["bytes_red"] == 15000
def test_countdown(self, capsys):
code, _ = run_cli(capsys, ["--json", "countdown", "2026-12-31"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes(
[0xFF, 0x00, 0x20, 0x26, 0x12, 0x31])
def test_countdown_off(self, capsys):
code, _ = run_cli(capsys, ["--json", "countdown", "--off"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes([0xFA])
def test_parking(self, capsys):
code, _ = run_cli(capsys, ["--json", "parking", "12"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes([0xEF, 0x11, 0x22])
def test_sleep(self, capsys):
code, _ = run_cli(capsys, ["--json", "sleep", "--on", "--start", "23", "--end", "7"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes([0xFB, 0x01, 0x23, 0x07])
def test_rotation(self, capsys):
code, _ = run_cli(capsys, ["--json", "rotation", "--count", "4"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes.fromhex("EA0003")
def test_lut(self, capsys):
code, _ = run_cli(capsys, ["--json", "lut", "0x05"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0] == bytes([0xE6, 0x05])
def test_activate_show_id(self, capsys):
code, out = run_cli(capsys, ["--json", "activate", "--show-id"])
assert code == 0
assert out["data"]["device_id"] == "DEVICE12345678"
def test_wifi(self, capsys):
code, _ = run_cli(capsys, ["--json", "wifi", "MyAP", "pw", "--city", "101010100"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes[0].startswith(b"WIFI_SSID:MyAP")
def test_raw(self, capsys):
code, _ = run_cli(capsys, ["--json", "raw", "e2"])
assert code == 0
assert FakeTransport.instances[0].rxtx_writes == [bytes([0xE2])]
def test_raw_epd_channel(self, capsys):
code, _ = run_cli(capsys, ["--json", "raw", "01", "--channel", "epd"])
assert code == 0
assert FakeTransport.instances[0].epd_writes == [bytes([0x01])]
def test_scan(self, capsys, monkeypatch):
import ppclock.transport as tr
async def fake_scan(timeout=5.0, name_prefix="NRF-"):
return [{"name": "NRF-TEST", "address": "AA:BB", "rssi": -50}]
monkeypatch.setattr(tr, "scan", fake_scan)
code, out = run_cli(capsys, ["--json", "scan"])
assert code == 0
assert out["data"]["devices"][0]["name"] == "NRF-TEST"
def test_template_custom(self, capsys):
code, out = run_cli(capsys, ["--json", "template", "custom",
"--data", '{"text": "你好"}'])
assert code == 0
t = FakeTransport.instances[0]
assert len(t.epd_writes) == 63 + 63 + 1 # 默认三色双平面
+138
View File
@@ -0,0 +1,138 @@
"""命令层测试 — 用 FakeTransport 验证高层操作的帧序列与顺序(证据 docs/protocol.md)。"""
import datetime
import pytest
from ppclock import commands as C
class FakeTransport:
def __init__(self, id_response="DEVICE12345678"):
self.epd_writes = []
self.rxtx_writes = []
self.id_response = id_response
self.delays = []
async def write_epd(self, data, response=True):
self.epd_writes.append(data)
async def write_rxtx(self, data, response=True):
self.rxtx_writes.append(data)
async def request_device_id(self, timeout=8.0):
self.rxtx_writes.append(bytes([0xEF, 0xEF]))
return self.id_response
async def delay(self, seconds):
self.delays.append(seconds)
@pytest.fixture
def t():
return FakeTransport()
class TestTimeAndMode:
@pytest.mark.asyncio
async def test_set_time_sends_dd_then_commit(self, t):
dt = datetime.datetime(2026, 7, 24, 15, 30, 0)
await C.set_time(t, dt)
assert t.rxtx_writes[0][0] == 0xDD
assert t.rxtx_writes[-1] == bytes([0xE2])
@pytest.mark.asyncio
async def test_set_mode_appends_commit(self, t):
await C.set_mode(t, "clock1")
assert t.rxtx_writes == [bytes.fromhex("e10201"), bytes([0xE2])]
@pytest.mark.asyncio
async def test_clear_uses_epd_channel(self, t):
await C.clear(t)
assert t.epd_writes == [bytes([0x00, 0x00]), bytes([0x01])]
assert t.rxtx_writes == []
class TestUploadImage:
@pytest.mark.asyncio
async def test_full_image_sequence(self, t):
"""整图:bw块×63 → 红块×63 → 延时 → 01"""
bw = bytes(15000)
red = bytes([0xFF]) * 15000
await C.upload_image(t, bw, red, small=False)
assert t.epd_writes[0][:4] == bytes([0x03, 0xFF, 0x00, 0x00])
assert t.epd_writes[63][:2] == bytes([0x03, 0x00]) # 红平面首块
assert t.epd_writes[-1] == bytes([0x01])
assert len(t.epd_writes) == 63 + 63 + 1
@pytest.mark.asyncio
async def test_mono_image_no_red_blocks(self, t):
await C.upload_image(t, bytes(15000), None, small=False)
assert len(t.epd_writes) == 63 + 1
@pytest.mark.asyncio
async def test_slot_select_before_upload(self, t):
await C.upload_image(t, bytes(15000), None, small=False, slot=2)
assert t.rxtx_writes[0] == bytes([0xEA, 0x02])
@pytest.mark.asyncio
async def test_small_image_ends_with_AA(self, t):
await C.upload_image(t, bytes(3500), None, small=True)
assert t.epd_writes[-1] == bytes([0xAA])
assert t.epd_writes[0][0] == 0x04
class TestOtherCommands:
@pytest.mark.asyncio
async def test_countdown_set(self, t):
d = datetime.date(2026, 12, 31)
await C.countdown_set(t, d, "clock")
assert t.rxtx_writes[0] == bytes([0xFF, 0x00, 0x20, 0x26, 0x12, 0x31])
assert t.rxtx_writes[-1] == bytes([0xE2])
@pytest.mark.asyncio
async def test_countdown_off(self, t):
await C.countdown_off(t)
assert t.rxtx_writes == [bytes([0xFA]), bytes([0xE2])]
@pytest.mark.asyncio
async def test_parking(self, t):
await C.parking(t, "13800138000")
assert t.rxtx_writes[0] == bytes([0xEF]) + bytes(
int(f"{d}{d}", 16) for d in "13800138000"
)
@pytest.mark.asyncio
async def test_sleep_on_off(self, t):
await C.sleep(t, True, 23, 7)
assert t.rxtx_writes[0] == bytes([0xFB, 0x01, 0x23, 0x07])
await C.sleep(t, False, 23, 7)
assert t.rxtx_writes[1] == bytes([0xFB, 0x00, 0x23, 0x07])
@pytest.mark.asyncio
async def test_rotation(self, t):
await C.rotation(t, count=4)
assert t.rxtx_writes[0] == bytes.fromhex("EA0003")
await C.rotation(t, interval=5, unit="min")
assert t.rxtx_writes[1] == bytes.fromhex("e9010501")
@pytest.mark.asyncio
async def test_activate(self, t):
await C.activate(t, "A1B2" * 8)
assert t.rxtx_writes[0] == bytes([0xEF]) + bytes.fromhex("A1B2" * 8)
assert t.rxtx_writes[-1] == bytes([0xE2])
@pytest.mark.asyncio
async def test_wifi(self, t):
await C.wifi(t, "MyAP", "pw", "101010100")
assert t.rxtx_writes[0] == b"WIFI_SSID:MyAP;WIFI_PASS:pw;CITY:101010100"
@pytest.mark.asyncio
async def test_lut(self, t):
await C.lut(t, 0x05)
assert t.rxtx_writes[0] == bytes([0xE6, 0x05])
@pytest.mark.asyncio
async def test_get_device_id(self, t):
dev_id = await C.get_device_id(t)
assert t.rxtx_writes[0] == bytes([0xEF, 0xEF])
assert dev_id == "DEVICE12345678"
+159
View File
@@ -0,0 +1,159 @@
"""图像管线测试 — 算法参数证据:analysis/web/timedjs.js(docs/protocol.md §5)。"""
import pytest
from PIL import Image
from ppclock import image_pipeline as IP
def make_img(w, h, pixels):
"""pixels: list[(r,g,b)] 长度 w*h,行主序。"""
img = Image.new("RGB", (w, h))
img.putdata(pixels)
return img
class TestPack:
def test_all_white_bw_plane(self):
px = [(255, 255, 255)] * 16
assert IP.pack_plane(px, "bw") == bytes([0xFF, 0xFF])
def test_all_black_bw_plane(self):
px = [(0, 0, 0)] * 16
assert IP.pack_plane(px, "bw") == bytes(2)
def test_msb_first_order(self):
"""首像素=bit7:仅(0,0)白→0x80;仅第8像素白→0x01;第9像素→第二字节0x80"""
px = [(255, 255, 255)] + [(0, 0, 0)] * 15
assert IP.pack_plane(px, "bw") == bytes([0x80, 0x00])
px = [(0, 0, 0)] * 7 + [(255, 255, 255)] + [(0, 0, 0)] * 8
assert IP.pack_plane(px, "bw") == bytes([0x01, 0x00])
px = [(0, 0, 0)] * 8 + [(255, 255, 255)] + [(0, 0, 0)] * 7
assert IP.pack_plane(px, "bw") == bytes([0x00, 0x80])
def test_red_pixel_planes(self):
"""红(255,0,0):bw 平面=0,红平面=1(timedjs.js:573-576,599-601)"""
px = [(255, 0, 0)] * 8
assert IP.pack_plane(px, "bw") == bytes([0x00])
assert IP.pack_plane(px, "red") == bytes([0xFF])
def test_partial_channels_count_as_white(self):
"""bw 规则:R,G,B 全>0 → 1;(1,1,1) 也是白"""
px = [(1, 1, 1)] * 8
assert IP.pack_plane(px, "bw") == bytes([0xFF])
def test_length_not_multiple_of_8_raises(self):
with pytest.raises(ValueError):
IP.pack_plane([(0, 0, 0)] * 7, "bw")
class TestDither:
def test_none_threshold(self):
"""none:gray<threshold→0 否则 255(timedjs.js:42-51)"""
px = [(100, 100, 100), (200, 200, 200)] * 4
out = IP.dither_pixels(px, 8, 1, "none", threshold=125)
assert out == [(0, 0, 0), (255, 255, 255)] * 4
def test_none_grayscale_coefficients(self):
"""灰度=0.299R+0.587G+0.114B:纯绿128 → gray=75.1 <125 → 黑"""
out = IP.dither_pixels([(0, 128, 0)] * 8, 8, 1, "none", threshold=125)
assert out == [(0, 0, 0)] * 8
def test_red_detected_before_quantize(self):
"""r>g*1.5 且 r>b*1.5 且 r>threshold → 纯红且不扩散误差"""
px = [(200, 50, 50)] * 8
out = IP.dither_pixels(px, 8, 1, "none", threshold=125, tricolor=True)
assert out == [(255, 0, 0)] * 8
def test_not_red_when_threshold_high(self):
px = [(200, 50, 50)] * 8
out = IP.dither_pixels(px, 8, 1, "none", threshold=250, tricolor=True)
# 200>250 假 → 走红判定失败 → 灰度 0.299*200+0.587*50+0.114*50=94.9 <250 → 黑
assert out == [(0, 0, 0)] * 8
def test_floyd_steinberg_error_propagation(self):
"""2x1 灰100:px0→黑 err=100*7/16=43.75 → px1=143.75→白(阈值125,扩散1.0)"""
px = [(100, 100, 100)] * 2
out = IP.dither_pixels(px, 2, 1, "floydsteinberg", threshold=125)
assert out[0] == (0, 0, 0)
assert out[1] == (255, 255, 255)
def test_diffusion_zero_behaves_like_none(self):
px = [(100, 100, 100)] * 2
out = IP.dither_pixels(px, 2, 1, "floydsteinberg", threshold=125, diffusion=0.0)
assert out == [(0, 0, 0)] * 2
def test_atkinson_kernel_smaller_error(self):
"""Atkinson 只扩散 6/8:px0 黑 err=100*1/8=12.5 → px1=112.5<125 仍黑"""
px = [(100, 100, 100)] * 2
out = IP.dither_pixels(px, 2, 1, "atkinson", threshold=125)
assert out == [(0, 0, 0), (0, 0, 0)]
def test_bayer_matrix_origin(self):
"""bayer (0,0) 阈值=15:灰10→黑,灰20→白(矩阵 timedjs.js:116-136)"""
out = IP.dither_pixels([(10, 10, 10)] * 8, 8, 1, "bayer", threshold=125)
assert out[0] == (0, 0, 0)
out = IP.dither_pixels([(20, 20, 20)] * 8, 8, 1, "bayer", threshold=125)
assert out[0] == (255, 255, 255)
def test_stucki_and_jarvis_run(self):
"""大核算法冒烟:全灰输入输出只有黑白"""
px = [(128, 128, 128)] * 64
for algo in ("stucki", "jarvis"):
out = IP.dither_pixels(px, 8, 8, algo, threshold=125)
assert set(out) <= {(0, 0, 0), (255, 255, 255)}
def test_unknown_algo_raises(self):
with pytest.raises(ValueError):
IP.dither_pixels([(0, 0, 0)] * 8, 8, 1, "magic")
class TestAdjust:
def test_brightness(self):
"""亮度+50 → v+=127.5(timedjs.js:9-17)"""
px = [(100, 100, 100)]
out = IP.adjust_pixels(px, brightness=50, contrast=0, saturation=100)
assert out == [(228, 228, 228)] # 100+127.5=227.5 → round=228
def test_contrast(self):
"""对比度+100 → factor=2, v=v*2-128(timedjs.js:18-27)"""
out = IP.adjust_pixels([(200, 50, 128)], brightness=0, contrast=100, saturation=100)
assert out == [(255, 0, 128)]
def test_saturation_desaturate(self):
"""饱和度35 → v=gray+(v-gray)*0.35(timedjs.js:28-38)"""
out = IP.adjust_pixels([(200, 50, 50)], brightness=0, contrast=0, saturation=35)
gray = 0.299 * 200 + 0.587 * 50 + 0.114 * 50
exp = round(gray + (200 - gray) * 0.35)
assert out[0][0] == exp
class TestProcess:
def test_full_size_output_lengths(self):
"""400x300 → bw 15000B;三色另有红平面 15000B"""
img = Image.new("RGB", (400, 300), (255, 255, 255))
bw, red = IP.process_image(img, size=(400, 300), algorithm="none", tricolor=True)
assert len(bw) == 15000
assert len(red) == 15000
assert bw == bytes([0xFF]) * 15000
assert red == bytes(15000)
def test_mono_has_no_red_plane(self):
img = Image.new("RGB", (400, 300), (0, 0, 0))
bw, red = IP.process_image(img, size=(400, 300), algorithm="none", tricolor=False)
assert len(bw) == 15000
assert red is None
def test_resize_to_fit(self):
img = Image.new("RGB", (800, 600), (255, 255, 255))
bw, _ = IP.process_image(img, size=(400, 300), algorithm="none", tricolor=False)
assert len(bw) == 15000
def test_rotate_180(self):
"""rotate=180:左上角白块移到右下"""
img = Image.new("RGB", (8, 8), (0, 0, 0))
for x in range(8):
img.putpixel((x, 0), (255, 255, 255))
bw, _ = IP.process_image(img, size=(8, 8), algorithm="none",
tricolor=False, rotate=180)
assert bw[0] == 0x00
assert bw[-1] == 0xFF
+34
View File
@@ -0,0 +1,34 @@
"""模板渲染测试。"""
import pytest
from ppclock import templates as T
@pytest.mark.parametrize("name", sorted(T.RENDERERS))
def test_all_templates_render_400x300(name):
payloads = {
"schedule": {"items": [["09:00", "晨会【重要】"]]},
"businesscard": {"name": "张三", "title": "工程师", "phone": "138"},
"memo": {"title": "购物", "lines": ["牛奶", "鸡蛋"]},
"course": {"grid": [["数学"]]},
"qrcode": {"content": "https://example.com", "caption": "扫码"},
"custom": {"text": "你好【世界】\n第二行"},
}
img = T.render_template(name, payloads[name])
assert img.size == (400, 300)
assert img.mode == "RGB"
def test_custom_wraps_long_text():
img = T.render_template("custom", {"text": "长" * 100})
assert img.size == (400, 300)
def test_red_segments_rendered_red():
img = T.render_template("custom", {"text": "【红】"})
assert (255, 0, 0) in set(img.getdata())
def test_unknown_template_raises():
with pytest.raises(ValueError):
T.render_template("nope", {})