Files
Andrey 05be38a04a Обновить Flash/CAN, версии сборок и управление периферией
Добавить защищённое обновление приложения через RS и CAN с сохранением
загрузчика, проверкой CRC, восстановлением передачи и защитой EEPROM.
Подключить общий процесс сборки C2000 и автоматический счётчик версии
для согласованных RAM, Flash, CAN и паспортов бинарных образов.

Уточнить запуск ЦАП, обработку измерений и параметров нагрузки,
начальную индикацию и документацию регистров GAS.
Дополнить модели аппаратуры и изолировать заголовки CAN в тестах.

Проверка: 26 тестов выпуска, 4 теста восстановления CAN и восемь
программных моделей CAN, Flash, EEPROM, ЦАП, параметров и TESTLAMP.
2026-11-06 16:11:11 +03:00

344 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверяет выпуск 167: старый OUT, EEPROM, обрезанный SCI8 и отсутствие публикации при отказе.""" # Использует производственные проверки и настоящий TI-конвертор при доступном toolchain.
from datetime import datetime # Независимо восстанавливает регистры даты для контрольного образа.
import hashlib # Сравнивает постоянные каталоги до и после отклонённого выпуска.
import importlib.util # Загружает существующий общий seal/archive без изменения templates.
import json # Создаёт минимальный проект и проверяет поля конечного manifest.
import os # Позволяет указать установленный TI toolchain переменной TI_CGT_ROOT.
from pathlib import Path # Изолирует все временные файлы от настоящих generated и Bin проекта.
import struct # Формирует независимые контрольные SCI8-блоки согласно формату загрузчика TI.
import subprocess # Компилирует минимальный настоящий COFF для проверки интеграции с hex2000.
import sys # Подключает локальные prebuild/postbuild из каталога CCS-проекта.
import tempfile # Удаляет только временные каталоги этой проверки после завершения тестов.
import unittest # Группирует успешные и отказные сценарии с отдельными результатами.
from unittest.mock import patch # Подменяет только конверсию для испытания повреждённых результатов инструментов.
ROOT = Path(__file__).resolve().parents[2] # Находит исходники проверяемой 167 независимо от рабочего каталога запуска.
sys.path.insert(0, str(ROOT / "UKSSTMS320F28335")) # Импортирует производственные функции без запуска их CLI.
import prepare_identity as prebuild # Проверяет фактическую генерацию ручной версии и физического профиля.
import publish_identity as release # Вызывает именно обязательный защищённый postbuild проекта.
VENDOR = ROOT.parent / "newProject/SETGUI/third_party/templates" # Совпадает с include paths обеих конфигураций CCS.
TOOLS = ROOT.parent / "newProject/templates" # Полная рабочая библиотека содержит общий BAT-конвертор и архивирование.
COMPILER = Path(os.environ.get("TI_CGT_ROOT", "C:/ti/ccs1230/ccs/tools/compiler/ti-cgt-c2000_22.6.0.LTS")) # Проверяет установленный TI без загрузки нового toolchain.
SPEC = importlib.util.spec_from_file_location("test_common_identity", TOOLS / "c/firmware-info/tools/identity.py") # Подключает тот же seal/archive, который вызывает postcompile.
IDENTITY = importlib.util.module_from_spec(SPEC) # Отделяет общие инструменты тестов от производственного prebuild.
SPEC.loader.exec_module(IDENTITY) # Неполная зависимость явно останавливает тест вместо пропуска проверок выпуска.
def identity_words(metadata): # Независимый контрольный вектор проверяет общий 12-словный контракт, а не копирует поиск production-кода.
stamp = datetime.fromisoformat(metadata["built_at_utc"]) # Использует фиксированное время того же тестового prebuild.
result = [1, *metadata["version"], stamp.year, stamp.month * 256 + stamp.day, stamp.hour * 256 + stamp.minute, stamp.second] # Первые восемь слов содержат формат, ручную версию и UTC.
result += [ord(metadata["build_id"][index]) * 256 + ord(metadata["build_id"][index + 1]) for index in range(0, 8, 2)] # Последние четыре слова хранят ASCII Build ID в сетевом порядке.
return result # Возвращает числа для независимой SCI8-таблицы и настоящего COFF-фикстура.
def block(address, words): # Строит минимальный загрузочный блок с адресом в словах C28x.
return struct.pack("<HHH", len(words), address >> 16, address & 65535) + struct.pack("<" + "H" * len(words), *words) # Поля длины, старшего/младшего адресов и данных соответствуют документации SCI8.
def image_for(metadata): # Формирует два непересекающихся блока RAM с загруженной точкой входа и неизменяемым паспортом.
return b"\xaa\x08" + bytes(16) + struct.pack("<HH", 0, 0x8000) + block(0x8000, [0x761F, 0x8000]) + block(0x8100, identity_words(metadata)) + bytes(2) # Заголовок занимает 22 октета, нулевая длина завершает таблицу.
def snapshot(path): # Доказывает сохранность прежних выпусков и каталога GUI при любом отказе валидации.
return {str(file.relative_to(path)): hashlib.sha256(file.read_bytes()).hexdigest() for file in path.rglob("*") if file.is_file()} if path.exists() else {} # Сравнивает имена и точные байты всех постоянных файлов.
class ReleaseTests(unittest.TestCase): # Испытывает реальную последовательность prebuild, проверок и публикации.
def setUp(self): # Каждый сценарий получает собственные project/generated/output/export.
self.temporary = tempfile.TemporaryDirectory(prefix="balsam-release-test-") # Временные результаты не попадают в настоящее дерево Bin.
self.directory = Path(self.temporary.name) # Задаёт корень только текущего тестового проекта.
self.project = self.directory / "Project" # Сохраняет отношение project.parent/Source, используемое настоящим prebuild.
self.project.mkdir() # Создаёт отдельный каталог настроек CCS.
policy = json.loads((ROOT / "UKSSTMS320F28335/firmware_build_profile.json").read_text(encoding="utf-8")) # Сохраняет фактический аппаратный профиль рабочей платы для всех сценариев выпуска.
policy["version_header"] = "firmware_version.h" # Существующие проверки выпуска дополнительно сохраняют совместимость ручного режима других проектов; автоматические сценарии явно отключают его.
(self.project / "firmware_build_profile.json").write_text(json.dumps(policy), encoding="utf-8") # Изолирует выбор источника версии в настройках временного проекта.
self.build = self.project / "Debug" # Использует штатное соглашение выбранной конфигурации.
self.build.mkdir() # Позволяет postbuild изолировать конверсию внутри этой конфигурации.
self.config = self.directory / "Source/Internal/Include/eeprom_config.h" # Отделяет выбор физической EEPROM от настоящей платы пользователя.
self.config.parent.mkdir(parents=True) # Создаёт только тестовую иерархию Source.
self.config.write_text("#define EEPROM_M95M02 0 /* Тестовая плата с AT25256B. */\n", encoding="utf-8") # Начальный профиль соответствует совместимой малой EEPROM 167.
self.version_text = "#define FIRMWARE_VERSION_MAJOR 1U /* Основной выпуск. */\n#define FIRMWARE_VERSION_MINOR 1U /* Дополнительный выпуск. */\n#define FIRMWARE_VERSION_PATCH 2U /* Ручной номер исправления. */\n" # Весь тест сохраняет пользовательский выпуск 1.1.2.
(self.project / "firmware_version.h").write_text(self.version_text, encoding="utf-8") # Источником версии остаётся C-заголовок.
self.settings = {"product": "Balsam-167-PM35", "target": "TMS320F28335", "increment_version": True} # Старый автоинкремент не должен преодолеть новый ручной контракт.
(self.project / "firmware_identity.json").write_text(json.dumps(self.settings), encoding="utf-8") # Проверяет совместимость со случайно сохранённым старым полем JSON.
self.output, self.export = self.directory / "Published", self.directory / "Gui" # Выбирает отдельные постоянные назначения для проверки порядка публикации.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated") # Выполняет настоящий prebuild с фактической компилируемой библиотекой.
self.map_path = self.build / "application.map" # Каждая попытка обязана представить карту активного линкера.
self.write_map(self.metadata) # Начальные абсолютные символы согласованы с текущей конфигурацией платы.
def tearDown(self): # Завершение теста не затрагивает настоящее состояние Git или прошивки.
self.temporary.cleanup() # Удаляет только созданный TemporaryDirectory после закрытия всех файлов.
def write_map(self, metadata): # Формирует формат TI MAP для испытания аппаратной согласованности до конверсии.
self.map_path.write_text(f"abs {metadata['eeprom_bytes']:08x} _EEPROM_TOTAL_BYTES\nabs {metadata['max_image_bytes']:08x} _EEPROM_FIRMWARE_LIMIT_BYTES\nabs {metadata['max_image_bytes'] // 2:08x} _EEPROM_PARAMS_START_WORD\n", encoding="utf-8") # Повторяет реальные абсолютные символы F28335.cmd с единицами октеты/слова.
if metadata.get("can_boot_profile"): # Только профиль M95M02 A/B должен доказывать наличие прикладной интеграции CAN.
with self.map_path.open("a", encoding="utf-8") as stream: # Дополняет только временную MAP, не меняя реальные символы проекта.
for name in ("_F28335CanBoot_Init", "_F28335CanBoot_Ready", "_F28335CanBoot_Service", "_F28335CanBoot_RxISR"): # Контрольные hooks представлены экспортированными символами, как в настоящей карте TI.
stream.write(f"0 00008000 {name}\n") # Адрес 0x8000 действительно присутствует в payload независимого SCI8-фикстура image_for.
def invoke(self, **arguments): # Передаёт стандартные пути в реальный postbuild, сохраняя возможность выбора EEPROM.
return release.publish(self.project, VENDOR, TOOLS, COMPILER, self.build, "application", self.map_path, self.output, self.export, **arguments) # Обязательные проверки и копирование выполняет production-код.
def converter(self, payload=None, wrong_eeprom=False): # Имитирует только результат инструмента, оставляя seal/archive и все проверки настоящими.
def produce(compiler, build_dir, artifact, metadata_path, stage, tools_templates): # Получает временное назначение так же, как общий postcompile.
image = stage / (artifact + "_" + self.metadata["build_id"] + ".bin") # Создаёт имя только текущей попытки выпуска.
image.write_bytes(payload if payload is not None else image_for(self.metadata)) # Позволяет испытать обрезанный, старый и слишком большой BIN после успешной конверсии.
IDENTITY.seal(image, metadata_path) # Создаёт настоящий SHA/manifest, чтобы проверки содержимого не подменялись проверкой хеша.
IDENTITY.archive_build(image, metadata_path, stage) # Формирует настоящий архив только во временном каталоге без export.
if wrong_eeprom: # Повреждённый аппаратный паспорт должен отклоняться даже при правильном хеше файла.
manifest_path = image.with_suffix(".bin.manifest.json") # Изменяет лишь паспорт текущего staged-BIN.
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) # Сохраняет остальные корректные поля, включая SHA и версию.
manifest["eeprom"] = "M95M02" if manifest["eeprom"] == "AT25256B" else "AT25256B" # Переключает ровно один аппаратный признак для адресной проверки.
manifest_path.write_text(json.dumps(manifest), encoding="utf-8") # Оставляет целый JSON с намеренно неверным профилем.
return produce # Подмена ограничивается внешним инструментом конверсии; проверки выпуска остаются производственными.
def assert_rejected_without_publication(self, converter, **arguments): # Проверяет не только отказ, но и сохранность уже выпущенных файлов.
self.output.mkdir(exist_ok=True) # Моделирует существующий успешный выпуск до неудачной новой попытки.
self.export.mkdir(exist_ok=True) # Моделирует каталог GUI с ранее проверенными файлами.
(self.output / "previous.bin").write_bytes(b"previous verified release") # Контрольный файл должен остаться побайтно прежним.
(self.export / "previous.bin").write_bytes(b"previous GUI release") # Ошибка не должна обновить внешнее приложение.
before = snapshot(self.output), snapshot(self.export) # Фиксирует полный состав обоих постоянных каталогов.
with patch.object(release, "convert", converter), self.assertRaises((ValueError, OSError)): # Подмена внешнего инструмента не ослабляет настоящие проверки SCI8 и паспорта.
self.invoke(**arguments) # Ошибочная попытка обязана завершиться до копирования staged-файлов.
self.assertEqual(before, (snapshot(self.output), snapshot(self.export))) # Новые архивы и BIN не появляются ни локально, ни в GUI.
def test_manual_version_and_compiled_templates(self): # Проверяет исходный дефект происхождения зависимости и ручной номер выпуска.
self.assertEqual(self.metadata["version"], [1, 1, 2]) # Старый increment_version=true не увеличивает версию из заголовка.
self.assertEqual((self.project / "firmware_version.h").read_text(encoding="utf-8"), self.version_text) # Prebuild не редактирует исходный C-заголовок.
self.assertEqual(json.loads((self.project / "firmware_identity.json").read_text()), self.settings) # Старый JSON также остаётся пользовательской настройкой без скрытой записи.
self.assertEqual(self.metadata["templates_root"], str(VENDOR.resolve())) # Паспорт привязан к компилируемой submodule-копии, а не другой checkout инструментов.
self.assertEqual(self.metadata["templates"], prebuild.git_state(VENDOR)) # Full SHA и dirty соответствуют именно фактической зависимости C.
def enable_auto_version(self, version=(1, 1, 2)): # Переводит только временный проект на тот же сохраняемый счётчик, который включён в настоящем проекте.
policy_path = self.project / "firmware_build_profile.json" # Настройка источника версии принадлежит конкретному CCS-проекту.
policy = json.loads(policy_path.read_text(encoding="utf-8")) # Сохраняет аппаратные поля существующего тестового профиля.
policy.pop("version_header", None) # Отсутствие ручного источника включает стандартный JSON-режим и допускает повторную настройку контрольного номера в одном тесте.
policy_path.write_text(json.dumps(policy), encoding="utf-8") # Изменяет только временный профиль, не настройки рабочей платы.
self.settings.update(version=list(version), increment_version=True, extra={"keep": "настройка"}) # Начальный номер и дополнительное поле проверяют увеличение без потери пользовательских настроек.
(self.project / "firmware_identity.json").write_text(json.dumps(self.settings, ensure_ascii=False), encoding="utf-8") # Создаёт постоянный источник номера для нескольких проверочных попыток.
(self.project / "firmware_version.h").write_bytes((ROOT / "UKSSTMS320F28335/firmware_version.h").read_bytes()) # Проверяет настоящий интерфейс версии, который подключает сгенерированные константы.
def test_real_project_enables_json_iterator(self): # Проверяет включение механизма в рабочем проекте, а не только поведение временной фикстуры.
settings = json.loads((ROOT / "UKSSTMS320F28335/firmware_identity.json").read_text(encoding="utf-8")) # Читает реальные настройки без резервирования номера сборки.
policy = json.loads((ROOT / "UKSSTMS320F28335/firmware_build_profile.json").read_text(encoding="utf-8")) # Проверяет, что старый ручной источник больше не подавляет итератор.
self.assertIs(settings["increment_version"], True) # Автоматическое увеличение требует настоящего логического true.
self.assertNotIn("version_header", policy) # Prebuild должен сохранять новый номер в исходном JSON проекта.
self.assertEqual(len(settings["version"]), 3) # Сохраняемый счётчик содержит major, minor и patch общего контракта.
def test_auto_version_sequence_in_debug_and_release(self): # Каждый основной prebuild должен резервировать ровно один номер независимо от конфигурации CCS.
self.enable_auto_version() # Отправной номер 1.1.2 совпадает с последним ручным выпуском проекта.
for configuration, expected in (("Debug", [1, 1, 3]), ("Release", [1, 1, 4])): # Последовательные сборки разных конфигураций используют один сохраняемый счётчик.
with self.subTest(configuration=configuration): # Различает ошибки увеличения Debug и Release в отчёте.
generated = self.directory / configuration / "generated" # Каждая конфигурация получает собственный заголовок и паспорт.
metadata = prebuild.prepare(self.project, VENDOR, generated) # Запускает производственный адаптер и подключённую библиотеку templates без подмены итератора.
saved = json.loads((self.project / "firmware_identity.json").read_text(encoding="utf-8")) # Считывает источник следующего номера после завершения подготовки.
self.assertEqual((metadata["version"], saved["version"]), (expected, expected)) # Не допускает разного номера в сохраняемом счётчике и паспорте текущей сборки.
self.assertEqual(saved["extra"], self.settings["extra"]) # Атомарная перезапись сохраняет посторонние поля и русский UTF-8 текст.
header = (generated / "firmware_identity.h").read_text(encoding="utf-8") # Проверяет константы, которые действительно подключаются к коду прошивки.
for part, value in zip(("MAJOR", "MINOR", "PATCH"), expected): # Контракт C и JSON должен совпадать по всем трём разрядам.
self.assertIn(f"#define FIRMWARE_VERSION_{part} {value}U", header) # Проверяет подготовленные для компилятора числовые определения версии.
self.assertEqual(json.loads((generated / "firmware_identity.json").read_text(encoding="utf-8"))["version"], expected) # Сопроводительный паспорт относится к тому же номеру.
def test_auto_version_is_not_incremented_by_postbuild(self): # EEPROM, Flash и упаковка одной попытки не должны резервировать дополнительные номера.
self.enable_auto_version() # Настраивает JSON-итератор настоящего проекта вместо ручного режима фикстуры.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated") # Единственный основной prebuild резервирует 1.1.3.
self.write_map(self.metadata) # Карта соответствует аппаратному профилю подготовленного паспорта.
saved = (self.project / "firmware_identity.json").read_bytes() # Фиксирует счётчик перед конверсией и публикацией.
with patch.object(release, "convert", self.converter()): # Подменяет только внешний конвертор; проверки встроенной версии и выпуск паспортов остаются настоящими.
image = self.invoke() # Полный postbuild должен опубликовать BIN текущей попытки без нового prebuild.
manifest = json.loads(image.with_suffix(".bin.manifest.json").read_text(encoding="utf-8")) # Читает паспорт реально выпущенного контрольного BIN.
self.assertEqual(manifest["version"], [1, 1, 3]) # Подписанная версия совпадает со словами внутри SCI8, проверенными production-процессом.
self.assertEqual((self.project / "firmware_identity.json").read_bytes(), saved) # Postbuild не меняет сохранённый источник номера.
def test_auto_version_failed_generation_reserves_number(self): # Следующая попытка не повторяет номер после ошибки формирования заголовка или будущей компиляции.
self.enable_auto_version() # Начинает с 1.1.2, не меняя рабочий счётчик настоящего проекта.
generator = prebuild.load_identity(VENDOR) # Загружает фактический генератор компилируемой зависимости для изолированной имитации отказа.
with patch.object(prebuild, "load_identity", return_value=generator), patch.object(generator, "write_atomic", side_effect=OSError("generated unavailable")): # Прерывает подготовку уже после атомарного сохранения номера 1.1.3.
with self.assertRaises(OSError): # Ошибка должна остановить вызывающую сборку, сохранив занятую версию.
prebuild.prepare(self.project, VENDOR, self.build / "generated") # Производственный prebuild не скрывает отказ создания выходов.
self.assertEqual(json.loads((self.project / "firmware_identity.json").read_text(encoding="utf-8"))["version"], [1, 1, 3]) # Неудачная попытка занимает свой номер, как в ветке 166.
self.assertEqual(prebuild.prepare(self.project, VENDOR, self.build / "generated")["version"], [1, 1, 4]) # Следующая подготовка использует новый номер вместо повторного 1.1.3.
def test_auto_version_rollover_and_exhaustion(self): # Пределы общего 12-словного контракта должны сохраняться после включения счётчика.
for initial, expected in (((1, 1, 999), [1, 2, 0]), ((1, 255, 999), [2, 0, 0])): # Patch переносится после 999, minor после 255; major также ограничен 255.
with self.subTest(initial=initial): # Различает два уровня переноса в отчёте проверки.
self.enable_auto_version(initial) # Устанавливает контрольный номер только во временном исходном JSON.
self.assertEqual(prebuild.prepare(self.project, VENDOR, self.build / "generated")["version"], expected) # Проверяет перенос через производственный prebuild, а не отдельную копию алгоритма.
self.enable_auto_version((255, 255, 999)) # Задаёт наибольшую представимую версию firmware-info v1.
original = (self.project / "firmware_identity.json").read_bytes() # Полное переполнение не должно обнулить или повредить счётчик.
with self.assertRaisesRegex(ValueError, "exhausted"): # Требует явного отказа вместо повторного использования версии 0.0.0.
prebuild.prepare(self.project, VENDOR, self.build / "generated") # Не допускает начало компиляции за пределами контракта версии.
self.assertEqual((self.project / "firmware_identity.json").read_bytes(), original) # Исходная максимальная версия остаётся доступной для диагностики.
def test_auto_version_failed_save_preserves_source(self): # Отказ фиксации счётчика должен оставлять исходный JSON целым.
self.enable_auto_version() # Включает автоматический режим только у временного проекта.
original = (self.project / "firmware_identity.json").read_bytes() # Сохраняет точные байты исходных настроек до ожидаемого отказа.
with patch.object(Path, "replace", side_effect=PermissionError("counter unavailable")): # Имитирует невозможность атомарно заменить JSON до генерации паспорта.
with self.assertRaises(PermissionError): # Не разрешает компиляцию с номером, который не удалось надёжно занять.
prebuild.prepare(self.project, VENDOR, self.build / "generated") # Вызывает настоящий порядок чтения профиля и резервирования версии.
self.assertEqual((self.project / "firmware_identity.json").read_bytes(), original) # Ошибка файловой системы не оставляет частично записанные настройки.
self.assertEqual(list(self.project.glob(".firmware-version-*")), []) # Собственный временный файл счётчика удаляется после неудачной замены.
@unittest.skipUnless((COMPILER / "bin/cl2000.exe").is_file(), "TI C2000 compiler is unavailable") # Сверяет реальную C-компиляцию при доступном установленном toolchain.
def test_auto_version_is_shared_with_can_and_flash(self): # Ручной интерфейс версии не должен оставлять CAN или Flash на старом номере после prebuild.
self.enable_auto_version() # Копирует production firmware_version.h, подключающий единственный generated-заголовок.
self.config.write_text("#define EEPROM_M95M02 1 /* Выбирает M95M02 для проверки включённого CAN A/B. */\n", encoding="utf-8") # Активирует аппаратную ветку, использующую номер прошивки в ProtoCAN.
(self.config.parent / "f28335_can_application_config.h").write_bytes((ROOT / "Source/Internal/Include/f28335_can_application_config.h").read_bytes()) # Проверяет реальную упаковку версии CAN с изолированной EEPROM-конфигурацией.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated") # Подготавливает единую версию 1.1.3 для всех вариантов текущей попытки.
source = self.build / "version_probe.c" # Малый исходник проверяет интерфейс версии без записи платы и без компиляции всего приложения.
source.write_text('#include "f28335_can_application_config.h" /* Подключает production-формулу версии ProtoCAN и generated-паспорт. */\n#if FIRMWARE_VERSION_MAJOR != 1 || FIRMWARE_VERSION_MINOR != 1 || FIRMWARE_VERSION_PATCH != 3 /* Требует номер этой попытки 1.1.3. */\n#error Version_does_not_match_prebuild /* Останавливает компиляцию при старом ручном заголовке. */\n#endif /* Завершает сверку трёх разрядов версии. */\ntypedef char can_version_matches[(F28335_CAN_BOOT_FIRMWARE_VERSION == 0x01010003UL) ? 1 : -1]; /* 0x01:major, 0x01:minor, 0x0003:patch повторяют 32-битный контракт ProtoCAN. */\n', encoding="utf-8") # Проверяет номер заголовка и поле FW настоящим компилятором C28x.
subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--abi=coffabi", "-c", "--include_path=" + str(self.config.parent), "--include_path=" + str(self.project), "--include_path=" + str(self.build / "generated"), "--output_file=" + str(self.build / "version_probe.obj"), str(source)], check=True, capture_output=True) # Реальная компиляция подтверждает отсутствие несовпадения CAN и паспорта.
import build_flash # Загружает реальный дополнительный сборщик, который не должен резервировать второй номер.
saved = (self.project / "firmware_identity.json").read_bytes() # Фиксирует счётчик перед проверкой Flash-конфигурации.
with patch.object(build_flash, "PROJECT", self.project), patch.object(build_flash, "configuration_paths", side_effect=RuntimeError("version accepted")): # Останавливает сборщик сразу после реальной проверки версии и EEPROM, до дорогостоящей компиляции приложения.
with self.assertRaisesRegex(RuntimeError, "version accepted"): # Положительный результат доказывает, что ручное чтение старого C-заголовка больше не применяется.
build_flash.build_flash(COMPILER, self.build, self.build / "flash.out") # Flash получает паспорт основной попытки и проходит проверку JSON-версии.
self.assertEqual((self.project / "firmware_identity.json").read_bytes(), saved) # Проверка и подготовка Flash не увеличивают счётчик повторно.
def test_success_for_both_eeprom_profiles(self): # Проверяет полноценные publish/archive/export для обеих физических ёмкостей.
for eeprom, limit in (("AT25256B", 32508), ("M95M02", 261884)): # Пределы включают исторический резерв 129 параметров и последнее свободное слово.
with self.subTest(eeprom=eeprom): # Различает ошибки аппаратных вариантов в отчёте тестов.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated", eeprom) # CLI-профиль также попадёт в паспорт до компиляции.
self.write_map(self.metadata) # Моделирует согласованный define выбранного линкера.
with patch.object(release, "convert", self.converter()): # Имитирует только внешний инструмент и использует настоящие проверки staged-комплекта.
image = self.invoke(eeprom=eeprom) # Успешный выпуск обязан завершить локальное и GUI-копирование.
manifest = json.loads(image.with_suffix(".bin.manifest.json").read_text(encoding="utf-8")) # Проверяет опубликованный, а не промежуточный паспорт.
self.assertEqual((manifest["eeprom"], manifest["max_image_bytes"], manifest["version"]), (eeprom, limit, [1, 1, 2])) # Связывает аппаратную ёмкость, границу и неизменённый номер версии.
self.assertTrue(any(self.export.rglob(image.name))) # Каталог GUI получает только проверенный архив текущей сборки.
def test_stale_embedded_identity(self): # Воспроизводит опасную подпись старого OUT новым manifest при правильном SHA.
stale = dict(self.metadata, version=[1, 1, 1]) # Единственным дефектом образа остаётся предыдущий номер встроенного паспорта.
self.assert_rejected_without_publication(self.converter(image_for(stale))) # Проверка содержимого обязана остановить публикацию до export.
def test_oversize_image(self): # Воспроизводит выход нового кода за начало сохраняемых параметров малой EEPROM.
self.assert_rejected_without_publication(self.converter(image_for(self.metadata) + bytes(32508))) # Даже корректно подписанный SHA большого файла не разрешает выпуск.
def enable_can(self): # Переводит только тестовую плату в профиль реального CAN-загрузчика M95M02.
self.config.write_text("#define EEPROM_M95M02 1 /* Тестовый профиль M95M02 с CAN A/B. */\n", encoding="utf-8") # Настоящий конфигурационный заголовок рабочей платы остаётся неизменным.
self.metadata = prebuild.prepare(self.project, VENDOR, self.build / "generated") # CAN-профиль должен попасть в паспорт до компиляции и последующей проверки выпуска.
self.write_map(self.metadata) # Согласует аппаратные символы и hooks с новой тестовой конфигурацией.
def test_can_bin_is_created_archived_and_exported_automatically(self): # Общий postbuild обязан выпускать CAN-комплект без отдельного ручного вызова конвертора.
self.enable_can() # Использует тот же явный профиль, который добавлен в проект настоящей 167.
with patch.object(release, "convert", self.converter()): # Подмена ограничена внешней конверсией; выпуск, проверки и экспорт выполняет production-код.
image = self.invoke() # Обычный защищённый вызов publish должен сам создать дополнительный CAN BIN.
can_image = image.parent.parent / "can" / (image.stem + "_can.bin") # CAN-вариант сохраняет артефакт и Build ID в собственной папке назначения рядом с eeprom.
self.assertEqual(can_image.read_bytes(), image.read_bytes()) # CAN использует тот же SCI8 RAM payload без перенумерации адресов или изменения контейнера.
manifest = IDENTITY.verify(can_image, can_image.with_suffix(".bin.manifest.json")) # Контрольная сумма и размер должны относиться к собственному имени CAN-файла.
self.assertEqual((manifest["transport"], manifest["protocol"], manifest["execution"], manifest["image_format"]), ("can", "ProtoCAN Boot", "RAM", "SCI8-RAM")) # CAN BIN явно отделён от обычного EEPROM и внутреннего Flash RS.
folder = IDENTITY.build_folder_name(self.metadata) # Использует единственный архив именно этой попытки компиляции.
for destination in (self.output / folder / "can", self.export / folder / "can"): # CAN BIN и sidecar сохраняют одну папку назначения в локальном архиве и штатном экспорте GUI.
self.assertEqual((destination / can_image.name).read_bytes(), can_image.read_bytes()) # Сравнивает полные байты, исключая подмену результата старым файлом.
IDENTITY.verify(destination / can_image.name, destination / (can_image.name + ".manifest.json")) # Проверяет реальные сопроводительные данные скопированного комплекта.
self.assertTrue((destination / (can_image.name + ".sha256")).is_file()) # Одного BIN без отдельного SHA sidecar недостаточно для полного выпуска.
def test_can_rejects_oversize_without_publishing_any_variant(self): # Предел EEPROM больше CAN-слота, поэтому CAN требует собственной проверки длины.
self.enable_can() # Выбирает физическую EEPROM на 256 КиБ и профиль A/B с меньшим логическим слотом.
payload = image_for(self.metadata)[:-2] + b"".join(block(0x8200 + index, [index]) for index in range(16320)) + bytes(2) # Валидные непересекающиеся RAM-блоки помещаются в EEPROM, но их SCI8-обрамление превышает CAN-лимит 130560 октетов.
probe = self.build / "oversize_ram.bin" # Отдельный временный образ доказывает пригодность таблицы для общего EEPROM/RAM-контракта.
probe.write_bytes(payload) # Сохраняет полный корректный SCI8 с большим числом коротких секций.
release.verify_embedded(probe, self.metadata) # Проверка обычного RAM должна пройти, чтобы отказ действительно относился к ограничениям CAN.
self.assert_rejected_without_publication(self.converter(payload)) # Отказ CAN не должен публиковать даже обычный RAM BIN из той же неуспешной попытки.
def test_can_rejects_missing_application_hooks(self): # Правильный RAM BIN старого приложения не обеспечивает READY/CONFIRM нового CAN-загрузчика.
self.enable_can() # Подготавливает согласованные паспорт и карту полноценной CAN-платы.
self.map_path.write_text(self.map_path.read_text(encoding="utf-8").replace("_F28335CanBoot_Ready", "_OldApplicationReady"), encoding="utf-8") # Удаляет обязательный READY из экспортированных символов, сохраняя EEPROM-профиль.
self.assert_rejected_without_publication(self.converter()) # Непригодное приложение не должно получить CAN-суффикс и попасть в GUI.
def test_can_rejects_more_than_64_sections(self): # Аппаратный parser имеет отдельный предел числа SCI8-блоков независимо от размера файла.
self.enable_can() # Активирует ограничения порта M95M02 A/B.
prefix = image_for(self.metadata)[:-2] # Сохраняет корректные заголовок, entry и паспорт, убирая лишь конечный терминатор.
payload = prefix + b"".join(block(0x8200 + index, [index]) for index in range(63)) + bytes(2) # Две исходные секции и 63 короткие дополнительные дают 65 непересекающихся блоков.
self.assert_rejected_without_publication(self.converter(payload)) # Проверка должна отклонить только CAN-несовместимое число секций до постоянного копирования.
def test_at25256_does_not_emit_can_variant(self): # Малый аппаратный вариант остаётся совместимым с прежней сборкой без обещания неподдерживаемого A/B.
with patch.object(release, "convert", self.converter()): # Оставляет настоящий постпроцесс и паспорт тестовой AT25256B.
image = self.invoke() # Обычный EEPROM-выпуск должен завершаться успешно без CAN-файла.
self.assertFalse((image.parent.parent / "can").exists()) # Папка CAN не должна обещать неподдержанную прошивку платы AT25256B.
def test_receipt_and_export_keep_purpose_paths(self): # Публикатор и CCS должны получать одинаковые проверенные пути вместо плоской смеси артефактов.
self.enable_can() # Включает реальный профиль M95M02 A/B с отдельным CAN BIN.
with patch.object(release, "convert", self.converter()): # Сохраняет настоящие проверки и архивирование, подменяя только TI-конверсию.
image = self.invoke() # Выпускает файлы в локальный каталог и экспорт SETGUI.
receipt = json.loads((self.output / "latest_build.json").read_text(encoding="utf-8")) # Читает точный указатель последней завершённой сборки.
self.assertEqual(image.parent.name, "eeprom") # Возвращаемый основной образ находится в папке внешней EEPROM.
self.assertEqual(receipt["manifest"], "eeprom/" + image.name + ".manifest.json") # Относительный путь остаётся переносимым между проектом CCS и SETGUI.
for root in (self.output, self.output / receipt["folder"], self.export / receipt["folder"]): # Проверяет текущую локальную раскладку CCS, её архив и экспортированный комплект SETGUI.
self.assertTrue((root / receipt["manifest"]).is_file()) # Относительный путь receipt разрешается как в текущих папках, так и в каждом архиве.
self.assertTrue((root / "firmware_identity.json").is_file()) # Общий паспорт остаётся в корне и связывает назначения одного выпуска.
self.assertFalse(list(root.glob("*.bin"))) # BIN не смешиваются в корне архива, выбранного для импорта.
def test_disabling_can_after_prebuild_requires_rebuild(self): # Удаление CAN-профиля не должно оставлять молча опубликованный файл по устаревшему паспорту.
self.enable_can() # Подготавливает реальный паспорт с включённым обязательным CAN-вариантом.
path = self.project / "firmware_build_profile.json" # Изменение ограничено декларативным профилем временного проекта теста.
policy = json.loads(path.read_text(encoding="utf-8")) # Сохраняет остальные RAM/EEPROM-настройки проекта.
del policy["can_boot_profile"] # Моделирует явное отключение CAN пользователем после уже завершённого prebuild.
path.write_text(json.dumps(policy), encoding="utf-8") # Не подменяет старые metadata и бинарный образ новым паспортом.
self.assert_rejected_without_publication(self.converter()) # Устаревшая конфигурация обязана остановить выпуск до конверсии и экспорта.
def test_truncated_image(self): # Воспроизводит неполную загрузочную секцию после сбоя конверсии или копирования.
self.assert_rejected_without_publication(self.converter(image_for(self.metadata)[:-5])) # Обрезает и часть данных, и терминатор, сохраняя заново вычисленный manifest.
def test_wrong_manifest_eeprom(self): # Проверяет требуемое различение аппаратных вариантов по полю manifest.eeprom.
self.assert_rejected_without_publication(self.converter(wrong_eeprom=True)) # Правильные BIN, версия и SHA не маскируют аппаратное несоответствие паспорта.
def test_flash_requires_protected_application_and_current_identity(self): # Проверяет паспорт текущей сборки внутри Flash-приложения, не затрагивающего постоянный сектор A.
from protocan.f28335_flash import descriptor # Использует действующий контракт FAB1, который проверяет production-парсер защищённого Flash.
entry_address = release.APP_DATA # Код приложения начинается после 16-словного описания FAB1 в секторе H.
header = b"\xaa\x08" + bytes(16) + struct.pack("<HH", entry_address >> 16, entry_address & 65535) # Точка входа SCI8 указывает на загруженный код приложения, а не аппаратный вектор ROM.
loaded = {entry_address: 0x761F, entry_address + 1: 0x8000} # Минимальная исполняемая секция содержит два контрольных слова приложения.
loaded.update((0x308000 + index, value) for index, value in enumerate(identity_words(self.metadata))) # Встроенные 12 слов содержат версию и Build ID текущего prebuild вне служебного заголовка.
application = block(entry_address, [0x761F, 0x8000]) + block(0x308000, identity_words(self.metadata)) # Создаёт непересекающиеся Flash-секции кода и неизменяемого паспорта.
boot = block(release.APP_START, struct.unpack("<16H", descriptor(entry_address, loaded))) # Описание FAB1 хранит границы, точку запуска и CRC точных слов приложения ниже сектора A.
body = boot + application # Полный Flash-поток включает единственное описание FAB1 и проверяемые данные приложения.
image = self.build / "flash.bin" # Изолирует проверки структуры от постоянных каталогов выпуска и GUI.
image.write_bytes(header + body + bytes(2)) # Проверяемая таблица не содержит запрещённого аппаратного перехода 0x33FFF6.
entry, loaded = release.verify_embedded(image, self.metadata, flash=True) # Вызывает production-парсер с отдельными ограничениями Flash и RS-буфера.
self.assertEqual((entry, loaded[entry_address]), (entry_address, 0x761F)) # Подтверждает реальную точку запуска и данные исполняемой секции приложения.
self.assertNotIn(0x33FFF6, loaded) # Постоянный аппаратный вход остаётся вне таблицы записи защищённого приложения.
failures = [image_for(self.metadata), header + application + bytes(2), header + body + boot + bytes(2)] # RAM-таблица, отсутствующий и дублированный FAB1 не разрешают Flash-выпуск.
failures += [header + body + block(address, [1, 2]) + bytes(2) for address in (0x2FFFFF, release.APP_STOP, 0x33FF80, 0x33FFF6, 0x33FFF8, 0x380400)] # Запрещает нижнюю границу, весь сектор A, аппаратный вход, CSM, пароли и OTP до стирания.
for payload in failures: # Все негативные сценарии проверяются настоящими границами production-парсера.
image.write_bytes(payload) # Корректный ключ/паспорт не должны маскировать запрещённые адреса либо аппаратный вход.
with self.assertRaises(ValueError): # Отказ является обязательным, даже если файл заново подписать корректным SHA.
release.verify_embedded(image, self.metadata, flash=True) # Проверяет конкретные Flash-условия независимо от общих RAM-тестов.
def test_wrong_linker_eeprom(self): # Проверяет случай define EEPROM_M95M02, переданного не всем этапам сборки.
self.write_map(dict(self.metadata, eeprom_bytes=262144, max_image_bytes=261884)) # MAP описывает большую EEPROM при AT25256B в prebuild.
self.assert_rejected_without_publication(self.converter()) # Отказ обязан наступить до конверсии и публикации.
def test_changed_profile_after_prebuild(self): # Проверяет изменение заголовка аппаратной конфигурации между prebuild и выпуском.
self.config.write_text("#define EEPROM_M95M02 1 /* После prebuild установили другой аппаратный профиль. */\n", encoding="utf-8") # Сохраняет прежние metadata и карту для обнаружения устаревшего комплекта.
self.assert_rejected_without_publication(self.converter()) # Требует повторной подготовки паспорта и пересборки вместо тихой смены аппаратного типа.
def test_required_flash_variant_cannot_be_omitted(self): # Проверяет, что неполная попытка Flash-build не публикует даже корректный новый RAM BIN.
(self.project / "build_flash.py").write_text("# Тестовый признак обязательного отдельного Flash-варианта проекта.\n", encoding="utf-8") # Повторяет соглашение автоматического обнаружения общего postcompile.
self.assert_rejected_without_publication(self.converter()) # Отсутствующие Flash OUT/SCI8 должны остановить выпуск до output и GUI.
@unittest.skipUnless((COMPILER / "bin/cl2000.exe").is_file(), "TI C2000 compiler is unavailable") # Настоящий препроцессор TI проверяет global compiler override без изменения прошивки.
def test_compiler_only_eeprom_override(self): # Закрывает случай M95M02-компилятора при паспорте и линкере AT25256B.
self.config.write_text("#ifndef EEPROM_M95M02 /* Разрешает штатное переопределение через define компилятора. */\n#define EEPROM_M95M02 0 /* Аппаратный профиль по умолчанию совпадает с prebuild. */\n#endif /* Завершает конфигурацию тестовой платы. */\n", encoding="utf-8") # Повторяет реальную семантику eeprom_config.h, при которой ошибка раньше оставалась незамеченной.
source = self.build / "profile_guard.c" # Контрольный C-модуль включает тот же generated-заголовок, что shared_ump.c.
source.write_text('#include "firmware_identity.h" /* Заставляет компилятор проверить аппаратный профиль паспорта. */\n', encoding="utf-8") # Не добавляет исполняемые данные в проверяемый BIN.
result = subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--preproc_only", "--define=EEPROM_M95M02=1", "--include_path=" + str(self.config.parent), "--include_path=" + str(self.build / "generated"), str(source)], cwd=self.build, text=True, capture_output=True) # Намеренно передаёт M95M02 только компилятору, оставляя паспорт AT25256B.
self.assertNotEqual(result.returncode, 0) # Несогласованный define обязан останавливать компиляцию до появления OUT.
self.assertIn("EEPROM_profile_differs_from_prebuild", result.stdout + result.stderr) # Отказ вызван профильной защитой, а не посторонней ошибкой установленного TI.
@unittest.skipUnless((COMPILER / "bin/cl2000.exe").is_file(), "TI C2000 compiler is unavailable") # Реальный конвертор проверяется на установленном toolchain; host-проверки обязательны в любом случае.
def test_real_ti_postcompile(self): # Проверяет общий BAT, TI COFF, hex2000, staged-архив и защищённую публикацию без полной прошивки платы.
assembly = self.build / "fixture.asm" # Минимальная таблица содержит реальную исполняемую секцию и настоящий встроенный паспорт.
assembly.write_text(' .sect ".text" ; Тестовая секция исполняется из внутренней RAM.\n .global _fixture_entry ; Экспортирует точку входа контрольного COFF.\n_fixture_entry: ; Начало двухсловного перехода в загруженном RAM-блоке.\n LB _fixture_entry ; Не завершает тестовый код при случайном запуске под отладчиком.\n .sect ".econst" ; Неизменяемые слова паспорта загружаются отдельным блоком.\n .word ' + ','.join(str(value) for value in identity_words(self.metadata)) + ' ; Точные 12 слов текущего prebuild.\n .end ; Завершает минимальный модуль C28x.\n', encoding="utf-8") # Создаёт настоящий исходник TI с русскими комментариями каждой строки.
linker = self.build / "fixture.cmd" # Карта фикстура соответствует тем RAM-диапазонам, которые разрешает производственный проверяющий код.
linker.write_text('MEMORY { PAGE 0: CODE: origin=0x8000,length=0x100 DATA: origin=0x8100,length=0x100 } /* Отдельные RAM-области тестового кода и паспорта. */\nSECTIONS { .text: > CODE PAGE=0 .econst: > DATA PAGE=0 } /* Создаёт два полных непересекающихся блока SCI8. */\n' + f'_EEPROM_TOTAL_BYTES={self.metadata["eeprom_bytes"]}; /* Физическая ёмкость выбранной EEPROM в октетах. */\n_EEPROM_FIRMWARE_LIMIT_BYTES={self.metadata["max_image_bytes"]}; /* Конец области прошивки до параметров, в октетах. */\n_EEPROM_PARAMS_START_WORD={self.metadata["max_image_bytes"] // 2}; /* Адрес первого слова сохранённых параметров. */\n', encoding="utf-8") # Абсолютные символы проходят ту же проверку, что F28335.cmd настоящего проекта.
subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--abi=coffabi", "-c", "--output_file=" + str(self.build / "fixture.obj"), str(assembly)], check=True) # Получает настоящий объект TI без аппаратного программирования.
subprocess.run([str(COMPILER / "bin/cl2000.exe"), "-v28", "--abi=coffabi", "-z", "--entry_point=_fixture_entry", "-m" + str(self.map_path), "-o", str(self.build / "application.out"), str(self.build / "fixture.obj"), str(linker)], check=True) # Линкует настоящий COFF и абсолютную карту выбранного EEPROM.
with self.assertRaises(ValueError): # Настоящий RAM COFF нельзя выдать за пригодный JTAG Flash OUT только из-за одинакового расширения.
release.verify_flash_coff(self.build / "application.out", COMPILER) # Проверяет отказ на фактических секциях TI вместо поддельной тестовой MAP.
image = self.invoke() # Запускает реальный общий BAT и hex2000, сохраняя обязательные производственные проверки и публикацию.
release.verify_embedded(image, self.metadata) # Дополнительно проверяет фактические байты, опубликованные после конверсии TI.
release.verify_package(image, self.metadata) # Подтверждает совпадение аппаратного manifest и контрольной суммы конечного файла.
if __name__ == "__main__": # Позволяет запускать всю проверку обычной командой Python из корня проекта.
unittest.main(verbosity=2) # Каждый отказный сценарий явно виден в отчёте без зависимости от pytest.