Files

338 lines
11 KiB
Python

"""Tests for the font component.
Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings
are correctly processed through the font configuration pipeline.
"""
import functools
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.font import (
CONF_BPP,
CONF_EXTRAS,
CONF_GLYPHSETS,
CONF_IGNORE_MISSING_GLYPHS,
CONF_RAW_GLYPH_ID,
FONT_CACHE,
flatten,
glyph_comparator,
to_code,
validate_font_config,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_GLYPHS,
CONF_ID,
CONF_PATH,
CONF_RAW_DATA_ID,
CONF_SIZE,
CONF_TYPE,
)
FONT_DIR = Path(__file__).parent
FONT_PATH = FONT_DIR / "NotoSans-Regular.ttf"
# 200 unique CJK Unified Ideograph characters (U+4E00..U+4EC7)
CHINESE_200 = "".join(chr(cp) for cp in range(0x4E00, 0x4EC8))
def _file_conf() -> dict:
return {CONF_PATH: str(FONT_PATH), CONF_TYPE: "local"}
def _make_config(
glyphs: list[str],
*,
ignore_missing: bool = False,
size: int = 20,
bpp: int = 1,
extras: list | None = None,
glyphsets: list | None = None,
) -> dict:
"""Build a config dict matching what FONT_SCHEMA produces."""
return {
CONF_FILE: _file_conf(),
CONF_GLYPHS: glyphs,
CONF_GLYPHSETS: glyphsets or [],
CONF_IGNORE_MISSING_GLYPHS: ignore_missing,
CONF_SIZE: size,
CONF_BPP: bpp,
CONF_EXTRAS: extras or [],
}
@pytest.fixture(autouse=True)
def _load_font():
"""Load the test font into FONT_CACHE and clean up afterwards."""
fc = _file_conf()
FONT_CACHE[fc] = FONT_PATH
yield
FONT_CACHE.store.clear()
# ---------- flatten / glyph_comparator helpers ----------
def test_flatten_splits_chinese_string_into_chars():
"""A single string of 200 Chinese characters must become 200 individual chars."""
result = flatten([CHINESE_200])
assert len(result) == 200
assert all(len(c) == 1 for c in result)
assert result[0] == "\u4e00"
assert result[-1] == "\u4ec7"
def test_flatten_multiple_chinese_strings():
"""Multiple glyph strings are concatenated then split correctly."""
s1 = CHINESE_200[:100]
s2 = CHINESE_200[100:]
result = flatten([list(s1), list(s2)])
assert len(result) == 200
def test_glyph_comparator_orders_chinese_by_utf8():
"""glyph_comparator must order CJK characters by their UTF-8 byte sequence."""
chars = list(CHINESE_200[:10])
sorted_chars = sorted(chars, key=functools.cmp_to_key(glyph_comparator))
# CJK block is contiguous and UTF-8 order matches codepoint order here
assert sorted_chars == chars
def test_glyph_comparator_mixed_ascii_and_chinese():
"""ASCII characters sort before CJK characters (lower UTF-8 bytes)."""
assert glyph_comparator("A", "\u4e00") == -1
assert glyph_comparator("\u4e00", "A") == 1
assert glyph_comparator("\u4e00", "\u4e00") == 0
# ---------- validate_font_config ----------
def test_long_chinese_glyphs_raises_missing_error():
"""200 Chinese chars not present in NotoSans must raise Invalid with the correct count."""
config = _make_config([CHINESE_200])
with pytest.raises(cv.Invalid, match=r"missing 200 glyphs"):
validate_font_config(config)
def test_long_chinese_glyphs_error_mentions_overflow():
"""When more than 10 glyphs are missing the error should mention the remainder."""
config = _make_config([CHINESE_200])
with pytest.raises(cv.Invalid, match=r"and 190 more"):
validate_font_config(config)
def test_duplicate_chinese_glyphs_detected():
"""Duplicate CJK characters within a single glyph string must be caught."""
duped = "\u4e00\u4e01\u4e00" # first char repeated
config = _make_config([duped])
with pytest.raises(cv.Invalid, match="duplicate"):
validate_font_config(config)
def test_duplicate_chinese_across_strings():
"""Duplicates across separate glyph strings are also caught."""
config = _make_config(["\u4e00\u4e01", "\u4e01\u4e02"])
with pytest.raises(cv.Invalid, match="duplicate"):
validate_font_config(config)
def test_no_false_duplicates_in_200_unique_chinese():
"""200 unique CJK characters must not trigger the duplicate check."""
config = _make_config([CHINESE_200])
# Should not raise duplicate error — it should reach the missing-glyph check instead
with pytest.raises(cv.Invalid, match="missing"):
validate_font_config(config)
def test_valid_latin_glyphs_pass_validation():
"""Latin characters present in NotoSans-Regular pass validation without error."""
config = _make_config(["ABCabc123"])
result = validate_font_config(config)
assert result is not None
assert result[CONF_SIZE] == 20
def test_long_latin_glyphs_pass_validation():
"""A long string of supported Latin glyphs passes validation."""
# 95 printable ASCII characters that NotoSans supports
latin = "".join(chr(cp) for cp in range(0x21, 0x7F))
config = _make_config([latin])
result = validate_font_config(config)
assert result is not None
def test_mixed_latin_and_chinese_glyphs_error():
"""Mixing valid Latin and invalid Chinese chars reports missing Chinese glyphs."""
chinese_10 = CHINESE_200[:10]
config = _make_config(["ABC", chinese_10])
with pytest.raises(cv.Invalid, match=r"missing 10 glyphs"):
validate_font_config(config)
def test_single_chinese_char_glyph():
"""A single Chinese character is correctly handled as one glyph."""
config = _make_config(["\u4e00"])
with pytest.raises(cv.Invalid, match=r"missing 1 glyph[^s]"):
validate_font_config(config)
def test_chinese_glyphs_as_individual_list_items():
"""Chinese chars provided as separate list items are handled the same as a single string."""
chars_as_list = list(CHINESE_200[:50])
config = _make_config(chars_as_list)
with pytest.raises(cv.Invalid, match=r"missing 50 glyphs"):
validate_font_config(config)
# ---------- YAML parsing ----------
def test_yaml_long_latin_glyphs_parsed_and_validated(tmp_path):
"""200 Latin Extended chars on a single YAML line are parsed intact and pass validation."""
from esphome.yaml_util import load_yaml
latin_long = "".join(chr(cp) for cp in range(0x100, 0x1C8))
yaml_file = tmp_path / "font_test.yaml"
yaml_file.write_text(
f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{latin_long}"\n',
encoding="utf-8",
)
parsed = load_yaml(yaml_file)
raw_glyphs = parsed["font"][0]["glyphs"]
# YAML must preserve every Unicode character on the single line
assert raw_glyphs == latin_long
assert len(raw_glyphs) == 200
# Feed through validate_font_config to confirm all glyphs are accepted
config = _make_config([raw_glyphs])
result = validate_font_config(config)
assert result is not None
@pytest.mark.parametrize(
"glyphs_str",
[
" ABC", # space at start
"AB CD", # space in middle
"ABC ", # space at end
],
ids=["start", "middle", "end"],
)
def test_yaml_space_in_glyphs_preserved(tmp_path, glyphs_str):
"""A space character in a glyphs string must survive YAML round-trip and validation."""
from esphome.yaml_util import load_yaml
yaml_file = tmp_path / "font_test.yaml"
yaml_file.write_text(
f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{glyphs_str}"\n',
encoding="utf-8",
)
parsed = load_yaml(yaml_file)
raw_glyphs = parsed["font"][0]["glyphs"]
assert raw_glyphs == glyphs_str
assert " " in raw_glyphs
# Space and ASCII letters are all in NotoSans — validation must pass
config = _make_config([raw_glyphs])
result = validate_font_config(config)
assert result is not None
# ---------- to_code generation ----------
# 200 unique Latin Extended characters (U+0100..U+01C7), all present in NotoSans
LATIN_LONG = "".join(chr(cp) for cp in range(0x100, 0x1C8))
@pytest.fixture
def mock_cg():
"""Mock all cg codegen functions used by to_code."""
with (
patch("esphome.components.font.cg.add_define") as mock_define,
patch("esphome.components.font.cg.progmem_array") as mock_progmem,
patch("esphome.components.font.cg.static_const_array") as mock_static,
patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar,
):
mock_progmem.return_value = MagicMock()
mock_static.return_value = MagicMock()
yield {
"add_define": mock_define,
"progmem_array": mock_progmem,
"static_const_array": mock_static,
"new_Pvariable": mock_new_pvar,
}
@pytest.mark.asyncio
async def test_to_code_long_latin_generates_all_glyphs(mock_cg):
"""to_code must generate glyph data for every character in a long Latin string."""
glyph_count = len(LATIN_LONG) # 200
config = _make_config([LATIN_LONG])
config[CONF_ID] = MagicMock()
config[CONF_RAW_DATA_ID] = MagicMock()
config[CONF_RAW_GLYPH_ID] = MagicMock()
await to_code(config)
# USE_FONT define must be emitted
mock_cg["add_define"].assert_any_call("USE_FONT")
# progmem_array receives the combined bitmap data (non-empty)
mock_cg["progmem_array"].assert_called_once()
bitmap_data = mock_cg["progmem_array"].call_args.args[1]
assert len(bitmap_data) > 0
# static_const_array receives one entry per unique glyph
mock_cg["static_const_array"].assert_called_once()
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
assert len(glyph_initializer) == glyph_count
# new_Pvariable is called with the correct glyph count
mock_cg["new_Pvariable"].assert_called_once()
pvar_args = mock_cg["new_Pvariable"].call_args.args
assert pvar_args[2] == glyph_count # len(glyph_initializer)
assert pvar_args[8] == 1 # bpp
@pytest.mark.asyncio
async def test_to_code_glyph_entries_contain_expected_fields(mock_cg):
"""Each glyph initializer entry must have 7 fields: codepoint, data ptr, advance, offset_x, offset_y, w, h."""
config = _make_config([LATIN_LONG])
config[CONF_ID] = MagicMock()
config[CONF_RAW_DATA_ID] = MagicMock()
config[CONF_RAW_GLYPH_ID] = MagicMock()
await to_code(config)
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
for entry in glyph_initializer:
assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}"
codepoint = entry[0]
assert isinstance(codepoint, int)
assert 0x100 <= codepoint <= 0x1C7
@pytest.mark.asyncio
async def test_to_code_glyphs_sorted_by_utf8(mock_cg):
"""Glyphs in the initializer must be sorted by UTF-8 byte order."""
config = _make_config([LATIN_LONG])
config[CONF_ID] = MagicMock()
config[CONF_RAW_DATA_ID] = MagicMock()
config[CONF_RAW_GLYPH_ID] = MagicMock()
await to_code(config)
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
codepoints = [entry[0] for entry in glyph_initializer]
assert codepoints == sorted(codepoints)