Files
HaloKeymind/test/test_indicator_display_profile.py
mikecarper 01cc7b8ae4 Separate Companion message history from pending inbox
Default to retained history with a separate pending count, and add persistent display.inbox CLI modes for history, pending, and unread. Preserve browsing while clients are connected and show the active USB or Bluetooth connection.

Fixes #5
2026-09-10 21:42:57 -07:00

228 lines
9.1 KiB
Python

#!/usr/bin/env python3
import re
import struct
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini"
DISPLAY = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.cpp"
INDICATOR_DISPLAY = (
ROOT / "variants" / "sensecap_indicator-espnow" / "SCIndicatorDisplay.h"
)
FONT = ROOT / "variants" / "sensecap_indicator-espnow" / "sd" / "ui-font.vlw"
def base_profile() -> str:
text = PROFILE.read_text()
match = re.search(
r"^\[SenseCapIndicator-ESPNow\]\n(?P<body>.*?)(?=^\[)",
text,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError("SenseCap Indicator base profile is missing")
return match.group("body")
def numeric_flag(name: str) -> float:
match = re.search(
rf"^\s*-D\s+{re.escape(name)}=(\S+)\s*$",
base_profile(),
re.MULTILINE,
)
if match is None:
raise AssertionError(f"{name} is missing from the Indicator profile")
return float(match.group(1).removesuffix("f"))
class IndicatorDisplayProfileTest(unittest.TestCase):
def test_panel_cs_release_cannot_discard_initialized_display(self):
display = INDICATOR_DISPLAY.read_text()
self.assertIn("releasePanelChipSelectWithRetry()", display)
self.assertIn("if (!initialized) {", display)
self.assertIn("begin_status = 0x82;", display)
self.assertNotIn("if (!initialized || !released) return false;", display)
self.assertIn("panel_chip_select_released =", display)
self.assertIn("void startFrame(ColorVal bkg", display)
self.assertIn("const bool controllers_prepared = prepareControllers()", display)
self.assertIn("begin_status = controllers_prepared ? 4 : 0x84", display)
self.assertIn("if (expander_address == 0 && prepareControllers())", display)
self.assertIn("uint8_t beginStatus() const", display)
def test_preserves_original_indicator_memory_profile(self):
profile = base_profile()
self.assertIn("board_build.arduino.memory_type = qio_opi", profile)
self.assertIn("board_build.psram_type = opi", profile)
self.assertIn("board_upload.flash_size = 8MB", profile)
def test_n16r2_profile_uses_quad_psram_and_full_flash(self):
profile = PROFILE.read_text()
start = profile.index(
"[env:SenseCapIndicator-LoRa-N16R2_comp_radio_usb_wifi]"
)
n16r2 = profile[start:]
self.assertIn("board_build.arduino.memory_type = qio_qspi", n16r2)
self.assertIn("board_build.psram_type = qspi", n16r2)
self.assertIn("board_upload.flash_size = 16MB", n16r2)
self.assertIn("board_upload.maximum_size = 16777216", n16r2)
self.assertIn("dual_ota_6400k_preserve_spiffs.csv", n16r2)
def test_preserves_logical_ui_and_fills_panel(self):
panel_size = 480
zoom = numeric_flag("UI_ZOOM")
coordinate_scale = numeric_flag("UI_COORD_SCALE")
logical_size = panel_size / (zoom * coordinate_scale)
sprite_size = logical_size * coordinate_scale
self.assertEqual(logical_size, 160)
self.assertEqual(sprite_size, 320)
self.assertEqual(sprite_size * zoom, panel_size)
def test_internal_canvas_leaves_interface_headroom(self):
panel_size = 480
color_depth = numeric_flag("UI_BUFFER_COLOR_DEPTH")
zoom = numeric_flag("UI_ZOOM")
coordinate_scale = numeric_flag("UI_COORD_SCALE")
logical_size = panel_size / (zoom * coordinate_scale)
sprite_size = int(logical_size * coordinate_scale)
old_canvas_bytes = panel_size * panel_size * int(color_depth) // 8
canvas_bytes = sprite_size * sprite_size * int(color_depth) // 8
self.assertEqual(canvas_bytes, 51_200)
self.assertGreaterEqual(old_canvas_bytes - canvas_bytes, 64_000)
self.assertIn("buffer.setPsram(false);", DISPLAY.read_text())
def test_uses_dedicated_pairing_block(self):
self.assertIn("-D UI_DEDICATED_PAIRING_BLOCK=1", base_profile())
logical_height = 160
block_height = 48
block_center = logical_height * 4 // 5
block_top = block_center - block_height // 2
info_top = 40
info_bottom = block_top - 12
self.assertEqual(block_center, 128)
self.assertEqual(block_top, 104)
self.assertLess(info_top, info_bottom)
self.assertLessEqual(info_bottom + 12, block_top)
def test_recovered_font_pairing_values_fit_reserved_block(self):
data = FONT.read_bytes()
glyph_count = struct.unpack_from(">I", data, 0)[0]
advances = {}
heights = {}
for index in range(glyph_count):
offset = 24 + index * 28
codepoint, height, _width, advance = struct.unpack_from(
">IIII", data, offset
)
advances[codepoint] = advance
heights[codepoint] = height
footer = data[-40:]
self.assertEqual(b"MCEMAP2\0", footer[:8])
atlas_offset = struct.unpack_from("<I", footer, 20)[0]
native_scale = data[atlas_offset + 9]
coordinate_scale = 2
def dimensions(text, requested_size, coordinate_scale=2,
profile_scale=1.0):
render_scale = (
requested_size * coordinate_scale / native_scale
* profile_scale
)
# Blank glyphs carry no bitmap record in the packed VLW. The
# checked-in font is monospaced with the documented 18px cell, so
# spaces still advance by one complete cell.
width = sum(
18 if char == " " else advances[ord(char)] for char in text
)
height = max(heights[ord(char)] for char in text if char != " ")
return (
int((width * render_scale + coordinate_scale - 1)
// coordinate_scale),
int((height * render_scale + coordinate_scale - 1)
// coordinate_scale),
)
self.assertEqual((108, 24), dimensions("123456", 3))
self.assertEqual((108, 16), dimensions("CONNECTED", 2))
for width, height in (
dimensions("123456", 3), dimensions("CONNECTED", 2)
):
self.assertLessEqual(width, 144)
self.assertLessEqual(height, 24)
# This continuous-scale model rounds outward and is deliberately a
# conservative fit bound; LovyanGFX applies fixed-point rounding while
# laying out individual glyphs.
native_pin = dimensions("123456", 3, 3, 1.2)
native_connected = dimensions("CONNECTED", 2, 3, 1.2)
for width, height in (native_pin, native_connected):
self.assertLessEqual(width, 144)
self.assertLessEqual(height, 30)
# Native 480 reflows the spacious home page instead of globally
# enlarging every dense UI row. The title, count, action, and pairing
# state each occupy their own large row.
native_title = dimensions("HISTORY", 3, 3, 1.2)
native_count = dimensions("9999", 3, 3, 1.2)
native_count_fallback = dimensions("99999", 2, 3, 1.2)
self.assertLessEqual(native_title[0], 156)
self.assertLessEqual(native_title[1], 39)
self.assertLessEqual(native_count[0], 156)
self.assertLessEqual(native_count_fallback[0], 156)
for text, requested_size, region_width in (
("TAP", 3, 144),
("OFF", 3, 144),
("PIN", 3, 144),
("LINKED", 3, 144),
("123456", 3, 144),
("IP: 192.168.100.200", 1, 144),
):
width, height = dimensions(text, requested_size, 3, 1.2)
self.assertLessEqual(width, region_width)
self.assertLessEqual(height, 29 if requested_size == 3 else 10)
# The two lower size-3 rows must not depend on blank pixels inside the
# checked-in VLW: the built-in fallback font can fill its whole cell.
pairing_label_y = 102
pairing_value_y = 131
self.assertLessEqual(pairing_label_y + 29, pairing_value_y)
self.assertLessEqual(pairing_value_y + 29, 160)
# Page 8 retains the shared status bar and dots, then uses two equal
# text rows and a version-2 WiFi QR. Compact mode deliberately bypasses
# the native 1.2x chrome boost in both render profiles.
for text in ("WIFI SETUP", "MC-Set-90DF"):
width, _height = dimensions(text, 2, 3, 1.0)
self.assertLessEqual(width, 160)
label_height = dimensions("WIFI SETUP", 2, 3, 1.0)[1]
self.assertLessEqual(20 + label_height, 37)
self.assertLessEqual(37 + label_height, 55)
qr_size = 105
qr_version_2_modules = 25
quiet_zone_modules = 4
native_module_size = qr_size * 3 // (
qr_version_2_modules + quiet_zone_modules * 2
)
fallback_module_size = qr_size * 2 // (
qr_version_2_modules + quiet_zone_modules * 2
)
self.assertEqual(native_module_size, 9)
self.assertEqual(fallback_module_size * 1.5, 9)
self.assertEqual(55 + qr_size, 160)
if __name__ == "__main__":
unittest.main()