Files
78--xiaozhi-esp32/scripts/tests/test_build.py
T
2026-08-08 08:33:45 +08:00

1512 lines
56 KiB
Python

import importlib.util
import json
import os
import contextlib
import io
import re
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location("build", ROOT / "scripts/build.py")
build = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(build)
class VersionTests(unittest.TestCase):
def test_parse_and_match(self):
self.assertEqual(build._parse_version("ESP-IDF v6.0.1"), (6, 0, 1))
self.assertTrue(build._version_matches((5, 5, 4), "<6.0"))
self.assertTrue(build._version_matches((6, 0, 1), ">=6.0"))
with self.assertRaises(ValueError):
build._version_matches((6, 0, 1), "~=6.0")
def test_current_matrix_uniqueness_and_p4_variants(self):
idf5 = build._collect_variants(idf_version=(5, 5, 4))
idf6 = build._collect_variants(idf_version=(6, 0, 1))
idf61 = build._collect_variants(idf_version=(6, 1, 0))
for variants in (idf5, idf6, idf61):
names = [variant["full_name"] for variant in variants]
self.assertEqual(len(names), len(set(names)))
idf6_names = {variant["full_name"] for variant in idf6}
self.assertIn("espressif-esp32-p4-function-ev-board", idf6_names)
self.assertIn("espressif-esp32-p4x-function-ev-board", idf6_names)
self.assertNotIn("espressif-esp-p4-function-ev-board", idf6_names)
self.assertNotIn("espressif-esp-p4-function-ev-board-p4x", idf6_names)
self.assertIn("waveshare-esp32-p4x-nano-10.1-a", idf6_names)
self.assertIn("waveshare-esp32-p4x-wifi6-touch-lcd-10.1", idf6_names)
self.assertNotIn("waveshare-esp32-p4-nano-10.1-a-p4x", idf6_names)
self.assertNotIn("espressif-esp32-s31-function-coreboard-1", idf6_names)
self.assertIn("alientek-atk-dnesp32s3", idf6_names)
self.assertNotIn("atk-dnesp32s3", idf6_names)
self.assertNotIn("alientek-alientek-atk-dnesp32s3", idf6_names)
self.assertIn("m5stack-atom-echos3r", idf6_names)
self.assertIn("nologo-xingzhi-abs-2.0", idf6_names)
self.assertIn("spotpear-sp-esp32-s3-1.28-box", idf6_names)
self.assertIn("dfrobot-df-k10", idf6_names)
self.assertIn("xorigin-aipi-lite", idf6_names)
self.assertIn("kevin-box-2", idf6_names)
self.assertNotIn("kevin-kevin-box-2", idf6_names)
self.assertIn("labplus-ledong-v2", idf6_names)
self.assertNotIn("labplus-labplus-ledong-v2", idf6_names)
self.assertIn("lckfb-lichuang-dev", idf6_names)
self.assertIn("lckfb-lichuang-c3-dev", idf6_names)
self.assertIn("wdmomo-esp32-cgc", idf6_names)
self.assertIn("wdmomo-esp32-cgc-144", idf6_names)
self.assertNotIn("esp32-cgc", idf6_names)
self.assertNotIn("esp32-cgc-144", idf6_names)
idf61_names = {variant["full_name"] for variant in idf61}
self.assertIn("espressif-esp32-s31-function-coreboard-1", idf61_names)
self.assertIn("rymcu-bigsmart", idf61_names)
self.assertNotIn("rymcu-rymcu-bigsmart", idf61_names)
atk = next(
variant for variant in idf61
if variant["board"] == "alientek/atk-dnesp32s3"
)
self.assertEqual(atk["type"], "atk-dnesp32s3")
self.assertEqual(atk["target"], "esp32s3")
self.assertEqual(atk["config"], "CONFIG_BOARD_TYPE_ATK_DNESP32S3")
self.assertEqual(
atk["display_name"],
"Alientek ATK-DNESP32S3 Development Board (正点原子)",
)
self.assertEqual(
build._get_release_full_name(
"espressif",
{"name": "esp-box-3"},
),
"espressif-esp-box-3",
)
self.assertEqual(
build._get_release_full_name(
"espressif",
{"name": "esp32-p4x-function-ev-board"},
),
"espressif-esp32-p4x-function-ev-board",
)
self.assertEqual(
build._normalize_p4x_release_name("m5stack-tab5-p4x"),
"m5stack-tab5-p4x",
)
for config_path in (ROOT / "main/boards").rglob("config.json"):
config = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(build._get_reported_type(config), config["type"])
self.assertNotIn("board_name", config, config_path)
self.assertNotIn("release_name", config, config_path)
board = config_path.parent.relative_to(ROOT / "main/boards").as_posix()
self.assertTrue(build._board_type_exists(board), config_path)
for build_config in config.get("builds", []):
self.assertNotIn("board_name", build_config, config_path)
self.assertNotIn("release_name", build_config, config_path)
cmake = (ROOT / "main/CMakeLists.txt").read_text(encoding="utf-8")
self.assertNotIn("set(MANUFACTURER", cmake)
self.assertIn('BOARD_MANUFACTURER=\\"${BOARD_MANUFACTURER}\\"', cmake)
self.assertIn(
'BOARD_TYPE MATCHES "^[a-z0-9.-]+$"',
cmake,
)
self.assertIn(
'BOARD_NAME MATCHES "^[a-z0-9.-]+$"',
cmake,
)
for source_name in (
"wifi_board.cc",
"ml307_board.cc",
"nt26_board.cc",
"rndis_board.cc",
"ethernet_board.cc",
):
source = (
ROOT / "main/boards/common" / source_name
).read_text(encoding="utf-8")
self.assertIn("manufacturer", source, source_name)
self.assertIn("BOARD_MANUFACTURER", source, source_name)
def test_reported_types_and_names_are_valid_and_unique(self):
type_owners = {}
name_owners = {}
pair_owners = {}
for config_path in sorted(
(ROOT / "main/boards").rglob("config*.json")
):
config = json.loads(config_path.read_text(encoding="utf-8"))
board = config_path.parent.relative_to(
ROOT / "main/boards"
).as_posix()
board_type = build._get_reported_type(config)
previous_board = type_owners.setdefault(board_type, board)
self.assertEqual(
previous_board,
board,
f"BOARD_TYPE {board_type!r} is used by multiple boards",
)
for build_config in config.get("builds", []):
board_name = build._get_reported_name(build_config)
self.assertNotIn(
board_name,
name_owners,
f"BOARD_NAME {board_name!r} is used by "
f"{name_owners.get(board_name)} and {config_path}",
)
name_owners[board_name] = config_path
pair = (board_type, board_name)
self.assertNotIn(
pair,
pair_owners,
f"reported board identity {pair!r} is duplicated",
)
pair_owners[pair] = config_path
def test_language_and_wake_word_are_not_board_config_options(self):
for config_path in sorted(
(ROOT / "main/boards").rglob("config*.json")
):
config = json.loads(config_path.read_text(encoding="utf-8"))
for build_config in config.get("builds", []):
for option in build_config.get("sdkconfig_append", []):
self.assertFalse(
option.startswith("CONFIG_LANGUAGE_")
or option.startswith("CONFIG_SR_WN_"),
f"{config_path}: configure language and wake word "
f"through menuconfig or build parameters, not {option}",
)
def test_default_flash_options_are_not_repeated(self):
def read_defaults(path):
values = {}
if not path.exists():
return values
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("CONFIG_") and "=" in line:
key, value = line.split("=", 1)
values[key] = value
return values
base_defaults = read_defaults(ROOT / "sdkconfig.defaults")
for config_path in sorted(
(ROOT / "main/boards").rglob("config*.json")
):
config = json.loads(config_path.read_text(encoding="utf-8"))
defaults = dict(base_defaults)
defaults.update(
read_defaults(
ROOT / f"sdkconfig.defaults.{config['target']}"
)
)
selected_flash = next(
(
key
for key, value in reversed(defaults.items())
if key.startswith("CONFIG_ESPTOOLPY_FLASHSIZE_")
and value == "y"
),
None,
)
for build_config in config.get("builds", []):
for option in build_config.get("sdkconfig_append", []):
if "=" not in option:
continue
key, value = option.split("=", 1)
if key.startswith("CONFIG_ESPTOOLPY_FLASHSIZE_"):
self.assertNotEqual(
(key, value),
(selected_flash, "y"),
f"{config_path}: {option} repeats the effective "
"project default",
)
elif key == "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME":
self.assertNotEqual(
defaults.get(key),
value,
f"{config_path}: {option} repeats the effective "
"project default",
)
def test_chip_defaults_only_contain_target_overrides(self):
def read_defaults(path):
values = {}
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("CONFIG_") and "=" in line:
key, value = line.split("=", 1)
values[key] = value
return values
base_defaults = read_defaults(ROOT / "sdkconfig.defaults")
self.assertEqual(
base_defaults["CONFIG_ESPTOOLPY_FLASHSIZE_16MB"],
"y",
)
self.assertEqual(
base_defaults["CONFIG_PARTITION_TABLE_CUSTOM_FILENAME"],
'"partitions/v2/16m.csv"',
)
expected_partitions = {
"esp32": '"partitions/v2/4m.csv"',
"esp32c3": '"partitions/v2/16m_c3.csv"',
"esp32c5": '"partitions/v2/16m.csv"',
"esp32c6": '"partitions/v2/16m_c3.csv"',
"esp32p4": '"partitions/v2/16m.csv"',
"esp32s3": '"partitions/v2/16m.csv"',
"esp32s31": '"partitions/v2/16m.csv"',
}
for target, expected_partition in expected_partitions.items():
target_defaults = read_defaults(
ROOT / f"sdkconfig.defaults.{target}"
)
duplicate_values = {
key
for key, value in target_defaults.items()
if base_defaults.get(key) == value
}
self.assertFalse(
duplicate_values,
f"sdkconfig.defaults.{target} repeats base defaults: "
f"{sorted(duplicate_values)}",
)
effective_defaults = dict(base_defaults)
effective_defaults.update(target_defaults)
self.assertEqual(
effective_defaults[
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME"
],
expected_partition,
)
if target == "esp32":
self.assertEqual(
target_defaults[
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB"
],
"y",
)
else:
self.assertFalse(
any(
key.startswith("CONFIG_ESPTOOLPY_FLASHSIZE_")
for key in target_defaults
),
f"sdkconfig.defaults.{target} should inherit 16MB",
)
class BoardSelectionTests(unittest.TestCase):
def setUp(self):
self.variants = [
{"board": "bread-compact-wifi", "name": "bread-compact-wifi", "full_name": "bread-compact-wifi"},
{
"board": "waveshare/esp32-c6-touch-amoled-2.06",
"name": "esp32-c6-touch-amoled-2.06",
"full_name": "waveshare-esp32-c6-touch-amoled-2.06",
},
]
def test_nested_manufacturer_board_path(self):
selected = build._select_variants_for_changes(
self.variants,
["main/boards/waveshare/esp32-c6-touch-amoled-2.06/config.h"],
)
self.assertEqual([item["board"] for item in selected], [self.variants[1]["board"]])
def test_official_directory_can_keep_legacy_board_type(self):
board = "espressif/esp32-s3-box-3"
self.assertTrue(build._board_type_exists(board))
self.assertEqual(
build._resolve_board_config(board, "esp32s3", []),
"CONFIG_BOARD_TYPE_ESP32_S3_BOX_3",
)
def test_board_display_name_comes_from_kconfig_prompt(self):
self.assertEqual(
build._get_board_display_name(
"CONFIG_BOARD_TYPE_ATK_DNESP32S3"
),
"Alientek ATK-DNESP32S3 Development Board (正点原子)",
)
def test_variant_name_disambiguates_shared_board_directory(self):
board = "lilygo/t-cameraplus-s3"
self.assertEqual(
build._resolve_board_config(
board,
"esp32s3",
[],
variant_name="lilygo-t-cameraplus-s3-v1.2",
),
"CONFIG_BOARD_TYPE_LILYGO_T_CAMERAPLUS_S3_V1_2",
)
def test_m5stack_directory_can_omit_manufacturer_prefix(self):
board = "m5stack/cardputer-adv"
self.assertTrue(build._board_type_exists(board))
self.assertEqual(
build._resolve_board_config(board, "esp32s3", []),
"CONFIG_BOARD_TYPE_M5STACK_CARDPUTER_ADV",
)
def test_new_manufacturer_directories_keep_existing_board_types(self):
cases = {
"xorigin/aipi-lite": "CONFIG_BOARD_TYPE_XORIGIN_AIPI_LITE",
"kevin/box-2": "CONFIG_BOARD_TYPE_KEVIN_BOX_2",
"labplus/ledong-v2": "CONFIG_BOARD_TYPE_LABPLUS_LEDONG_V2",
"lckfb/szpi-esp32s3": "CONFIG_BOARD_TYPE_LICHUANG_DEV_S3",
"lckfb/szpi-esp32c3": "CONFIG_BOARD_TYPE_LICHUANG_DEV_C3",
"wdmomo/esp32-cgc": "CONFIG_BOARD_TYPE_WDMOMO_CGC",
"wdmomo/esp32-cgc-144": "CONFIG_BOARD_TYPE_WDMOMO_CGC_144",
}
for board, expected in cases.items():
with self.subTest(board=board):
self.assertTrue(build._board_type_exists(board))
config = json.loads(
(ROOT / "main/boards" / board / "config.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(
build._resolve_board_config(
board,
config["target"],
config["builds"][0].get("sdkconfig_append", []),
),
expected,
)
def test_alientek_directory_keeps_atk_board_types(self):
cases = {
"alientek/atk-dnesp32s3": (
"CONFIG_BOARD_TYPE_ATK_DNESP32S3",
"atk-dnesp32s3",
),
"alientek/atk-dnesp32s3m-wifi": (
"CONFIG_BOARD_TYPE_ATK_DNESP32S3M_WIFI",
"atk-dnesp32s3m-wifi",
),
"alientek/atk-dnesp32s3m-4g": (
"CONFIG_BOARD_TYPE_ATK_DNESP32S3M_4G",
"atk-dnesp32s3m-4g",
),
}
for board, (expected_config, expected_type) in cases.items():
with self.subTest(board=board):
config = json.loads(
(ROOT / "main/boards" / board / "config.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(config["manufacturer"], "alientek")
self.assertEqual(config["type"], expected_type)
self.assertEqual(
build._resolve_board_config(
board,
config["target"],
config.get("sdkconfig_append", []),
),
expected_config,
)
def test_same_leaf_names_are_scoped_by_manufacturer(self):
self.assertEqual(
build._resolve_board_config("magiclick/c3", "esp32c3", []),
"CONFIG_BOARD_TYPE_MAGICLICK_C3",
)
self.assertEqual(
build._resolve_board_config("xmini/c3", "esp32c3", []),
"CONFIG_BOARD_TYPE_XMINI_C3",
)
def test_common_and_core_changes_select_all(self):
for path in (
"main/boards/common/board.cc",
"main/application.cc",
"components/esp-ml307/src/at_modem.cc",
"scripts/build_default_assets.py",
"scripts/build.py",
):
with self.subTest(path=path):
self.assertEqual(
build._select_variants_for_changes(self.variants, [path]),
self.variants,
)
def test_docs_only_selects_none(self):
self.assertEqual(
build._select_variants_for_changes(self.variants, ["docs/readme.md"]),
[],
)
class BoardMenuTests(unittest.TestCase):
def test_board_menu_is_sorted_and_matches_cmake(self):
kconfig = (ROOT / "main/Kconfig.projbuild").read_text(encoding="utf-8")
cmake = (ROOT / "main/CMakeLists.txt").read_text(encoding="utf-8")
choice = kconfig.split("choice BOARD_TYPE\n", 1)[1].split(
"endchoice\n", 1
)[0]
entries = re.findall(
r'^ config (BOARD_TYPE_[A-Za-z0-9_]+)\n'
r' bool "([^"]+)"\n'
r' depends on (IDF_TARGET_[A-Za-z0-9_]+)$',
choice,
re.MULTILINE,
)
symbols = [symbol for symbol, _, _ in entries]
labels = [label for _, label, _ in entries]
self.assertEqual(len(symbols), len(set(symbols)))
self.assertEqual(len(labels), len(set(labels)))
self.assertEqual(
set(symbols),
set(
re.findall(
r"(?:if|elseif)\(CONFIG_(BOARD_TYPE_[A-Za-z0-9_]+)\)",
cmake,
)
),
)
def natural_key(label):
label = re.sub(
r"\s+\([^)]*[\u3400-\u9fff][^)]*\)$",
"",
label.casefold(),
)
return tuple(
(1, float(part))
if re.fullmatch(r"\d+(?:\.\d+)?", part)
else (0, part)
for part in re.split(r"(\d+(?:\.\d+)?)", label)
)
self.assertEqual(labels, sorted(labels, key=natural_key))
for label in labels:
if re.search(r"[\u3400-\u9fff]", label):
self.assertRegex(
label,
r"^[^\u3400-\u9fff]+\([^)]*[\u3400-\u9fff][^)]*\)$",
)
for stale_label in (
"ALIENTEK",
"AMOLOED",
"AIPI-Lite",
"NOLOGO",
"NULLLAB-AI-VOX3",
"StopWatch",
"electronBot",
"ottoRobot",
):
self.assertNotIn(stale_label, choice)
def test_board_menu_has_explicit_target_defaults(self):
kconfig = (ROOT / "main/Kconfig.projbuild").read_text(encoding="utf-8")
choice = kconfig.split("choice BOARD_TYPE\n", 1)[1].split(
"endchoice\n", 1
)[0]
expected = {
"IDF_TARGET_ESP32": "BOARD_TYPE_BREAD_COMPACT_ESP32",
"IDF_TARGET_ESP32C3": "BOARD_TYPE_XMINI_C3_V3",
"IDF_TARGET_ESP32C5": "BOARD_TYPE_ESP_SENSAIRSHUTTLE",
"IDF_TARGET_ESP32C6": (
"BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_06"
),
"IDF_TARGET_ESP32S3": "BOARD_TYPE_BREAD_COMPACT_WIFI",
"IDF_TARGET_ESP32P4": (
"BOARD_TYPE_ESP32_P4_FUNCTION_EV_BOARD"
),
"IDF_TARGET_ESP32S31": (
"BOARD_TYPE_ESP32_S31_FUNCTION_COREBOARD_1"
),
}
for target, symbol in expected.items():
self.assertIn(f"default {symbol} if {target}", choice)
class InvalidConfigTests(unittest.TestCase):
def test_duplicate_reported_identifiers_fail_collection(self):
cases = (
(
("type-a", "same-name"),
("type-b", "same-name"),
"duplicate reported board name",
),
(
("same-type", "name-a"),
("same-type", "name-b"),
"duplicate reported board type",
),
)
for first, second, expected_error in cases:
with self.subTest(expected_error=expected_error):
with tempfile.TemporaryDirectory() as temp_dir:
boards = Path(temp_dir)
for directory, (board_type, board_name) in zip(
("board-a", "board-b"),
(first, second),
):
board_dir = boards / directory
board_dir.mkdir()
(board_dir / "config.json").write_text(
json.dumps({
"type": board_type,
"target": "esp32s3",
"builds": [{"name": board_name}],
}),
encoding="utf-8",
)
with mock.patch.object(build, "_BOARDS_DIR", boards):
with self.assertRaisesRegex(
ValueError,
expected_error,
):
build._collect_variants(
idf_version=(6, 0, 1)
)
def test_invalid_reported_identifiers_are_rejected(self):
for value in (
"bad_board",
"Bad-Board",
"bad board",
"坏板子",
):
with self.subTest(type=value):
with self.assertRaisesRegex(ValueError, "only lowercase"):
build._get_reported_type({"type": value})
with self.subTest(name=value):
with self.assertRaisesRegex(ValueError, "only lowercase"):
build._get_reported_name({"name": value})
for value in ("board", "board-1", "board.1", "1.2-3"):
with self.subTest(valid=value):
self.assertEqual(
build._get_reported_type({"type": value}),
value,
)
self.assertEqual(
build._get_reported_name({"name": value}),
value,
)
def test_missing_reported_type_fails_collection(self):
with tempfile.TemporaryDirectory() as temp_dir:
boards = Path(temp_dir)
board_dir = boards / "bad-board"
board_dir.mkdir()
(board_dir / "config.json").write_text(json.dumps({
"target": "esp32s3",
"builds": [{"name": "bad-board"}],
}), encoding="utf-8")
with mock.patch.object(build, "_BOARDS_DIR", boards):
with self.assertRaisesRegex(ValueError, 'top-level "type"'):
build._collect_variants(idf_version=(6, 0, 1))
def test_invalid_version_rule_fails_collection(self):
with tempfile.TemporaryDirectory() as temp_dir:
boards = Path(temp_dir)
board_dir = boards / "bad-board"
board_dir.mkdir()
(board_dir / "config.json").write_text(json.dumps({
"type": "bad-board",
"target": "esp32s3",
"builds": [{
"name": "bad-board",
"idf_version": "~=6.0",
}],
}), encoding="utf-8")
with mock.patch.object(build, "_BOARDS_DIR", boards):
with self.assertRaisesRegex(ValueError, "Invalid ESP-IDF version expression"):
build._collect_variants(idf_version=(6, 0, 1))
class PreviewTargetTests(unittest.TestCase):
def test_stage_marker_is_only_emitted_when_enabled(self):
output = io.StringIO()
with (
mock.patch.dict(os.environ, {}, clear=True),
contextlib.redirect_stdout(output),
):
build._emit_build_stage("compiling")
self.assertEqual(output.getvalue(), "")
output = io.StringIO()
with (
mock.patch.dict(
os.environ,
{"XIAOZHI_BUILD_STAGES": "true"},
clear=True,
),
contextlib.redirect_stdout(output),
):
build._emit_build_stage("compiling")
self.assertEqual(output.getvalue(), "XIAOZHI_STAGE compiling\n")
def test_merge_bin_enables_preview_mode(self):
with mock.patch.object(build, "_run_idf") as run_idf:
build.merge_bin(preview=True)
run_idf.assert_called_once_with("merge-bin", preview=True)
class TargetConfigurationTests(unittest.TestCase):
def test_sync_vscode_target_preserves_other_settings(self):
with tempfile.TemporaryDirectory() as temp_dir:
settings = Path(temp_dir) / "settings.json"
settings.write_text(
'{\n'
' "idf.customExtraVars": {\n'
' "IDF_TARGET": "esp32c3"\n'
' },\n'
' "editor.formatOnSave": true\n'
'}\n',
encoding="utf-8",
)
self.assertTrue(build._sync_vscode_target("esp32s3", settings))
self.assertEqual(
settings.read_text(encoding="utf-8"),
'{\n'
' "idf.customExtraVars": {\n'
' "IDF_TARGET": "esp32s3"\n'
' },\n'
' "editor.formatOnSave": true\n'
'}\n',
)
self.assertFalse(build._sync_vscode_target("esp32s3", settings))
def test_sync_vscode_target_skips_missing_settings(self):
with tempfile.TemporaryDirectory() as temp_dir:
settings = Path(temp_dir) / "settings.json"
self.assertFalse(build._sync_vscode_target("esp32s3", settings))
def test_same_target_does_not_clean_build_directory(self):
with (
mock.patch.object(build, "_target_from_cmake_cache", return_value="esp32s3"),
mock.patch.object(build, "_configured_target", return_value="esp32s3"),
mock.patch.object(build, "_run_idf") as run_idf,
):
build._prepare_target("esp32s3", preview=False)
run_idf.assert_not_called()
def test_changed_cmake_target_runs_fullclean_only(self):
with (
mock.patch.object(build, "_target_from_cmake_cache", return_value="esp32c3"),
mock.patch.object(build, "_configured_target", return_value=None),
mock.patch.object(build, "_run_idf") as run_idf,
):
build._prepare_target("esp32s3", preview=True)
run_idf.assert_called_once_with(
"fullclean",
preview=True,
)
def test_configure_build_uses_all_cmake_values_in_one_run(self):
previous_cwd = Path.cwd()
try:
with tempfile.TemporaryDirectory() as temp_dir:
os.chdir(temp_dir)
Path("sdkconfig").write_text(
'CONFIG_IDF_TARGET="esp32s3"\nCONFIG_OLD_VARIANT=y\n',
encoding="utf-8",
)
Path("sdkconfig.defaults").write_text(
"CONFIG_PROJECT_DEFAULT=y\n",
encoding="utf-8",
)
with mock.patch.object(build, "_run_idf") as run_idf:
build._configure_build(
"esp32s3",
["CONFIG_BOARD_TYPE_TEST=y", "CONFIG_FEATURE=y"],
"test-board",
preview=False,
)
self.assertFalse(Path("sdkconfig").exists())
self.assertIn(
"CONFIG_OLD_VARIANT=y",
Path("sdkconfig.old").read_text(encoding="utf-8"),
)
fragment = Path("build/xiaozhi-build.sdkconfig.defaults")
self.assertEqual(
fragment.read_text(encoding="utf-8"),
"# Generated by scripts/build.py\n"
"CONFIG_BOARD_TYPE_TEST=y\n"
"CONFIG_FEATURE=y\n",
)
run_idf.assert_called_once_with(
"-DIDF_TARGET=esp32s3",
"-DSDKCONFIG_DEFAULTS="
"sdkconfig.defaults;build/xiaozhi-build.sdkconfig.defaults",
"-DBOARD_NAME=test-board",
"reconfigure",
preview=False,
)
finally:
os.chdir(previous_cwd)
def test_configure_build_replaces_stale_sdkconfig_backup(self):
previous_cwd = Path.cwd()
try:
with tempfile.TemporaryDirectory() as temp_dir:
os.chdir(temp_dir)
Path("sdkconfig").write_text(
'CONFIG_IDF_TARGET="esp32s3"\n',
encoding="utf-8",
)
Path("sdkconfig.old").write_text(
"CONFIG_USER_PREVIOUS_VALUE=y\n",
encoding="utf-8",
)
with mock.patch.object(build, "_run_idf"):
build._configure_build(
"esp32s3",
["CONFIG_BOARD_TYPE_TEST=y"],
"test-board",
preview=False,
)
self.assertFalse(Path("sdkconfig").exists())
self.assertEqual(
Path("sdkconfig.old").read_text(encoding="utf-8"),
'CONFIG_IDF_TARGET="esp32s3"\n',
)
finally:
os.chdir(previous_cwd)
class BuildOptionTests(unittest.TestCase):
def test_supported_languages_match_kconfig_and_cmake(self):
kconfig = (ROOT / "main/Kconfig.projbuild").read_text(
encoding="utf-8"
)
cmake = (ROOT / "main/CMakeLists.txt").read_text(encoding="utf-8")
supported_languages = build._collect_languages()
kconfig_languages = set(
re.findall(r"^\s+config LANGUAGE_([A-Z_]+)$", kconfig, re.MULTILINE)
)
expected_symbols = {
language.replace("-", "_").upper()
for language in supported_languages
}
self.assertEqual(kconfig_languages, expected_symbols)
for language in supported_languages:
symbol = language.replace("-", "_").upper()
self.assertIn(f"CONFIG_LANGUAGE_{symbol}", cmake)
self.assertTrue(
(ROOT / "main/assets/locales" / language).is_dir(),
language,
)
def test_languages_are_discovered_from_build_configuration(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
cmake = root / "CMakeLists.txt"
kconfig = root / "Kconfig.projbuild"
locales = root / "locales"
(locales / "xx-YY").mkdir(parents=True)
cmake.write_text(
'if(CONFIG_LANGUAGE_XX_YY)\n'
' set(LANG_DIR "xx-YY")\n'
'endif()\n',
encoding="utf-8",
)
kconfig.write_text(
"config LANGUAGE_XX_YY\n"
' bool "Test language"\n',
encoding="utf-8",
)
self.assertEqual(
build._collect_languages(cmake, kconfig, locales),
["xx-YY"],
)
def test_language_normalization_and_option(self):
self.assertEqual(
build._language_sdkconfig_option("EN_us"),
("en-US", "CONFIG_LANGUAGE_EN_US=y"),
)
with self.assertRaisesRegex(ValueError, "Unsupported language"):
build._language_sdkconfig_option("xx-YY")
def test_wake_words_are_read_from_esp_sr_kconfig(self):
with tempfile.TemporaryDirectory() as temp_dir:
kconfig = Path(temp_dir) / "Kconfig.projbuild"
kconfig.write_text(
'config SR_WN_WN9S_HELLO\n'
' bool "Hello (wn9s_hello)"\n'
'\n'
'config SR_WN_WN9_JARVIS_TTS\n'
' bool "Jarvis (wn9_jarvis_tts)"\n',
encoding="utf-8",
)
wake_words = build._collect_wake_words(kconfig)
self.assertEqual(
[item["model"] for item in wake_words],
["wn9s_hello", "wn9_jarvis_tts"],
)
self.assertEqual(
[item["phrase"] for item in wake_words],
["Hello", "Jarvis"],
)
self.assertIn("esp32c5", wake_words[0]["targets"])
self.assertNotIn("esp32c5", wake_words[1]["targets"])
def test_missing_esp_sr_kconfig_has_actionable_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
missing = Path(temp_dir) / "missing-kconfig"
with self.assertRaisesRegex(
RuntimeError,
"idf.py reconfigure",
):
build._collect_wake_words(missing)
def test_wake_word_alias_selects_target_engine(self):
model, options, symbols = build._wake_word_sdkconfig_options(
"nihaoxiaozhi",
"esp32c5",
)
self.assertEqual(model, "wn9s_nihaoxiaozhi")
self.assertIn("CONFIG_USE_ESP_WAKE_WORD=y", options)
self.assertIn("CONFIG_SR_WN_WN9S_NIHAOXIAOZHI=y", options)
self.assertEqual(
symbols,
[
"CONFIG_USE_ESP_WAKE_WORD",
"CONFIG_SR_WN_WN9S_NIHAOXIAOZHI",
],
)
model, options, symbols = build._wake_word_sdkconfig_options(
"nihaoxiaozhi",
"esp32s3",
)
self.assertEqual(model, "wn9_nihaoxiaozhi_tts")
self.assertIn("CONFIG_USE_AFE_WAKE_WORD=y", options)
self.assertIn("CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=y", options)
self.assertEqual(
symbols,
[
"CONFIG_USE_AFE_WAKE_WORD",
"CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS",
],
)
def test_wake_word_disabled_removes_target_default_model(self):
model, options, symbols = build._wake_word_sdkconfig_options(
"disabled",
"esp32s3",
)
self.assertEqual(model, "disabled")
self.assertIn("CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n", options)
self.assertIn("CONFIG_USE_AFE_WAKE_WORD=n", options)
self.assertIn("CONFIG_USE_ESP_WAKE_WORD=n", options)
self.assertIn("CONFIG_WAKE_WORD_DISABLED=y", options)
self.assertEqual(symbols, ["CONFIG_WAKE_WORD_DISABLED"])
def test_incompatible_wake_word_model_is_rejected(self):
with self.assertRaisesRegex(ValueError, "WakeNet9s"):
build._wake_word_sdkconfig_options(
"wn9_jarvis_tts",
"esp32c6",
)
with self.assertRaisesRegex(ValueError, "Invalid wake word"):
build._wake_word_sdkconfig_options("jarvis", "esp32s3")
def test_board_wake_word_support_obeys_psram_dependency(self):
self.assertTrue(build._board_supports_wake_word("esp32c3", []))
self.assertFalse(build._board_supports_wake_word("esp32", []))
self.assertTrue(
build._board_supports_wake_word("esp32", ["CONFIG_SPIRAM=y"])
)
self.assertFalse(
build._board_supports_wake_word("esp32s3", ["CONFIG_SPIRAM=n"])
)
def test_user_options_override_board_options(self):
merged = build._merge_sdkconfig_options(
[
"CONFIG_BOARD_TYPE_TEST=y",
"CONFIG_USE_ESP_WAKE_WORD=n",
],
[
"CONFIG_USE_ESP_WAKE_WORD=y",
"CONFIG_SR_WN_WN9S_NIHAOXIAOZHI=y",
],
)
self.assertEqual(
merged,
[
"CONFIG_BOARD_TYPE_TEST=y",
"CONFIG_USE_ESP_WAKE_WORD=y",
"CONFIG_SR_WN_WN9S_NIHAOXIAOZHI=y",
],
)
def test_configured_build_options_are_verified(self):
previous_cwd = Path.cwd()
try:
with tempfile.TemporaryDirectory() as temp_dir:
os.chdir(temp_dir)
Path("sdkconfig").write_text(
"CONFIG_LANGUAGE_EN_US=y\n",
encoding="utf-8",
)
build._validate_configured_symbols(
["CONFIG_LANGUAGE_EN_US"],
"--language",
)
with self.assertRaisesRegex(ValueError, "Kconfig rejected"):
build._validate_configured_symbols(
["CONFIG_SR_WN_UNKNOWN"],
"--wake-word",
)
finally:
os.chdir(previous_cwd)
def test_disabled_build_options_accept_symbols_hidden_by_kconfig(self):
previous_cwd = Path.cwd()
try:
with tempfile.TemporaryDirectory() as temp_dir:
os.chdir(temp_dir)
Path("sdkconfig").write_text(
"CONFIG_SELECTED_STYLE=y\n"
"# CONFIG_EXPLICITLY_DISABLED is not set\n",
encoding="utf-8",
)
build._validate_configured_options(
[
"CONFIG_SELECTED_STYLE=y",
"CONFIG_EXPLICITLY_DISABLED=n",
"CONFIG_HIDDEN_BY_DEPENDENCY=n",
],
"--build-options-json",
)
with self.assertRaisesRegex(
ValueError,
"CONFIG_SELECTED_STYLE=n",
):
build._validate_configured_options(
["CONFIG_SELECTED_STYLE=n"],
"--build-options-json",
)
with self.assertRaisesRegex(
ValueError,
"CONFIG_HIDDEN_BY_DEPENDENCY=y",
):
build._validate_configured_options(
["CONFIG_HIDDEN_BY_DEPENDENCY=y"],
"--build-options-json",
)
finally:
os.chdir(previous_cwd)
def test_lcd_board_exposes_curated_display_options(self):
config = json.loads(
(ROOT / "main/boards/bread-compact-esp32-lcd/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"bread-compact-esp32-lcd",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"bread-compact-esp32-lcd",
config["target"],
board_config,
build_config,
)
by_key = {definition["key"]: definition for definition in definitions}
self.assertEqual(by_key["display_model"]["default"], "LCD_ST7789_240X240_7PIN")
self.assertNotIn(
"LCD_CUSTOM",
{choice["value"] for choice in by_key["display_model"]["choices"]},
)
self.assertIn("display_style", by_key)
self.assertIn("multiline_chat", by_key)
normalized = build._normalize_build_options(
definitions,
{"display_model": "LCD_ST7789_240X320"},
)
sdkconfig = build._build_options_sdkconfig(definitions, normalized, {})
self.assertIn("CONFIG_LCD_CUSTOM=n", sdkconfig)
def test_bread_compact_esp32_config_supports_sh1106(self):
config_header = (
ROOT / "main/boards/bread-compact-esp32/config.h"
).read_text(encoding="utf-8")
self.assertIn("CONFIG_OLED_SH1106_128X64", config_header)
def test_bread_compact_nt26_supports_sh1106(self):
board_dir = ROOT / "main/boards/bread-compact-nt26"
config_header = (board_dir / "config.h").read_text(encoding="utf-8")
board_source = (board_dir / "compact_nt26_board.cc").read_text(
encoding="utf-8"
)
self.assertIn("CONFIG_OLED_SH1106_128X64", config_header)
self.assertIn("esp_lcd_new_panel_sh1106", board_source)
def test_non_default_style_disables_multiline_chat(self):
definitions = [
{
"key": "display_style",
"type": "select",
"default": "default",
"choices": [
{"value": "default", "label": "Default"},
{"value": "wechat", "label": "WeChat"},
],
},
{"key": "multiline_chat", "type": "boolean", "default": True},
]
normalized = build._normalize_build_options(
definitions,
{"display_style": "wechat", "multiline_chat": True},
)
self.assertFalse(normalized["multiline_chat"])
def test_display_style_only_writes_board_supported_choices(self):
definitions = [{
"key": "display_style",
"type": "select",
"default": "default",
"choices": [
{"value": "default", "label": "Default"},
{"value": "wechat", "label": "WeChat"},
],
}]
options = build._build_options_sdkconfig(
definitions,
{"display_style": "wechat"},
{},
)
self.assertIn("CONFIG_USE_DEFAULT_MESSAGE_STYLE=n", options)
self.assertIn("CONFIG_USE_WECHAT_MESSAGE_STYLE=y", options)
self.assertNotIn("CONFIG_USE_EMOTE_MESSAGE_STYLE=n", options)
def test_esp_vocat_default_style_overrides_emote_board_defaults(self):
config = json.loads(
(ROOT / "main/boards/espressif/esp-vocat/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"espressif/esp-vocat",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"espressif/esp-vocat",
config["target"],
board_config,
build_config,
)
normalized = build._normalize_build_options(
definitions,
{"display_style": "default", "multiline_chat": True},
)
options = build._build_options_sdkconfig(
definitions,
normalized,
build._sdkconfig_assignments(build_config["sdkconfig_append"]),
)
self.assertIn("CONFIG_USE_DEFAULT_MESSAGE_STYLE=y", options)
self.assertIn("CONFIG_USE_EMOTE_MESSAGE_STYLE=n", options)
self.assertIn("CONFIG_FLASH_DEFAULT_ASSETS=y", options)
self.assertIn("CONFIG_FLASH_EXPRESSION_ASSETS=n", options)
self.assertIn("CONFIG_USE_MULTILINE_CHAT_MESSAGE=y", options)
def test_camera_mirror_guard_is_settable_by_build_defaults(self):
kconfig = (ROOT / "main/Kconfig.projbuild").read_text(
encoding="utf-8"
)
guard = kconfig.split(
"config XIAOZHI_CAMERA_MIRROR_CONFIGURED\n",
1,
)[1].split("config XIAOZHI_CAMERA_HMIRROR\n", 1)[0]
self.assertIn('bool "Override camera mirror settings"', guard)
definitions = [
{"key": "camera_hmirror", "type": "boolean", "default": False},
{"key": "camera_vflip", "type": "boolean", "default": True},
]
options = build._build_options_sdkconfig(
definitions,
{"camera_hmirror": False, "camera_vflip": True},
{},
)
self.assertIn("CONFIG_XIAOZHI_CAMERA_MIRROR_CONFIGURED=y", options)
self.assertIn("CONFIG_XIAOZHI_CAMERA_HMIRROR=n", options)
self.assertIn("CONFIG_XIAOZHI_CAMERA_VFLIP=y", options)
def test_blufi_expansion_disables_hotspot(self):
definitions = [{
"key": "wifi_provisioning",
"type": "select",
"default": "hotspot",
"choices": [
{"value": "hotspot", "label": "Wi-Fi hotspot"},
{"value": "blufi", "label": "ESP-BluFi"},
],
}]
options = build._build_options_sdkconfig(
definitions,
{"wifi_provisioning": "blufi"},
{},
)
self.assertIn("CONFIG_USE_HOTSPOT_WIFI_PROVISIONING=n", options)
self.assertIn("CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y", options)
def test_camera_board_defaults_are_declared_by_board_config(self):
config = json.loads(
(ROOT / "main/boards/espressif/esp32-s3-korvo-2-v3.0/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"espressif/esp32-s3-korvo-2-v3.0",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"espressif/esp32-s3-korvo-2-v3.0",
config["target"],
board_config,
build_config,
)
defaults = {definition["key"]: definition["default"] for definition in definitions}
self.assertFalse(defaults["camera_hmirror"])
self.assertTrue(defaults["camera_vflip"])
def test_optional_usb_camera_options_require_camera_to_be_enabled(self):
config = json.loads(
(ROOT / "main/boards/espressif/esp-vocat/config.json").read_text(
encoding="utf-8"
)
)
build_config = config["builds"][0]
board_config = build._resolve_board_config(
"espressif/esp-vocat",
config["target"],
build_config["sdkconfig_append"],
variant_name=build_config["name"],
)
definitions = build._build_option_definitions(
"espressif/esp-vocat",
config["target"],
board_config,
build_config,
)
keys = {definition["key"] for definition in definitions}
self.assertNotIn("camera_hmirror", keys)
self.assertNotIn("camera_vflip", keys)
camera_build = dict(build_config)
camera_build["sdkconfig_append"] = [
*build_config["sdkconfig_append"],
"CONFIG_ESP_VIDEO_ENABLE_USB_UVC_VIDEO_DEVICE=y",
]
camera_definitions = build._build_option_definitions(
"espressif/esp-vocat",
config["target"],
board_config,
camera_build,
)
camera_keys = {
definition["key"] for definition in camera_definitions
}
self.assertIn("camera_hmirror", camera_keys)
self.assertIn("camera_vflip", camera_keys)
def test_unknown_semantic_build_option_is_rejected(self):
with self.assertRaisesRegex(ValueError, "Unsupported build option"):
build._normalize_build_options([], {"raw_sdkconfig": "CONFIG_FOO=y"})
class VariantSelectionTests(unittest.TestCase):
def setUp(self):
self.variants = [
{"board": "test-board", "name": "variant-a", "full_name": "variant-a"},
{"board": "test-board", "name": "variant-b", "full_name": "variant-b"},
]
def test_non_interactive_selection_requires_name(self):
stdin = mock.Mock()
stdin.isatty.return_value = False
with (
mock.patch.object(build.sys, "stdin", stdin),
self.assertRaisesRegex(SystemExit, "2"),
):
build._select_variant("test-board", self.variants)
def test_interactive_selection_accepts_number(self):
stdin = mock.Mock()
stdin.isatty.return_value = True
with (
mock.patch.object(build.sys, "stdin", stdin),
mock.patch("builtins.input", return_value="2"),
):
selected = build._select_variant("test-board", self.variants)
self.assertEqual(selected, "variant-b")
class CliTests(unittest.TestCase):
def setUp(self):
self.variants = [
{
"board": "bread-compact-wifi",
"name": "bread-compact-wifi",
"full_name": "bread-compact-wifi",
},
{
"board": "multi-board",
"name": "variant-a",
"full_name": "variant-a",
},
{
"board": "multi-board",
"name": "variant-b",
"full_name": "variant-b",
},
]
def test_no_arguments_prints_help_without_building(self):
output = io.StringIO()
with (
mock.patch.object(build, "build_board") as build_board,
contextlib.redirect_stdout(output),
):
build.main([])
build_board.assert_not_called()
self.assertIn("usage:", output.getvalue())
self.assertIn("--list-boards", output.getvalue())
self.assertIn("--list-languages", output.getvalue())
self.assertIn("--list-wake-words", output.getvalue())
def test_list_boards_prints_boards_and_multi_variants(self):
output = io.StringIO()
with (
mock.patch.object(
build,
"_detect_idf_version_for_listing",
return_value=(6, 0, 2),
),
mock.patch.object(
build,
"_collect_variants",
return_value=self.variants,
),
contextlib.redirect_stdout(output),
):
build.main(["--list-boards"])
self.assertEqual(
output.getvalue(),
"bread-compact-wifi\n"
"multi-board\n"
" - variant-a\n"
" - variant-b\n",
)
def test_list_languages_supports_json(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
build.main(["--list-languages", "--json"])
self.assertEqual(
json.loads(output.getvalue()),
build._collect_languages(),
)
def test_list_wake_words_supports_json(self):
wake_words = [{
"model": "wn9_jarvis_tts",
"phrase": "Jarvis",
"targets": ["esp32s3"],
}]
output = io.StringIO()
with (
mock.patch.object(
build,
"_collect_wake_words",
return_value=wake_words,
),
contextlib.redirect_stdout(output),
):
build.main(["--list-wake-words", "--json"])
self.assertEqual(json.loads(output.getvalue()), wake_words)
def test_build_does_not_create_zip_by_default(self):
with (
mock.patch.object(build, "_detect_idf_version", return_value=(6, 0, 2)),
mock.patch.object(build, "_board_type_exists", return_value=True),
mock.patch.object(
build,
"_collect_variants",
return_value=self.variants,
),
mock.patch.object(build, "build_board") as build_board,
):
build.main(["bread-compact-wifi"])
build_board.assert_called_once_with(
"bread-compact-wifi",
config_filename="config.json",
name_filter="bread-compact-wifi",
create_zip=False,
language=None,
wake_word=None,
build_options=None,
idf_version=(6, 0, 2),
)
def test_zip_flag_is_forwarded(self):
with (
mock.patch.object(build, "_detect_idf_version", return_value=(6, 0, 2)),
mock.patch.object(build, "_board_type_exists", return_value=True),
mock.patch.object(
build,
"_collect_variants",
return_value=self.variants,
),
mock.patch.object(build, "build_board") as build_board,
):
build.main(["bread-compact-wifi", "--zip"])
self.assertTrue(build_board.call_args.kwargs["create_zip"])
def test_language_and_wake_word_are_forwarded(self):
with (
mock.patch.object(build, "_detect_idf_version", return_value=(6, 0, 2)),
mock.patch.object(build, "_board_type_exists", return_value=True),
mock.patch.object(
build,
"_collect_variants",
return_value=self.variants,
),
mock.patch.object(build, "build_board") as build_board,
):
build.main([
"bread-compact-wifi",
"--language",
"en-US",
"--wake-word",
"wn9_jarvis_tts",
])
self.assertEqual(build_board.call_args.kwargs["language"], "en-US")
self.assertEqual(
build_board.call_args.kwargs["wake_word"],
"wn9_jarvis_tts",
)
def test_build_options_json_is_forwarded(self):
with (
mock.patch.object(build, "_detect_idf_version", return_value=(6, 0, 2)),
mock.patch.object(build, "_board_type_exists", return_value=True),
mock.patch.object(build, "_collect_variants", return_value=self.variants),
mock.patch.object(build, "build_board") as build_board,
):
build.main([
"bread-compact-wifi",
"--build-options-json",
'{"wifi_provisioning":"blufi"}',
])
self.assertEqual(
build_board.call_args.kwargs["build_options"],
{"wifi_provisioning": "blufi"},
)
class BoardSourceTests(unittest.TestCase):
def test_relative_board_includes_exist(self):
missing = []
boards_dir = ROOT / "main/boards"
for source in boards_dir.rglob("*"):
if source.suffix not in {".c", ".cc", ".cpp", ".h", ".hpp"}:
continue
for line_number, line in enumerate(
source.read_text(encoding="utf-8", errors="replace").splitlines(),
1,
):
match = re.match(
r'\s*#\s*include\s+"(\.\./[^"]+)"',
line,
)
if match and not (source.parent / match.group(1)).resolve().exists():
missing.append(
f"{source.relative_to(ROOT)}:{line_number}: {match.group(1)}"
)
self.assertEqual(missing, [])
class ZipTests(unittest.TestCase):
def test_zip_is_always_recreated(self):
previous_cwd = Path.cwd()
try:
with tempfile.TemporaryDirectory() as temp_dir:
os.chdir(temp_dir)
Path("build").mkdir()
Path("build/merged-binary.bin").write_bytes(b"new firmware")
Path("releases").mkdir()
output = Path("releases/v1.2.3_test-board.zip")
output.write_bytes(b"stale zip")
build.zip_bin("test-board", "1.2.3")
with build.zipfile.ZipFile(output) as archive:
self.assertEqual(
archive.read("merged-binary.bin"),
b"new firmware",
)
finally:
os.chdir(previous_cwd)
if __name__ == "__main__":
unittest.main()