"""Проверяет общий сохраняемый итератор версии до компиляции 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() # Любое нарушение общего итератора возвращает ненулевой код процесса.