diff --git a/c/firmware-info/tests/test_version_iterator.py b/c/firmware-info/tests/test_version_iterator.py new file mode 100644 index 0000000..deb7cc8 --- /dev/null +++ b/c/firmware-info/tests/test_version_iterator.py @@ -0,0 +1,83 @@ +"""Проверяет общий сохраняемый итератор версии до компиляции CCS, Keil и CLI.""" # Запуск: python -m unittest discover -s c/firmware-info/tests -p test_version_iterator.py. +import importlib.util # Загружает тот же генератор, который вызывается штатными BAT templates. +import json # Проверяет исходный JSON и метаданные после резервирования версии. +from pathlib import Path # Изолирует проверочные настройки и generated во временном каталоге. +import tempfile # Удаляет только собственные файлы после каждой проверки. +import unittest # Проверяет последовательность номеров, ручной режим и отказы записи. +from unittest.mock import patch # Воспроизводит ошибки файловой системы без изменения прав пользователя. +TOOL = Path(__file__).resolve().parents[1] / "tools/identity.py" # Находит общую реализацию относительно файла теста. +spec = importlib.util.spec_from_file_location("shared_iterator", TOOL) # Не импортирует проектные обёртки или другой генератор из PATH. +identity = importlib.util.module_from_spec(spec) # Создаёт отдельный экземпляр модуля для изолированной подмены отказов. +spec.loader.exec_module(identity) # Загружает функции без выполнения CLI и без изменения рабочих версий. + + +class VersionIteratorTests(unittest.TestCase): # Проверяет поведение общего prepare независимо от IDE. + def setUp(self): # Готовит настройки другого изделия только во временном каталоге. + temporary = tempfile.TemporaryDirectory(prefix="shared iterator ") # Пробелы в пути не должны влиять на атомарную запись настроек. + self.addCleanup(temporary.cleanup) # Удаляет временные файлы даже после ожидаемого отказа генерации. + self.root = Path(temporary.name) # Ограничивает все изменения проверки собственным каталогом. + self.config = self.root / "config.json" # Отделяет исходную настройку от generated/firmware_identity.json. + self.output = self.root / "generated" # Моделирует отдельный каталог текущей конфигурации сборки. + self.settings = {"product": "Другое изделие", "target": "host", "version": [1, 0, 4], "increment_version": True, "extra": {"keep": "настройка"}} # Дополнительное поле проверяет сохранность проектных настроек в UTF-8. + self.save() # Устанавливает номер, на котором обнаружилась остановка прямого подключения. + + def save(self): # Сохраняет выбранные входные настройки для текущего сценария. + self.config.write_text(json.dumps(self.settings, ensure_ascii=False), encoding="utf-8") # Не использует рабочий счётчик какого-либо проекта. + + def prepare(self): # Запускает публичный общий API с детерминированным состоянием Git. + with patch.object(identity, "git_state", return_value={"commit": None, "dirty": None}): # Доступ к Git не влияет на проверку счётчика. + return identity.prepare(self.root, self.root, self.config, self.output) # Вызывает тот же prepare, что CCS, Keil и CLI. + + def test_sequence_and_matching_outputs(self): # Воспроизводит увеличение после 1.0.4 и отсутствие повторного номера. + for expected in ([1, 0, 5], [1, 0, 6]): # Каждый общий pre-build должен занимать ровно один номер. + metadata = self.prepare() # Генерирует реальный заголовок и JSON после атомарного резервирования. + settings = json.loads(self.config.read_text(encoding="utf-8")) # Читает сохранённый источник следующей версии. + self.assertEqual(settings["version"], expected) # Проверяет увеличение исходного JSON, а не только отображаемой версии. + self.assertEqual(settings["extra"], self.settings["extra"]) # Сохраняет неизвестные общему генератору поля проекта. + self.assertEqual(metadata["version"], expected) # Возвращаемые метаданные содержат уже занятый номер. + self.assertEqual(json.loads((self.output / "firmware_identity.json").read_text(encoding="utf-8"))["version"], expected) # Сохранённый паспорт совпадает с источником версии. + self.assertIn(f"#define FIRMWARE_VERSION_PATCH {expected[2]}U", (self.output / "firmware_identity.h").read_text(encoding="utf-8")) # Компилятор получает тот же patch, что manifest. + + def test_manual_mode_preserves_config_bytes(self): # Старые проекты без флага и явно отключённый итератор сохраняют ручную версию. + for enabled in (None, False): # None обозначает отсутствие поля, а не допустимое значение JSON-флага. + self.settings.pop("increment_version", None) # Сначала моделирует старую конфигурацию без нового переключателя. + if enabled is False: self.settings["increment_version"] = False # Затем проверяет явное отключение без удаления поля. + self.save() # Записывает исходный формат, который генератор не должен переформатировать. + original = self.config.read_bytes() # Сравнивает байты, чтобы заметить любую лишнюю запись. + self.assertEqual(self.prepare()["version"], [1, 0, 4]) # Ручной номер используется без увеличения. + self.assertEqual(self.config.read_bytes(), original) # Исходный JSON остаётся побайтно прежним. + + def test_carry_and_exhaustion(self): # Проверяет ограничения трёх разрядов контракта firmware-info v1. + for previous, expected in (([1, 0, 999], [1, 1, 0]), ([1, 255, 999], [2, 0, 0])): # Patch переносится после 999, minor после 255. + self.settings["version"] = previous # Задаёт последнюю версию перед границей разряда. + self.save() # Передаёт граничное значение публичному prepare через JSON. + self.assertEqual(self.prepare()["version"], expected) # Проверяет перенос вместе с реальной записью настроек. + self.settings["version"] = [255, 255, 999] # Устанавливает последний допустимый номер всего контракта. + self.save() # Сохраняет исходный предел перед ожидаемым отказом. + original = self.config.read_bytes() # Переполнение не должно сбрасывать номер в JSON. + with self.assertRaises(ValueError): self.prepare() # Требует явную ошибку вместо повторного использования версии. + self.assertEqual(self.config.read_bytes(), original) # Сохраняет последний занятый номер без частичной записи. + + def test_invalid_flag_does_not_change_config(self): # Не допускает неявное включение счётчика строками, числами или null. + for value in ("true", "false", 1, 0, None, [], {}): # Только JSON true и false являются допустимыми значениями переключателя. + self.settings["increment_version"] = value # Передаёт неверный тип через настоящий JSON. + self.save() # Фиксирует исходные байты для проверки отказа до резервирования. + original = self.config.read_bytes() # Ошибка настройки не должна занимать номер версии. + with self.assertRaises(ValueError): self.prepare() # Останавливает генерацию до изменения счётчика. + self.assertEqual(self.config.read_bytes(), original) # Подтверждает сохранение исходного JSON при отказе проверки. + + def test_generation_failure_consumes_number(self): # Номер неудачной попытки не должен использоваться следующей сборкой. + with patch.object(identity, "write_atomic", side_effect=OSError("header unavailable")): # Воспроизводит отказ записи заголовка после сохранения следующей версии. + with self.assertRaises(OSError): self.prepare() # Передаёт ошибку вызывающему BAT без маскировки успешным завершением. + self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], [1, 0, 5]) # Резервирование фиксируется до генерации выходных файлов. + self.assertEqual(self.prepare()["version"], [1, 0, 6]) # Следующая попытка занимает следующий номер, даже если прежняя не дошла до компиляции. + + def test_failed_config_replace_preserves_original(self): # Атомарная замена не должна портить настройки при отказе файловой системы. + original = self.config.read_bytes() # Сохраняет эталон исходного номера и остальных полей. + with patch.object(identity.Path, "replace", side_effect=PermissionError("config unavailable")): # Имитирует отказ фиксации временного файла вместо записи нового JSON. + with self.assertRaises(PermissionError): self.prepare() # Останавливает pre-build, если номер нельзя надёжно сохранить. + self.assertEqual(self.config.read_bytes(), original) # Исходные настройки не заменены частичным или неверным файлом. + self.assertEqual(list(self.root.glob(".firmware-version-*")), []) # При отказе не остаются собственные временные файлы счётчика. + + +if __name__ == "__main__": unittest.main() # Любое нарушение общего итератора возвращает ненулевой код процесса. diff --git a/c/firmware-info/tools/identity.py b/c/firmware-info/tools/identity.py index 725d4ea..a338bca 100644 --- a/c/firmware-info/tools/identity.py +++ b/c/firmware-info/tools/identity.py @@ -45,15 +45,34 @@ def write_atomic(path: Path, text: str): temporary.replace(path) +def next_version(version): # Вычисляет следующий номер по ограничениям общего контракта firmware-info v1. + result = list(version) # Сохраняет исходный номер до успешной проверки полного переполнения. + for index, limit in ((2, 999), (1, 255), (0, 255)): # Patch имеет диапазон 0..999, minor и major занимают по одному октету 0..255. + if result[index] < limit: # Находит младший разряд, который ещё можно увеличить. + result[index] += 1 # Резервирует следующий номер попытки сборки, включая будущие неудачные компиляции. + return result # Старшие разряды сохраняются, переполненные младшие уже обнулены. + result[index] = 0 # Переносит единицу в следующий разряд после достижения предела текущего. + raise ValueError("Firmware version exhausted at 255.255.999") # Не допускает повторного использования номера после полного переполнения. + + def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict: settings = json.loads(config.read_text(encoding="utf-8-sig")) version = settings["version"] - if (len(version) != 3 or any(type(v) is not int for v in version) - or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))): - raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") + if not isinstance(version, list) or len(version) != 3 or any(type(value) is not int or not 0 <= value <= limit for value, limit in zip(version, (255, 255, 999))): # Проверяет JSON-массив трёх целых разрядов до любого изменения сохраняемого счётчика. + raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") # Ошибочные типы, логические значения и выход за пределы контракта останавливают pre-build. + if type(settings.get("increment_version", False)) is not bool: # Отсутствующий флаг сохраняет ручной режим старых проектов; строки true/false не считаются настройкой. + raise ValueError("increment_version must be true or false") # Требует явное логическое значение JSON вместо неявного преобразования Python. for field in ("product", "target"): if not isinstance(settings.get(field), str) or not settings[field].strip(): raise ValueError(f"missing {field}") + if settings.get("increment_version", False): # Прямые вызовы templates из CCS, Keil и CLI используют один и тот же сохраняемый итератор. + version = settings["version"] = next_version(version) # Вычисляет номер для JSON, заголовка и метаданных только один раз. + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=config.parent, prefix=".firmware-version-", delete=False) as stream: # Временный файл на том же диске позволяет атомарно заменить настройки без частичной записи. + temporary = Path(stream.name) # Запоминает только собственный файл, чтобы не затронуть пользовательские данные при очистке. + try: stream.write(json.dumps(settings, ensure_ascii=False, indent=2) + "\n") # Сохраняет новый номер и все дополнительные поля проекта в UTF-8. + except BaseException: stream.close(); temporary.unlink(); raise # Ошибка записи оставляет исходный JSON целым и удаляет незавершённый файл. + try: temporary.replace(config) # Занимает номер до дальнейших шагов, поэтому отказ генерации или компиляции не повторит его. + finally: temporary.unlink(missing_ok=True) # Удаляет временный файл при ошибке замены; после успеха он уже переименован. source, dependency = git_state(repository), git_state(templates) # A clean application built with a modified templates checkout is dirty too. known = source["commit"] is not None and dependency["commit"] is not None diff --git a/tools/firmware-build/README.md b/tools/firmware-build/README.md index 0453a24..b86b63e 100644 --- a/tools/firmware-build/README.md +++ b/tools/firmware-build/README.md @@ -22,9 +22,9 @@ ## Текущий проект 28335_periph: вызов через firmware_build.bat Для проекта `K:\git_project\28335_periph\UKSSTMS320F28335` используйте -локальную обвязку. Она вызывает общие BAT из templates, увеличивает версию -в проектном JSON и перед публикацией дополнительно проверяет встроенный -паспорт SCI8 и предел EEPROM. +локальную обвязку. Она вызывает общие BAT из templates; общий генератор +увеличивает версию в проектном JSON. Перед публикацией обвязка дополнительно +проверяет встроенный паспорт SCI8 и предел EEPROM. В CCS → **Project Properties → Build → Steps** задайте для Debug и Release следующие команды, каждую одной строкой. @@ -84,7 +84,11 @@ Include paths компилятора для текущего проекта: Следующие разделы описывают прямые вызовы `ccs/precompile.bat` и `ccs/postcompile.bat`. В этом варианте дополнительный последний аргумент postcompile поддерживается и экспортирует целиком папку текущей сборки. -Общие BAT берут версию из JSON и не увеличивают её автоматически. +Общий генератор увеличивает версию прямо при вызове precompile, если в JSON задано `"increment_version": true`. Отсутствующий флаг или `false` сохраняет ручной режим. + +Итератор общий для CCS, Keil и CLI; проектная обёртка не должна увеличивать номер повторно. + +Каждый pre-build атомарно сохраняет следующий номер до генерации паспорта, включая попытки с последующей ошибкой. После patch 999 увеличивается minor, после minor 255 — major; младшие поля обнуляются. После 255.255.999 сборка останавливается. Clean не сбрасывает JSON; одновременные сборки с одним JSON не поддерживаются. Выберите одну схему подключения: её pre-build и post-build должны использовать один набор метаданных. @@ -94,17 +98,18 @@ postcompile поддерживается и экспортирует целик ### 1. Создайте конфигурацию версии -При первом запуске pre-build автоматически копирует `ccs/firmware_identity.example.json` в путь конфигурации из команды (обычно `${PROJECT_LOC}/firmware_identity.json`, рядом с `.project`), если файла ещё нет. Существующий JSON никогда не перезаписывается. После первого запуска откройте его, задайте свои product, target и version и пересоберите проект. Первый запуск использует значения примера: +При первом запуске pre-build автоматически копирует `ccs/firmware_identity.example.json` в путь конфигурации из команды (обычно `${PROJECT_LOC}/firmware_identity.json`, рядом с `.project`), если файла ещё нет. Существующий JSON не заменяется примером; при включённом итераторе в нём обновляется версия. После первого запуска откройте его, задайте свои product, target и version и пересоберите проект. Исходные значения примера перед первым запуском: ```json { "product": "MY_CONTROLLER", "target": "TMS320F28335", - "version": [0, 1, 0] + "version": [0, 1, 0], + "increment_version": true } ``` -Задайте своё изделие, настоящий контроллер и нужную версию. Для TMS320F2812 замените target. Пример версии — 0.1.0; это не номер предыдущих прошивок. Этот JSON — единственное вручную редактируемое место версии в данной схеме. +Задайте своё изделие, настоящий контроллер и нужную версию. Для TMS320F2812 замените target. Начальный счётчик примера — 0.1.0; при включённом итераторе первый pre-build выдаёт 0.1.1. Этот JSON — единственное вручную редактируемое место версии в данной схеме. ### 2. Добавьте команды в CCS @@ -247,7 +252,8 @@ project/ { "product": "MY_CONTROLLER", "target": "STM32F407", - "version": [0, 1, 0] + "version": [0, 1, 0], + "increment_version": true } ``` diff --git a/tools/firmware-build/ccs/firmware_identity.example.json b/tools/firmware-build/ccs/firmware_identity.example.json index acef913..1faef6a 100644 --- a/tools/firmware-build/ccs/firmware_identity.example.json +++ b/tools/firmware-build/ccs/firmware_identity.example.json @@ -1,5 +1,6 @@ { "product": "MY_CONTROLLER", "target": "TMS320F28335", - "version": [0, 1, 0] + "version": [0, 1, 0], + "increment_version": true } diff --git a/tools/firmware-build/keil/firmware_identity.example.json b/tools/firmware-build/keil/firmware_identity.example.json index 7767f6d..cf7b225 100644 --- a/tools/firmware-build/keil/firmware_identity.example.json +++ b/tools/firmware-build/keil/firmware_identity.example.json @@ -1,5 +1,6 @@ { "product": "MY_CONTROLLER", "target": "STM32F407", - "version": [0, 1, 0] + "version": [0, 1, 0], + "increment_version": true }