Убрать повторное увеличение версии из проектной обёртки. Проверять прямое подключение templates и вызов через обёртку в CCS. Сохранить текущий номер прошивки 1.0.5 и уточнить документацию.
179 lines
31 KiB
Python
179 lines
31 KiB
Python
"""Проверяет перенос трёх файлов сборки, итератор JSON и запрет выпуска неверного BIN.""" # Не меняет рабочую версию или файлы прошивки пользователя.
|
|
import hashlib # Создаёт независимый эталон SHA-256 для проверяемого транспортного файла.
|
|
import importlib.util # Загружает production-сценарий без запуска его CLI.
|
|
import json # Готовит конфигурацию другого изделия и проверяет сохраняемый счётчик.
|
|
import os # Задаёт относительный путь к templates для временного проекта на том же диске.
|
|
from pathlib import Path # Находит файлы проекта и изолирует пути с пробелами.
|
|
import shutil # Копирует только три файла, необходимые для подключения сборки.
|
|
import struct # Формирует независимый минимальный SCI8 с известным паспортом.
|
|
import subprocess # Запускает настоящий BAT из постороннего рабочего каталога.
|
|
import tempfile # Удаляет только временные файлы проверяемого проекта.
|
|
import unittest # Проверяет успешные сборки, переполнение и ошибки выпуска.
|
|
from unittest.mock import patch # Подменяет внешнюю конверсию для воспроизведения неверных результатов.
|
|
import xml.etree.ElementTree as ET # Читает реальные команды Debug и Release из настроек CCS.
|
|
ROOT = Path(__file__).resolve().parents[2] # Использует репозиторий только для поиска переносимых исходников и библиотеки.
|
|
PROJECT = ROOT / "UKSSTMS320F28335" # Здесь лежат три копируемых файла подключения сборки.
|
|
TEMPLATES = ROOT.parent / "newProject/templates" # Настоящая общая библиотека используется тестами prebuild без копирования реализации.
|
|
spec = importlib.util.spec_from_file_location("portable_build", PROJECT / "build_identity.py") # Проверяет тот же код, который запускает BAT проекта.
|
|
builder = importlib.util.module_from_spec(spec) # Создаёт отдельный импорт без изменения версии.
|
|
spec.loader.exec_module(builder) # Загружает реальные функции счётчика и проверки выпуска.
|
|
|
|
|
|
class BuildStepsTest(unittest.TestCase): # Проверяет контракт переноса независимо от названия текущего изделия.
|
|
def setUp(self): # Создаёт другой проект только из трёх переносимых файлов.
|
|
self.temporary = tempfile.TemporaryDirectory(prefix="portable build ", dir=ROOT) # Тот же диск позволяет проверить относительный путь к templates, а пробелы проверяют кавычки BAT.
|
|
self.addCleanup(self.temporary.cleanup) # Удаляет только собственные временные данные даже при отказе проверки.
|
|
self.project = Path(self.temporary.name) / "another controller" # Имя временного проекта отличается от имени production-проекта.
|
|
self.project.mkdir() # Создаёт каталог назначения до копирования переносимых файлов.
|
|
for name in ("firmware_build.bat", "build_identity.py", "firmware_identity.json"): # Никакие вспомогательные заголовки версии или prebuild-скрипты не требуются.
|
|
shutil.copy2(PROJECT / name, self.project / name) # Копирует исходные сценарии без исправления их кода.
|
|
self.config = self.project / "firmware_identity.json" # Единственный изменяемый файл настройки переносимого подключения.
|
|
self.settings = {"product": "Portable-Test-Controller", "target": "TMS320F28335", "version": [1, 0, 0], "increment_version": True, "templates": os.path.relpath(TEMPLATES, self.project), "max_image_bytes": 64, "copy_to": "../images copy"} # Предел 64 байта относится только к минимальному тестовому BIN, а путь копирования проверяет разрешение от JSON.
|
|
self.save_settings() # Записывает другое изделие и размещение зависимости без изменения Python или BAT.
|
|
self.build_dir = self.project / "Debug with spaces" # Все generated и firmware отделены от пользовательских результатов.
|
|
self.metadata = {"product": "Portable-Test-Controller", "target": "TMS320F28335", "version": [1, 0, 0], "build_id": "1234abcd", "built_at_utc": "2026-10-17T09:00:00+00:00"} # Независимый паспорт для сценариев конверсии и отказов.
|
|
|
|
def save_settings(self): # Позволяет каждому сценарию явно задать входную конфигурацию.
|
|
self.config.write_text(json.dumps(self.settings), encoding="utf-8") # Хранит проверочные настройки в требуемой кодировке.
|
|
|
|
def prepare_metadata(self): # Готовит результат prebuild без изменения рабочего проекта.
|
|
generated = self.build_dir / "generated" # Использует стандартный путь, который ожидает production-postbuild.
|
|
generated.mkdir(parents=True) # Создаёт изолированный контекст компиляции.
|
|
(generated / "firmware_identity.json").write_text(json.dumps(self.metadata), encoding="utf-8") # Фиксирует паспорт компиляции независимо от поведения проверяемого кода.
|
|
|
|
def image_bytes(self, patch_number=0): # Создаёт эталон SCI8 без использования production-упаковщика.
|
|
words = [1, 1, 0, patch_number, 2026, 0x0A11, 0x0900, 0, 0x3132, 0x3334, 0x6162, 0x6364] # Двенадцать слов задают контракт, версию, UTC и строку 1234abcd в регистровом порядке.
|
|
return b"\xaa\x08" + bytes(20) + struct.pack("<HHH12HH", 12, 0, 0x8000, *words, 0) # Один блок из 12 слов по адресу 0x8000 и нулевой терминатор занимают 54 байта.
|
|
|
|
def convert(self, command, check): # Имитирует только внешний инструмент, оставляя настоящими все проверки выпуска.
|
|
self.assertTrue(check) # Ошибки общего BAT обязаны останавливать вызывающий postbuild.
|
|
self.assertEqual(Path(command[4]), TEMPLATES / "tools/firmware-build/ccs/postcompile.bat") # Путь к общей зависимости разрешается из настроек нового проекта.
|
|
stage = Path(command[-1]) # Результаты должны оставаться во временном каталоге до проверки паспорта.
|
|
image = stage / (Path(command[8]).name + "_1234abcd.bin") # Имя берётся из переданного artifact, а не из имени исходного изделия.
|
|
data = self.image_bytes() # Возвращает минимальный корректный паспорт текущего тестового проекта.
|
|
image.write_bytes(data) # Создаёт только результат этого вызова конвертора.
|
|
digest = hashlib.sha256(data).hexdigest() # Вычисляет независимую контрольную сумму ожидаемых байтов.
|
|
manifest = dict(self.metadata, file=image.name, size_bytes=len(data), sha256=digest) # Связывает паспорт, имя и размер в стандартном manifest templates.
|
|
image.with_suffix(".bin.manifest.json").write_text(json.dumps(manifest), encoding="utf-8") # Готовит реальный вход проверки контрольных файлов.
|
|
image.with_suffix(".bin.sha256").write_text(f"{digest} {image.name}\n", encoding="utf-8") # Сохраняет стандартную строку SHA-256 с двумя пробелами.
|
|
(stage / (Path(command[8]).name + ".hex")).write_text("HEX", encoding="utf-8") # Имитирует сопутствующий HEX, не проверяемый как самостоятельный транспортный файл.
|
|
(stage / (Path(command[8]).name + ".hex.map")).write_text("MAP", encoding="utf-8") # Проверяет перенос карты под именем произвольного artifact.
|
|
archive = stage / "1234abcd_2026-10-17_12-00-00_UTC+3" # Проверяет сохранение папки архива текущей сборки.
|
|
archive.mkdir() # Архив создаёт внешний общий postcompile до проверки и публикации.
|
|
shutil.copy2(image, archive / image.name) # Даёт независимый наблюдаемый файл для проверки переноса архива.
|
|
|
|
def test_real_bat_portable_prebuild_and_iterator(self): # Проверяет BAT → Python → общий precompile из другого рабочего каталога.
|
|
for expected in ([1, 0, 1], [1, 0, 2]): # Последовательные попытки должны сохранять и увеличивать номер в JSON.
|
|
subprocess.run(["cmd.exe", "/d", "/c", "call", str(self.project / "firmware_build.bat"), "prepare", "--build-dir", str(self.build_dir)], cwd=ROOT, check=True, capture_output=True) # Копии трёх файлов достаточно для фактической генерации с пробелами в путях.
|
|
settings = json.loads(self.config.read_text(encoding="utf-8")) # Читает сохраняемый источник следующего номера.
|
|
metadata = json.loads((self.build_dir / "generated/firmware_identity.json").read_text(encoding="utf-8")) # Читает паспорт, который затем использует postcompile.
|
|
self.assertEqual(settings["version"], expected) # Проверяет сохранение счётчика для следующей сборки и после Clean.
|
|
self.assertEqual(metadata["version"], expected) # Не допускает расхождение номера в настройках и метаданных компиляции.
|
|
self.assertEqual(metadata["product"], "Portable-Test-Controller") # Не допускает зашитое имя исходного изделия в переносимых сценариях.
|
|
self.assertIn(f"#define FIRMWARE_VERSION_PATCH {expected[2]}U", (self.build_dir / "generated/firmware_identity.h").read_text(encoding="utf-8")) # Проверяет номер также в заголовке, включаемом компилятором TI.
|
|
|
|
def test_disabled_iterator_keeps_config_unchanged(self): # Общая схема с ручной версией остаётся доступной одной настройкой.
|
|
self.settings["increment_version"] = False # Отключает итератор без исправления BAT или Python.
|
|
self.save_settings() # Передаёт выбранный режим через тот же JSON.
|
|
previous = self.config.read_bytes() # Сохраняет исходные байты для проверки отсутствия записи счётчика.
|
|
self.assertEqual(builder.prepare(self.config, self.build_dir)["version"], [1, 0, 0]) # Настоящий общий генератор должен принять текущую версию.
|
|
self.assertEqual(self.config.read_bytes(), previous) # Ручной режим не переформатирует и не изменяет настройки.
|
|
|
|
def test_carry_and_exhaustion(self): # Проверяет обе границы переноса и запрет переполнения полного номера.
|
|
for previous, expected in (([1, 9, 999], [1, 10, 0]), ([1, 255, 999], [2, 0, 0])): # Эталонные значения заданы независимо от production-счётчика.
|
|
self.settings["version"] = list(previous) # Передаёт граничный номер общему итератору через штатный JSON проекта.
|
|
self.save_settings() # Сохраняет исходный номер только во временном проекте проверки.
|
|
self.assertEqual(builder.prepare(self.config, self.build_dir)["version"], expected) # Проверяет перенос через реальную цепочку проектный BAT → общий precompile.
|
|
self.settings["version"] = [255, 255, 999] # Задаёт последний допустимый номер контракта.
|
|
self.save_settings() # Устанавливает границу в сохраняемой конфигурации.
|
|
previous = self.config.read_bytes() # Полное переполнение не должно портить исходный JSON.
|
|
with self.assertRaises(subprocess.CalledProcessError): builder.prepare(self.config, self.build_dir) # Общий precompile отклоняет переполнение и передаёт проектной обёртке ненулевой код процесса.
|
|
self.assertEqual(self.config.read_bytes(), previous) # Проверяет отсутствие сброса версии к нулю или частичной записи.
|
|
|
|
def test_failure_consumes_number_and_bat_returns_error(self): # Сохраняет правило занятых номеров при неудачной сборке и передачу ошибки CCS.
|
|
self.build_dir.mkdir() # Готовит каталог только временного проекта для воспроизведения отказа записи заголовка.
|
|
(self.build_dir / "generated").write_text("blocked", encoding="utf-8") # Файл вместо каталога generated вызывает отказ общего генератора после резервирования номера.
|
|
result = subprocess.run(["cmd.exe", "/d", "/c", "call", str(self.project / "firmware_build.bat"), "prepare", "--build-dir", str(self.build_dir)], cwd=ROOT, capture_output=True) # Реальный BAT проверяет передачу ошибки templates без подмены процесса, владеющего счётчиком.
|
|
self.assertNotEqual(result.returncode, 0) # Неудачная генерация заголовка обязана остановить pre-build.
|
|
self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], [1, 0, 1]) # Следующая попытка не должна повторять номер неудачной сборки.
|
|
result = subprocess.run(["cmd.exe", "/d", "/c", "call", str(self.project / "firmware_build.bat"), "build"], cwd=ROOT, capture_output=True) # Намеренно пропускает обязательные аргументы CLI через настоящий BAT.
|
|
self.assertNotEqual(result.returncode, 0) # BAT не должен маскировать ошибку Python успешным завершением.
|
|
|
|
def test_portable_postbuild_copies_only_verified_package(self): # Проверяет выпуск другого изделия и другого имени OUT без изменения сценариев.
|
|
self.prepare_metadata() # Готовит независимый паспорт текущей компиляции.
|
|
with patch.object(builder.subprocess, "run", side_effect=self.convert): # Подменяет только конвертор, оставляя проверки и копирование настоящими.
|
|
image = builder.build(self.project / "compiler", self.build_dir, "../external output/OtherFirmware", self.config) # Относительный путь OUT и его имя отличаются от production-проекта.
|
|
self.assertEqual(image.read_bytes(), self.image_bytes()) # Постоянный firmware содержит точные байты текущего проверенного BIN.
|
|
destination = self.project.parent / "images copy" # Каталог копирования должен отсчитываться от JSON, а не от cwd или BuildDirectory.
|
|
self.assertEqual((destination / image.name).read_bytes(), self.image_bytes()) # Проверяет содержимое дополнительной копии нового изделия.
|
|
self.assertTrue((destination / "OtherFirmware.map").is_file()) # Имя карты также следует artifact без зашитого имени прошивки.
|
|
self.assertTrue((image.parent / "1234abcd_2026-10-17_12-00-00_UTC+3" / image.name).is_file()) # Архив текущей сборки переносится вместе с плоскими файлами.
|
|
|
|
def test_stale_or_oversize_image_is_not_published(self): # Сохраняет защиту EEPROM и запрет нового manifest поверх старой прошивки.
|
|
self.prepare_metadata() # Готовит паспорт, с которым обязана совпасть конверсия.
|
|
for stale, limit in ((True, 64), (False, 53)): # Первый вариант подменяет версию, второй превышает проектный предел ровно на байт.
|
|
with self.subTest(stale=stale): # Показывает конкретную нарушенную защиту при ошибке.
|
|
self.settings["max_image_bytes"] = limit # Меняет только ограничение платы в JSON.
|
|
self.save_settings() # Передаёт ограничение реальному postbuild.
|
|
def convert(command, check): # Создаёт ожидаемые результаты внешнего инструмента для выбранного дефекта.
|
|
self.convert(command, check) # Готовит полноценный комплект, который затем намеренно повреждается.
|
|
if stale: (Path(command[-1]) / "OtherFirmware_1234abcd.bin").write_bytes(self.image_bytes(1)) # Встраивает patch 1 вместо patch 0 текущего prebuild.
|
|
with patch.object(builder.subprocess, "run", side_effect=convert): # Реальные проверки должны отклонить результат до публикации.
|
|
with self.assertRaises(ValueError): builder.build(self.project / "compiler", self.build_dir, "OtherFirmware", self.config) # Успешный внешний конвертор не разрешает неверный выпуск.
|
|
self.assertFalse((self.build_dir / "firmware").exists()) # Даже постоянный архив не должен появляться при ошибке.
|
|
self.assertFalse((self.project.parent / "images copy").exists()) # Дополнительная папка также не получает непроверенный BIN.
|
|
|
|
def test_changed_config_and_bad_hash_are_rejected(self): # Проверяет рассогласование настроек и повреждение контрольного комплекта.
|
|
self.prepare_metadata() # Фиксирует паспорт исходной конфигурации.
|
|
self.settings["product"] = "Another-Product" # Изменение изделия после компиляции требует нового prebuild.
|
|
self.save_settings() # Сохраняет намеренно несовпадающую конфигурацию.
|
|
with patch.object(builder.subprocess, "run") as convert: # До проверки настроек внешняя конверсия запускаться не должна.
|
|
with self.assertRaises(ValueError): builder.build(self.project / "compiler", self.build_dir, "OtherFirmware", self.config) # Не принимает generated от другого изделия.
|
|
convert.assert_not_called() # Подтверждает отказ до конверсии или записи результатов.
|
|
self.settings["product"] = self.metadata["product"] # Восстанавливает совпадающий паспорт для независимой проверки SHA-256.
|
|
self.save_settings() # Сохраняет корректные входные настройки.
|
|
def bad_hash(command, check): # Имитирует повреждённый sidecar при корректном паспорте BIN.
|
|
self.convert(command, check) # Создаёт корректный комплект общего конвертора.
|
|
(Path(command[-1]) / "OtherFirmware_1234abcd.bin.sha256").write_text("broken", encoding="utf-8") # Подменяет только контрольную сумму, не трогая BIN.
|
|
with patch.object(builder.subprocess, "run", side_effect=bad_hash): # Проверку контрольного файла выполняет production-код.
|
|
with self.assertRaises(ValueError): builder.build(self.project / "compiler", self.build_dir, "OtherFirmware", self.config) # Выпуск обязан остановиться даже при правильном встроенном паспорте.
|
|
self.assertFalse((self.build_dir / "firmware").exists()) # Повреждённый комплект не должен попадать в постоянный выпуск.
|
|
|
|
@unittest.skipUnless(os.environ.get("C2000_COMPILER"), "Set C2000_COMPILER to run real TI conversion") # Реальная конверсия требует установленный toolchain, остальные проверки доступны без него.
|
|
def test_real_ti_compile_and_bat_postbuild(self): # Проверяет скопированные BAT и Python с настоящим COFF другого изделия.
|
|
compiler = Path(os.environ["C2000_COMPILER"]) / "bin/cl2000.exe" # Использует явно выбранную тестом установку без поиска версии в production-коде.
|
|
self.settings["max_image_bytes"] = 4096 # Четырёх килобайт достаточно для минимального проверочного COFF, не задаёт предел настоящей платы.
|
|
self.save_settings() # Передаёт тестовый предел тому же JSON переносимого проекта.
|
|
metadata = builder.prepare(self.config, self.build_dir) # Настоящий precompile создаёт паспорт для последующей компиляции TI.
|
|
source = self.project / "fixture.c" # Минимальный C-файл не зависит от исходников или библиотек текущей платы.
|
|
source.write_text('#include "firmware_identity.h" /* Подключает константы именно текущего prebuild. */\nconst unsigned int identity[12] = {1U, FIRMWARE_VERSION_MAJOR, FIRMWARE_VERSION_MINOR, FIRMWARE_VERSION_PATCH, FI_YEAR, FI_MONTH * 256U + FI_DAY, FI_HOUR * 256U + FI_MINUTE, FI_SECOND, FI_ID_0 * 256U + FI_ID_1, FI_ID_2 * 256U + FI_ID_3, FI_ID_4 * 256U + FI_ID_5, FI_ID_6 * 256U + FI_ID_7}; /* Формирует двенадцать регистров независимым C-инициализатором. */\nvolatile unsigned int probe; void entry(void) { for (;;) { probe = identity[probe & 7U]; } } /* Удерживает паспорт обращением по изменяемому индексу 0..7 и даёт линкеру точку входа без аппаратных зависимостей. */\n', encoding="utf-8") # Каждая строка тестового C содержит объяснение на русском языке.
|
|
linker = self.project / "fixture.cmd" # Собственная карта памяти отделяет интеграционный тест от F28335.cmd изделия.
|
|
linker.write_text('MEMORY { RAM : origin = 0x8000, length = 0x1000 } /* Отводит 4096 слов RAM под минимальный пример C28x. */\nSECTIONS { .text > RAM .econst > RAM .const > RAM .cinit > RAM .bss > RAM .ebss > RAM .stack > RAM .data > RAM } /* Размещает все секции примера в загрузочном блоке RAM. */\n', encoding="utf-8") # Проверяет транспорт SCI8, не меняя размещение пользовательской прошивки.
|
|
subprocess.run([str(compiler), "-v28", "--abi=coffabi", "--compile_only", "--include_path=" + str(self.build_dir / "generated"), "--obj_directory=" + str(self.build_dir), str(source)], check=True) # Компилирует настоящий паспорт из generated в объект C28x COFF.
|
|
artifact = self.project / "external output/PortableFirmware" # Полный путь OUT с пробелами проверяет стандартный макрос BuildArtifactFileBaseName.
|
|
artifact.parent.mkdir() # Выходной OUT специально находится за пределами BuildDirectory.
|
|
subprocess.run([str(compiler), "-v28", "--abi=coffabi", "--run_linker", "--rom_model", "--entry_point=_entry", "--output_file=" + str(artifact) + ".out", "--map_file=" + str(artifact) + ".map", str(self.build_dir / "fixture.obj"), str(linker)], check=True) # Линкует минимальный пример без библиотек конкретной платы и сохраняет секцию паспорта.
|
|
result = subprocess.run(["cmd.exe", "/d", "/c", "call", str(self.project / "firmware_build.bat"), "build", "--compiler", str(compiler.parents[1]), "--build-dir", str(self.build_dir), "--artifact", str(artifact)], cwd=ROOT, capture_output=True) # Полностью проходит BAT → Python → общий postcompile → hex2000 → проверки → копирование.
|
|
self.assertEqual(result.returncode, 0, repr(result.stdout) + repr(result.stderr)) # При ошибке показывает фактический вывод общей конверсии без потери диагностики.
|
|
image = self.build_dir / "firmware" / (artifact.name + "_" + metadata["build_id"] + ".bin") # Выбирает точный результат фактического prebuild вместо поиска старого BIN.
|
|
builder.verify_embedded(image, metadata, 4096) # Проверяет реальную транспортную таблицу и встроенные слова паспорта.
|
|
builder.verify_package(self.project.parent / "images copy" / image.name, metadata) # Проверяет SHA-256 и метаданные дополнительной копии.
|
|
self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], [1, 0, 1]) # Postbuild не увеличивает номер повторно после prebuild.
|
|
|
|
def test_ccs_prebuild_increments_in_both_configurations(self): # Проверяет фактический итератор и при прямом подключении templates, и через проектную обёртку CCS.
|
|
configurations = ET.parse(PROJECT / ".cproject").findall("./storageModule/cconfiguration/storageModule/configuration") # Проверяет реальные настройки проекта.
|
|
self.assertEqual({item.get("name") for item in configurations}, {"Debug", "Release"}) # Требует обнаружения обеих конфигураций.
|
|
self.settings["version"] = [1, 0, 4] # Воспроизводит рабочий номер, на котором прямое подключение templates перестало увеличивать версию.
|
|
self.save_settings() # Не меняет настоящий JSON и не занимает номера пользовательских прошивок.
|
|
for configuration, expected in zip(configurations, ([1, 0, 5], [1, 0, 6])): # Два запуска с одним JSON должны увеличивать номер ровно по одному разу.
|
|
build_dir = self.build_dir / configuration.get("name") # Каждая конфигурация получает собственные generated, как при сборке в IDE.
|
|
command = configuration.get("prebuildStep").rstrip(";").replace("${PROJECT_LOC}", str(self.project)).replace("${BuildDirectory}", str(build_dir)) # Подставляет макросы CCS и удаляет завершающий разделитель списка команд IDE.
|
|
result = subprocess.run(command, cwd=ROOT, capture_output=True) # Выполняет команду из настоящего XML через cmd с путями, содержащими пробелы.
|
|
self.assertEqual(result.returncode, 0, repr(result.stdout) + repr(result.stderr)) # Показывает диагностику фактического BAT, если подключение templates неработоспособно.
|
|
self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], expected) # Проверяет сохраняемый счётчик после прямого вызова и обёртки без двойного увеличения.
|
|
self.assertEqual(json.loads((build_dir / "generated/firmware_identity.json").read_text(encoding="utf-8"))["version"], expected) # Паспорт компиляции обязан содержать тот же номер, который зарезервирован в JSON.
|
|
self.assertIn(f"#define FIRMWARE_VERSION_PATCH {expected[2]}U", (build_dir / "generated/firmware_identity.h").read_text(encoding="utf-8")) # Проверяет, что компилятор получит обновлённую версию, а не только новый manifest.
|
|
|
|
|
|
if __name__ == "__main__": unittest.main() # Любое нарушение версии, переносимости или защиты BIN возвращает ненулевой код.
|