[esp8266] Add the native ninja emission (#18560)

This commit is contained in:
J. Nick Koston
2026-09-24 10:45:54 +12:00
committed by GitHub
parent e3951f2ab4
commit f4300d9cee
4 changed files with 1032 additions and 33 deletions
+529 -24
View File
@@ -1,38 +1,34 @@
"""Drift tests for the native ESP8266 Arduino build spec.
"""Drift tests for the native ESP8266 Arduino build generator.
These pin the ESPHome side of the transliteration (the knob-define
precedence, the define/flag sets, and the linker-script generation) against
literals audited from the PlatformIO builder
(framework-arduinoespressif8266/tools/platformio-build.py and
platform-espressif8266/builder/main.py). They catch an accidental edit on
this side; an upstream change in a new framework release is caught by the
A/B byte-identical build check on a version bump, not by these tests.
Pin the transliterated flag/define/link sets against literals audited from
the PlatformIO builder. Upstream drift is caught by the A/B build check on
version bumps, not here.
"""
from __future__ import annotations
from collections.abc import Generator
import logging
import os
from pathlib import Path
import shutil
from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino.library import ArduinoLibrary
from esphome.arduino8266.framework import InstalledPaths, toolchain_tool
from esphome.build_gen import arduino8266
from esphome.build_gen.arduino8266 import (
_defines_flags,
_flag_defines,
_flash_size_str,
_resolve_build_config,
get_flash_ld_path,
)
from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
from esphome.components.esp8266.const import (
KEY_BOARD,
KEY_ESP8266,
KEY_FLASH_MODE,
KEY_SCANF_FLOAT,
)
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@@ -47,9 +43,13 @@ def _setup_core(tmp_path: Path) -> Generator[None]:
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
CORE.data[KEY_ESP8266] = {
KEY_BOARD: "nodemcuv2",
KEY_FLASH_MODE: "dout",
KEY_SCANF_FLOAT: False,
}
# The producers esp8266/__init__ pins unconditionally
CORE.platformio_options = {
"board_build.flash_mode": "dout",
"build_src_flags": "-include esphome/components/esp8266/throw_stubs.h",
}
yield
# CORE.reset() (the suite-wide autouse fixture) does not clear this flag
CORE.testing_mode = False
@@ -59,6 +59,11 @@ def _set_flags(*flags: str) -> None:
CORE.build_flags = set(flags)
def _shq(tok: str) -> str:
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
def _resolve(*flags: str) -> arduino8266._BuildConfig:
"""Set the build flags and resolve the knob config in one step."""
_set_flags(*flags)
@@ -206,12 +211,173 @@ def _make_framework(tmp_path: Path) -> InstalledPaths:
for sub in ("include", "ld", "lwip2/include", "lib"):
(framework / "tools" / "sdk" / sub).mkdir(parents=True)
(framework / "libraries").mkdir()
(framework / "tools" / "elf2bin.py").write_text("")
eboot = framework / "bootloaders" / "eboot"
eboot.mkdir(parents=True)
(eboot / "eboot.elf").write_text("")
toolchain = tmp_path / "toolchain"
(toolchain / "bin").mkdir(parents=True)
(toolchain / "include").mkdir()
return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja"))
def _write_ninja(
paths: InstalledPaths,
libraries: list | None = None,
ccache: str | None = None,
) -> str:
src = CORE.relative_src_path()
(src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
(src / "esphome" / "vendor.c").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch(
"esphome.arduino.library.resolve_libraries",
return_value=libraries or [],
),
):
arduino8266.write_project(paths, ccache)
return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text()
def test_write_project_rejects_bad_flash_mode(tmp_path: Path) -> None:
"""A flash mode outside the closed set fails by name before landing
unquoted in the elf2bin command."""
paths = _make_framework(tmp_path)
CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /"
with pytest.raises(EsphomeError, match="Invalid flash mode"):
_write_ninja(paths)
def test_write_project_flash_mode_reaches_define_and_elf2bin(
tmp_path: Path,
) -> None:
"""A non-default board_flash_mode lands in both the compile define and
the elf2bin image header, never silently falling back to dout."""
paths = _make_framework(tmp_path)
CORE.platformio_options["board_build.flash_mode"] = "dio"
content = _write_ninja(paths)
assert "-DFLASHMODE_DIO" in content
assert "--flash_mode dio" in content
assert "FLASHMODE_DOUT" not in content
def test_write_project_trailing_include_raises(tmp_path: Path) -> None:
"""A dangling -include must fail by name, not become -include <src_dir>."""
paths = _make_framework(tmp_path)
CORE.platformio_options["build_src_flags"] = "-include"
with pytest.raises(EsphomeError, match="trailing '-include'"):
_write_ninja(paths)
def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None:
"""Non-include build_src_flags tokens are requoted onto the src edges."""
paths = _make_framework(tmp_path)
CORE.platformio_options["build_src_flags"] = (
"-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1"
)
content = _write_ninja(paths)
assert "throw_stubs.h" in content
assert "-DSRC_ONLY=1" in content
def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH",
"-DUSE_ESP8266_WAVEFORM_STUBS",
"-Wl,--wrap=millis",
"-Wl,--wrap=printf",
"-Wno-nonnull-compare",
"-L/opt/blobs",
"-luser_blob",
"-L /spc/blobs -l spaced_blob",
)
content = _write_ninja(paths)
# Base link flags from the PlatformIO builder
for flag in (
"-Wl,--no-check-sections",
"-Wl,-static",
"-Wl,--gc-sections",
"-Wl,-wrap,system_restart_local",
"-Wl,-wrap,spi_flash_read",
"-u app_entry",
"-u _printf_float",
"-u _DebugExceptionVector",
"-u _DoubleExceptionVector",
"-u _KernelExceptionVector",
"-u _NMIExceptionVector",
"-u _UserExceptionVector",
):
assert flag in content
# ESPHome's link flags and the board linker script
assert "-Wl,--wrap=millis" in content
assert "-Wl,--wrap=printf" in content
assert "-T eagle.flash.4m.ld" in content
# scanf float disabled: the forced-link flag must not appear
assert "_scanf_float" not in content
# $in/$out must stay unquoted; ninja escapes its own path variables
assert "-c $in -o $out" in content
assert "--app $in --flash_mode" in content
assert '"$in"' not in content
assert '"$out"' not in content
# -L/-l from esphome build_flags reach the link line, not the compiles;
# spaced forms ("-L /path") are shell-lexed the way PlatformIO does.
# str(Path(...)) so the separator matches the host platform.
opt_blobs = str(Path("/opt/blobs"))
spc_blobs = str(Path("/spc/blobs"))
assert f"-L{_shq(opt_blobs)}" in content
assert "-luser_blob" in content
assert f"-L{_shq(spc_blobs)}" in content
assert "-lspaced_blob" in content
for line in content.splitlines():
if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"):
assert "user_blob" not in line
assert opt_blobs not in line
assert "spaced_blob" not in line
assert spc_blobs not in line
# System libraries with the selected lwIP variant, in the builder's order
assert (
"-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps "
"-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob "
"-luser_blob "
"-lstdc++ -lm -lc -lgcc" in content
)
# Core exclusions: native OTA backend and waveform stubs
assert "Updater.cpp" not in content
assert "core_esp8266_waveform_pwm.cpp" not in content
assert "core_esp8266_waveform_phase.cpp" not in content
assert "core_esp8266_main.cpp.o" in content
# Assembly and C sources compile through their own rules
assert "cont.S.o: aspp" in content
assert "abi.c.o: c" in content
# throw_stubs is force-included for ESPHome sources only, via one shared
# srcflags variable rather than a copy of the flags line per edge
src_lines = [line for line in content.splitlines() if "obj/src/" in line]
assert any("main.cpp.o: cxx" in line for line in src_lines)
assert content.count("throw_stubs.h") == 1
assert "srcflags = -include" in content
flags_lines = [
line for line in content.splitlines() if line.startswith(" flags = ")
]
assert flags_lines
assert all(line == " flags = $srcflags" for line in flags_lines)
def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
content = _write_ninja(paths)
assert "-u _scanf_float" in content
# Waveform not stubbed out: both implementations stay in the archive
assert "core_esp8266_waveform_pwm.cpp.o" in content
assert "core_esp8266_waveform_phase.cpp.o" in content
@pytest.mark.parametrize(
("knob", "lib", "mss", "features", "ipv6"),
[
@@ -360,8 +526,8 @@ def test_generate_ld_scripts(tmp_path: Path) -> None:
_run_generate_ld_scripts(paths)
mock_run.assert_not_called()
# A surgery edit invalidates the stamp via the fingerprint (a stale
# linker script would otherwise persist until an esphome clean)
# An edit to the surgery constants invalidates the stamp (a stale linker
# script would otherwise persist until an esphome clean)
with (
patch.object(
arduino8266.build_surgery, "surgery_fingerprint", return_value="changed"
@@ -401,8 +567,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
(paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
# Real flash ld scripts carry no iram1_0_seg (that lives in the
# generated common ld); the surgery rejects one it was not asked about
"MEMORY\n{\n"
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
" irom0_0_seg : org = 0x40201010, len = 0xfeff0\n"
@@ -416,6 +580,96 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
assert "len = 0x2000000" in patched
def test_write_project_libraries_and_variant(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
paths = _make_framework(tmp_path)
variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp"
variant_src.write_text("")
lib_dir = tmp_path / "libsrc"
lib_dir.mkdir()
(lib_dir / "lib.cpp").write_text("")
(lib_dir / "impl.cc").write_text("")
headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir])
library = ArduinoLibrary(
name="MyLib",
sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"],
include_dirs=[lib_dir],
flags=["-DMYLIB=1"],
link_dirs=[lib_dir / "blobs"],
link_libs=["algobsec"],
link_flags=["-Wl,--wrap=malloc"],
)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"):
content = _write_ninja(
paths, libraries=[library, headers_only], ccache="/cc/ccache"
)
assert "build libFrameworkArduinoVariant.a: ar" in content
assert "build libMyLib.a: ar" in content
# A headers-only library contributes includes but no archive, with a
# debug log distinguishing it from a resolution failure
assert "libHeadersOnly.a" not in content
assert "Library HeadersOnly has no source files" in caplog.text
assert " flags = -DMYLIB=1" in content
assert "-lalgobsec" in content
# Library link flags reach the firmware link line; .cc compiles as C++
assert "-Wl,--wrap=malloc" in content
assert "impl.cc.o: cxx" in content
assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content
# Exceptions knob: -fexceptions and the exception-enabled stdc++
assert "-fexceptions" in content
assert "-lstdc++-exc" in content
assert f"ccache = {_shq('/cc/ccache')}" in content
def test_get_flash_ld_path(tmp_path: Path) -> None:
paths = InstalledPaths(
framework=tmp_path / "framework",
toolchain=tmp_path / "toolchain",
ninja=Path("ninja"),
)
CORE.testing_mode = True
assert get_flash_ld_path(tmp_path, paths) == (
tmp_path / "ld" / "testing_eagle.flash.4m.ld"
)
CORE.testing_mode = False
# Reads the same install the ninja file linked against; no re-resolve
assert get_flash_ld_path(tmp_path, paths) == (
tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld"
)
def test_flash_size_str() -> None:
assert _flash_size_str(4 * 1024 * 1024) == "4M"
assert _flash_size_str(512 * 1024) == "512K"
def test_write_project_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
CORE.testing_mode = True
_set_flags()
content = _write_ninja(paths)
assert "-T testing_eagle.flash.4m.ld" in content
assert "ld/testing_eagle.flash.4m.ld" in content
def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None:
"""An incomplete framework install fails naming the missing path."""
paths = _make_framework(tmp_path)
shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2")
_set_flags()
with pytest.raises(EsphomeError, match="incomplete.*lwip2"):
_write_ninja(paths)
def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises(
tmp_path: Path,
) -> None:
@@ -440,6 +694,61 @@ def test_build_config_nonosdk_precedence() -> None:
assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221"
def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None:
"""A lowercase .s source assembles plain (SCons AS), never through the
preprocessor rule that a .S source gets."""
paths = _make_framework(tmp_path)
core_dir = paths.framework / "cores" / "esp8266"
(core_dir / "lowlevel.s").write_text("nop\n")
_set_flags()
content = _write_ninja(paths)
assert "lowlevel.s.o: asm " in content
assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in (
content
)
def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None:
"""build_unflags: -nostdlib filters whole-token from both lines, as
PlatformIO allows; only operand-taking flags hard-error."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-nostdlib"}
content = _write_ninja(paths)
assert "-nostdlib" not in content
def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None:
"""Unflagging a -u symbol drops the -u that carried it; a dangling -u
would consume the next token and hand ld a symbol as an input file."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"_printf_float"}
content = _write_ninja(paths)
link_line = next(
line for line in content.splitlines() if line.startswith("linkflags = ")
)
assert "_printf_float" not in link_line
assert "-u -u" not in link_line
# The neighbors survive as intact pairs
assert "-u app_entry" in link_line
assert "-u _DebugExceptionVector" in link_line
def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None:
"""build_unflags removes flags from the framework sets, as PlatformIO does."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"}
content = _write_ninja(paths)
for line in content.splitlines():
key = line.split(" = ")[0]
if key in ("cflags", "cxxflags", "asflags"):
assert "-fipa-pta" not in line
if key == "linkflags":
assert "-Wl,--gc-sections" not in line
def test_project_flags_trailing_bare_linker_flag_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -494,10 +803,18 @@ def test_project_flags_requotes_lexed_defines() -> None:
compile_flags, _link, _dirs, _libs = _split_flags()
# shlex folds the quotes (as PIO's ParseFlags does); _shell_token
# re-quotes the spaced token so the shell passes one argv element
expected = (
'"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'"
)
assert compile_flags == [expected]
assert compile_flags == [_shq("-DGREETING=hello world")]
def test_write_project_empty_core_raises(tmp_path: Path) -> None:
"""A framework tree with no core sources fails at generation, not link."""
paths = _make_framework(tmp_path)
core = paths.framework / "cores" / "esp8266"
for f in core.iterdir():
f.unlink()
_set_flags()
with pytest.raises(EsphomeError, match="no core sources"):
_write_ninja(paths)
def test_flag_defines_joins_spaced_define() -> None:
@@ -508,6 +825,75 @@ def test_flag_defines_joins_spaced_define() -> None:
assert "" not in defines
def test_ninja_path_escaping() -> None:
"""Build-statement paths and command-line paths escape differently."""
assert arduino8266._e("a b:$c") == "a$ b$:$$c"
assert arduino8266._q("/a b/$x") == _shq("/a b/$$x")
def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None:
"""The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags
reach assembly compiles."""
paths = _make_framework(tmp_path)
_set_flags("-DUSER_KNOB=1", "-Wno-volatile")
content = _write_ninja(paths)
asflags = next(line for line in content.splitlines() if line.startswith("asflags"))
assert "-DUSER_KNOB=1" in asflags
assert "-Wno-volatile" not in asflags
cxxflags = next(
line for line in content.splitlines() if line.startswith("cxxflags")
)
assert "-Wno-volatile" in cxxflags
def test_write_project_returns_changed(tmp_path: Path) -> None:
"""The documented contract: True when build.ninja changed, False on an
identical regeneration (pins byte-stable output too)."""
paths = _make_framework(tmp_path)
_set_flags()
src = CORE.relative_src_path()
(src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch("esphome.arduino.library.resolve_libraries", return_value=[]),
):
assert arduino8266.write_project(paths, None) is True
assert arduino8266.write_project(paths, None) is False
def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None:
"""A half-extracted package must fail by name at generation, not after
the full compile at the elf2bin edge."""
paths = _make_framework(tmp_path)
(paths.framework / "tools" / "elf2bin.py").unlink()
_set_flags()
src = CORE.relative_src_path()
(src / "main.cpp").parent.mkdir(parents=True, exist_ok=True)
(src / "main.cpp").write_text("")
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch("esphome.arduino.library.resolve_libraries", return_value=[]),
pytest.raises(EsphomeError, match="elf2bin"),
):
arduino8266.write_project(paths, None)
def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None:
"""A missing generated source tree is its own error, not an install one."""
paths = _make_framework(tmp_path)
_set_flags()
with (
patch.object(arduino8266, "generate_ld_scripts"),
patch("esphome.arduino.library.resolve_libraries", return_value=[]),
patch.object(
arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope"
),
pytest.raises(EsphomeError, match="source directory"),
):
arduino8266.write_project(paths, None)
def test_build_config_custom_mmu_without_knob_raises() -> None:
"""Custom MMU sizes without the CUSTOM knob are refused."""
with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"):
@@ -600,6 +986,49 @@ def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None:
_run_generate_ld_scripts(paths)
def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None:
"""A spaced -D/-I user flag arrives shell-quoted; assembly must still
receive it."""
paths = _make_framework(tmp_path)
_set_flags('-DGREETING="hello world"', "-Wno-volatile")
content = _write_ninja(paths)
asflags = next(line for line in content.splitlines() if line.startswith("asflags"))
assert _shq("-DGREETING=hello world") in asflags
assert "-Wno-volatile" not in asflags
def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None:
"""A libArchive:false library's objects reach the link directly."""
paths = _make_framework(tmp_path)
lib_src = tmp_path / "gdb" / "src"
lib_src.mkdir(parents=True)
(lib_src / "GDBStub.cpp").write_text("")
_set_flags()
lib = ArduinoLibrary(
name="GDBStub",
sources=[lib_src / "GDBStub.cpp"],
include_dirs=[lib_src],
lib_archive=False,
)
content = _write_ninja(paths, libraries=[lib])
assert "libGDBStub.a" not in content
link_line = next(
line for line in content.splitlines() if line.startswith("build firmware.elf")
)
assert "GDBStub.cpp.o" in link_line
def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None:
"""A caller bypassing config validation gets the board named, not a
KeyError."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board"
with pytest.raises(EsphomeError, match="'not_a_board' is not supported"):
_write_ninja(paths)
def test_unflag_tokens_join_spaced_entries() -> None:
"""Spaced build_unflags entries ("-D FOO") match the joined token."""
CORE.build_unflags = {"-D FOO", "-l bar"}
@@ -1072,6 +1501,31 @@ def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None:
_run_generate_ld_scripts(paths)
def test_write_project_unmatched_unflag_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unflag that removes nothing is named; a matching one is silent."""
paths = _make_framework(tmp_path)
_set_flags("-DUSE_FOO=1")
CORE.build_unflags = {"-DUSE_FOO", "-Os"}
content = _write_ninja(paths)
assert "matched no build flag: -DUSE_FOO" in caplog.text
assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0]
# The matching -Os unflag really removed the framework flag
cflags = next(line for line in content.splitlines() if line.startswith("cflags"))
assert " -Os " not in cflags
def test_write_project_lexes_build_flags_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A malformed build_flags entry warns once per generation."""
paths = _make_framework(tmp_path)
_set_flags("-DFOO=1 -l")
_write_ninja(paths)
assert caplog.text.count("Ignoring trailing '-l'") == 1
def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None:
"""With MMU_CUSTOM also set, the actionable fix is dropping the variant
knob, not setting the knob the user already set."""
@@ -1171,6 +1625,11 @@ def test_generate_ld_scripts_unreadable_header_forces_regeneration(
mock_run.assert_called_once()
def test_board_tables_are_equal() -> None:
"""BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync."""
assert set(BOARDS) == set(ESP8266_BOARD_BUILD)
def test_bare_include_and_define_dropped(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -1208,3 +1667,49 @@ def test_defines_flags_honors_f_cpu_override() -> None:
CORE.platformio_options = {"board_build.f_cpu": "160000000L"}
defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"])
assert "-DF_CPU=160000000L" in defines
def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None:
"""board_build.ldscript (filesystem reservation, corrected flash size)
replaces the board default; a path is rejected since the name resolves
via the -L search path."""
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld"
CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"}
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld"
paths = _make_framework(tmp_path)
_set_flags()
content = _write_ninja(paths)
assert "-T eagle.flash.4m2m.ld" in content
CORE.platformio_options = {"board_build.ldscript": "../evil.ld"}
with pytest.raises(EsphomeError, match="bare script name"):
arduino8266._flash_ld_name("nodemcuv2")
def test_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None:
"""build_unflags: -u would strip all seven -u tokens and leave the
operands as ld input files; refuse by name instead."""
paths = _make_framework(tmp_path)
_set_flags()
CORE.build_unflags = {"-u _printf_float"}
with pytest.raises(EsphomeError, match="cannot remove plain linker"):
_write_ninja(paths)
def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None:
"""The board joined BOARDS late; the flash-size default (2m.ld) would
move _FS_end and the preferences sector on existing devices."""
assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld"
assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld"
def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None:
"""A spaced override never reaches the link line: generate_ld_scripts
rejects the name first (the -T _shell_token quoting behind it is
defence-in-depth)."""
CORE.platformio_options = {"board_build.ldscript": "my script.ld"}
paths = _make_framework(tmp_path)
_set_flags()
# The completeness checks run first; give them a src dir to pass
CORE.relative_src_path().mkdir(parents=True, exist_ok=True)
with pytest.raises(EsphomeError, match="Invalid flash linker script name"):
arduino8266.write_project(paths, None)