diff --git a/tests/component_tests/font/.gitattributes b/tests/component_tests/font/.gitattributes new file mode 100644 index 00000000000..4df6726184f --- /dev/null +++ b/tests/component_tests/font/.gitattributes @@ -0,0 +1,2 @@ +*.pcf -text +*.ttf -text diff --git a/tests/component_tests/font/NotoSans-Regular.ttf b/tests/component_tests/font/NotoSans-Regular.ttf new file mode 100644 index 00000000000..a1b8994edea Binary files /dev/null and b/tests/component_tests/font/NotoSans-Regular.ttf differ diff --git a/tests/component_tests/font/__init__.py b/tests/component_tests/font/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py new file mode 100644 index 00000000000..55e27ae84ce --- /dev/null +++ b/tests/component_tests/font/test_font.py @@ -0,0 +1,337 @@ +"""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) diff --git a/tests/components/font/.gitattributes b/tests/components/font/.gitattributes index 63ab00e9f28..4df6726184f 100644 --- a/tests/components/font/.gitattributes +++ b/tests/components/font/.gitattributes @@ -1 +1,2 @@ -*.pcf -text +*.pcf -text +*.ttf -text