Files
templates/tools/firmware-build/fpga/tests/test_flows.py
T
Andrey 62c0492bfd feat(firmware-build): Добавь сборку и паспорта проектов ПЛИС
Подключи ISE, Vivado и Quartus к общему итератору версии и HDL-паспорту. Проверяй новый bit/sof до создания manifest, SHA-256 и архива выпуска.

Добавь установку локальных команд ISE, инструкции переноса и регрессионные проверки успешных и прерванных сборок.
2026-10-23 14:16:21 +03:00

97 lines
14 KiB
Python

"""Исполняет реальные Tcl-сценарии с заменой только команд производителей САПР.""" # Проверяет порядок, пути с пробелами и блокирование post-build.
import json # Проверяет, сколько номеров занял каждый запуск сценария.
import os # Временно задаёт путь минимальной инициализации Tcl без GUI.
from pathlib import Path # Находит сценарии относительно тестов.
import sys # Передаёт Tcl путь к тому же Python 3, которым выполняется тест.
import tempfile # Изолирует проекты и готовые образы каждого запуска.
import unittest # Организует успешные и ошибочные сценарии трёх САПР.
import tkinter # Предоставляет настоящий Tcl-интерпретатор без открытия окна Tk.
SCRIPTS = Path(__file__).resolve().parents[1] # Находит поставку FPGA-сценариев.
class FlowTests(unittest.TestCase): # Проверяет Tcl-управление без подмены генерации паспорта и архивирования.
def run_flow(self, toolchain, language="verilog", failure="", revision=""): # Исполняет один полный flow во временном проекте.
scratch = SCRIPTS.parents[2] / ".codex-build" # Каталог внутри репозитория исключает внешние junction AppData при проверке Tcl на Windows.
scratch.mkdir(exist_ok=True) # Хранит временные проекты в уже игнорируемой области сборки.
with tempfile.TemporaryDirectory(prefix="FPGA flow пробел ", dir=scratch) as temporary: # Кириллица и пробелы должны пережить Tcl exec и Python CLI.
root = Path(temporary) # Не использует настоящие проекты или настройки САПР.
project = root / {"ise": "board.xise", "vivado": "board.xpr", "quartus": "board.qpf"}[toolchain] # Создаёт только входной маркер нужного типа проекта.
project.touch() # Открытие моделируется командами производителя, наличие проверяет реальный common.tcl.
config = root / "identity.json" # Хранит реальный сохраняемый счётчик.
config.write_text(json.dumps({"product": "Плата", "target": "test", "version": [0, 1, 0], "increment_version": "true" if failure == "prepare" else True}), encoding="utf-8") # Ошибка типа флага должна остановить prepare до резервирования; штатный flow занимает 0.1.1.
image = root / ("board.sof" if toolchain == "quartus" else "board.bit") # Совпадает с нативным форматом сценария.
image.write_bytes(b"old image") # Позволяет проверить отказ упаковки старого файла после сбоя сборки.
previous = Path.cwd() # Настоящий Tcl cd меняет текущий каталог процесса Python.
script = os.path.relpath(SCRIPTS / (toolchain + ".tcl"), previous) # Относительный путь проверяет нахождение Python-helper после перехода в другой каталог проекта.
interpreter = tkinter.Tcl() # Выполняет установленный Tcl без окна и без запуска xtclsh/Vivado/Quartus.
calls = [] # Собирает последовательность только вендорных команд.
def vendor(name, *arguments): # Моделирует интерфейс САПР, сохраняя настоящий exec Python-инструмента.
calls.append((name, *arguments)) # Позволяет проверить порядок подготовки и запуска дочерних шагов.
if name == "current_run": # В Vivado выбирается активная реализация с нестандартным именем.
return "impl_board" # Проверяет отсутствие жёсткой привязки к impl_1.
if name == "get_runs": # Возвращает имя объекта run без лишних преобразований.
return arguments[-1] # Достаточно для чтения PARENT, SRCSET и PROGRESS.
if name == "get_property": # Моделирует свойства выбранного синтеза и реализации.
field, target = arguments # Сохраняет различие родительского синтеза и активного в GUI.
if field == "PARENT": # Реализация может зависеть от другого синтеза.
return "synth_board" # Сценарий должен выбрать этого родителя.
if field == "SRCSET": # Новый модуль должен попасть именно в исходники родителя реализации.
return "sources_board" # Проверяет выбор набора вместо предположения sources_1.
return "0%" if failure == target else "100%" # Ожидание завершения при ошибке не считается успехом.
finished = name == "execute_flow" or name == "process" or (name == "wait_on_run" and arguments[0] == "impl_board") # Определяет место записи результата компиляции.
if finished and not failure: # Только успешный flow создаёт новый файл.
image.write_bytes(b"new image from " + toolchain.encode("ascii")) # Имитирует точные байты нового .bit/.sof.
if name == "process": # ISE сообщает отказ логическим результатом без Tcl error.
return "false" if failure else "true" # Проверяет особую обработку boolean в ise.tcl.
if name == "execute_flow" and failure: # Quartus сообщает ошибку компиляции Tcl-исключением.
raise RuntimeError("test compiler failure") # Вызывает catch в настоящем quartus.tcl.
return "" # Остальные команды производителя успешно завершаются без значения.
for name in ("project", "xfile", "process", "open_project", "current_run", "get_property", "get_runs", "add_files", "update_compile_order", "reset_run", "launch_runs", "wait_on_run", "close_project", "load_package", "project_open", "set_global_assignment", "execute_flow", "project_close"): # Подменяет только API производителя, не команды ядра Tcl.
interpreter.createcommand(name, lambda *arguments, name=name: vendor(name, *arguments)) # Привязывает имя при создании каждой callback-функции.
interpreter.eval('proc exit {code} {error "flow-exit:$code"}') # Не позволяет Tcl exit завершить процесс тестов.
interpreter.call("package", "provide", "::quartus::project", "1.0") # Удовлетворяет require пакета, чьи команды заменены заглушками.
arguments = [str(project), str(config), str(root / "build"), str(image), language, sys.executable] # Передаёт пути списком, без shell-экранирования.
if revision: # Quartus допускает дополнительное имя аппаратной ревизии.
arguments.append(revision) # Проверяет передачу значения с пробелами одним аргументом.
interpreter.call("set", "quartus(args)" if toolchain == "quartus" else "argv", tuple(arguments)) # Создаёт штатный источник аргументов соответствующей САПР.
try: # Гарантирует восстановление cwd даже при ожидаемом exit 1.
if failure: # Неудачная компиляция должна прервать сценарий до упаковки.
with self.assertRaisesRegex(tkinter.TclError, "flow-exit:1"): # Проверяет внешнее состояние отказа.
interpreter.call("source", script) # Исполняет настоящий сценарий со всеми проверками.
else: # Успешный сценарий обязан дойти до архива.
interpreter.call("source", script) # Реальный exec создаёт HDL, manifest и архив.
self.assertEqual(json.loads(config.read_text(encoding="utf-8"))["version"], [0, 1, 0] if failure == "prepare" else [0, 1, 1]) # Ошибка конфигурации не занимает номер; после начала сборки номер резервируется ровно один раз.
manifests = list((root / "build/firmware").glob("*/*.manifest.json")) # Ищет только выпущенные архивы текущего запуска.
self.assertEqual(len(manifests), 0 if failure else 1) # Ошибка синтеза/реализации не должна выпустить старый образ.
if failure == "prepare": # Ошибка общего Python pre-build должна блокировать запуск САПР.
self.assertFalse(any(call[0] in ("process", "launch_runs", "execute_flow") for call in calls)) # Не допускает синтез с прежним паспортом после ошибки настроек.
if not failure: # Дополнительно проверяет выбранный HDL после реального prepare.
suffix = "v" if language == "verilog" else "vhd" # В проект регистрируется только выбранный вариант.
self.assertTrue(any(str(root / "build/generated" / ("firmware_identity." + suffix)).replace("\\", "/") in str(call).replace("\\\\", "/") for call in calls)) # Учитывает Windows-разделители в строковом представлении кортежей.
finally: # Tcl cd не должен влиять на следующий тест или временную очистку.
os.chdir(previous) # Возвращает исходный каталог процесса до удаления проекта.
return calls # Отдаёт последовательность команд для специфических проверок САПР.
def test_all_toolchains_and_languages(self): # Каждый сценарий должен выпустить файл для обеих HDL-реализаций.
for toolchain in ("ise", "vivado", "quartus"): # Покрывает три независимых набора Tcl-команд.
for language in ("verilog", "vhdl"): # Проверяет правильное расширение и тип назначения исходника.
with self.subTest(toolchain=toolchain, language=language): # Показывает точную конфигурацию возможного отказа.
self.run_flow(toolchain, language) # Создаёт реальный паспорт и архив без лицензии производителя.
def test_failures_do_not_archive_old_images(self): # Проверяет false ISE, ошибку Quartus и неуспешные runs Vivado.
for toolchain, failure in (("ise", "compile"), ("quartus", "compile"), ("vivado", "synth_board"), ("vivado", "impl_board"), ("ise", "prepare"), ("vivado", "prepare"), ("quartus", "prepare")): # Отдельно покрывает обе стадии Vivado и отказ Python pre-build во всех САПР.
with self.subTest(toolchain=toolchain, failure=failure): # Сохраняет контекст ожидаемой ошибки.
self.run_flow(toolchain, failure=failure) # Старый файл существует, но архив не должен появиться.
def test_revision_and_parent_synthesis(self): # Проверяет нестандартные имена конфигураций проектов.
quartus = self.run_flow("quartus", revision="board revision B") # Явная ревизия содержит пробелы и передаётся единым аргументом.
self.assertTrue(any(call[:3] == ("project_open", "-revision", "board revision B") for call in quartus)) # Не допускает случайной сборки default revision.
vivado = self.run_flow("vivado") # Активная реализация зависит от синтеза с нестандартным именем.
self.assertIn(("launch_runs", "synth_board"), vivado) # Собирается именно родитель выбранной реализации.
self.assertIn(("update_compile_order", "-fileset", "sources_board"), vivado) # Паспорт относится к набору исходников этого синтеза.
self.assertLess(vivado.index(("wait_on_run", "synth_board")), vivado.index(("launch_runs", "impl_board", "-to_step", "write_bitstream"))) # Реализация начинается только после ожидания синтеза.
if __name__ == "__main__": # Поддерживает прямой запуск файла проверки.
unittest.main() # Возвращает ненулевой код при нарушении порядка сборки.