Files
tt-support-tools/test_project_info.py
T
Uri Shaked 03ca42307f feat(tt_tools): --check-docs now prints all errors
Previously, it only printed the first error it encountered, causing more iterations when fixing info.yaml and info.md errors
2025-10-03 18:25:42 +03:00

618 lines
23 KiB
Python

import pytest
import yaml
from project_info import YAML_VERSION, ProjectInfo, ProjectYamlError
@pytest.fixture
def valid_yaml_data():
"""Base valid YAML data for testing"""
return {
"yaml_version": YAML_VERSION,
"project": {
"title": "Test Project",
"author": "Test Author",
"description": "Test Description",
"tiles": "1x1",
"analog_pins": 0,
"uses_3v3": False,
"language": "Verilog",
"top_module": "tt_um_test_project",
"source_files": ["test.v"],
"clock_hz": 10000000,
},
"pinout": {
"ui[0]": "input_0",
"ui[1]": "input_1",
"ui[2]": "input_2",
"ui[3]": "input_3",
"ui[4]": "input_4",
"ui[5]": "input_5",
"ui[6]": "input_6",
"ui[7]": "input_7",
"uo[0]": "output_0",
"uo[1]": "output_1",
"uo[2]": "output_2",
"uo[3]": "output_3",
"uo[4]": "output_4",
"uo[5]": "output_5",
"uo[6]": "output_6",
"uo[7]": "output_7",
"uio[0]": "bidir_0",
"uio[1]": "bidir_1",
"uio[2]": "bidir_2",
"uio[3]": "bidir_3",
"uio[4]": "bidir_4",
"uio[5]": "bidir_5",
"uio[6]": "bidir_6",
"uio[7]": "bidir_7",
},
}
@pytest.fixture
def tile_sizes():
"""Valid tile sizes for testing"""
return {
"1x1": "0 0 161 111.52",
"1x2": "0 0 161 225.76",
"2x2": "0 0 322 225.76",
"8x2": "0 0 1292 225.76",
}
class TestProjectInfoYamlVersion:
"""Test YAML version validation"""
def test_missing_yaml_version(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["yaml_version"]
with pytest.raises(ProjectYamlError, match="Missing 'yaml_version'"):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_wrong_yaml_version(self, valid_yaml_data, tile_sizes):
valid_yaml_data["yaml_version"] = YAML_VERSION - 1
with pytest.raises(
ProjectYamlError,
match=f"Unsupported YAML version: {YAML_VERSION - 1}, expected {YAML_VERSION}",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_valid_yaml_version(self, valid_yaml_data, tile_sizes):
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info is not None
class TestProjectInfoProjectSection:
"""Test project section validation"""
def test_missing_project_section(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]
with pytest.raises(ProjectYamlError, match="Missing 'project' section"):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_title(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["title"]
with pytest.raises(
ProjectYamlError, match="Missing key 'title' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_empty_title(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["title"] = ""
with pytest.raises(ProjectYamlError, match="Project title cannot be empty"):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_author(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["author"]
with pytest.raises(
ProjectYamlError, match="Missing key 'author' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_empty_author(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["author"] = ""
with pytest.raises(ProjectYamlError, match="Project author cannot be empty"):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_description(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["description"]
with pytest.raises(
ProjectYamlError, match="Missing key 'description' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_empty_description(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["description"] = ""
with pytest.raises(
ProjectYamlError, match="Project description cannot be empty"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_tiles(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["tiles"]
with pytest.raises(
ProjectYamlError, match="Missing key 'tiles' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_invalid_tiles(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["tiles"] = "invalid_size"
with pytest.raises(
ProjectYamlError,
match="Invalid value for 'tiles' in 'project' section: invalid_size",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_analog_pins_not_integer(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = "not_int"
with pytest.raises(
ProjectYamlError,
match="Invalid value for 'analog_pins' in 'project' section, must be an integer",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_analog_pins_negative(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = -1
with pytest.raises(
ProjectYamlError,
match="Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_analog_pins_too_high(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = 7
with pytest.raises(
ProjectYamlError,
match="Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_uses_3v3_without_analog_pins(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["uses_3v3"] = True
valid_yaml_data["project"]["analog_pins"] = 0
with pytest.raises(
ProjectYamlError,
match="Projects with 3v3 power need at least one analog pin",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_language(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["language"]
with pytest.raises(
ProjectYamlError, match="Missing key 'language' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_empty_language(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["language"] = ""
with pytest.raises(ProjectYamlError, match="Project language cannot be empty"):
ProjectInfo(valid_yaml_data, tile_sizes)
class TestProjectInfoWokwiLanguage:
"""Test Wokwi language specific validation"""
def test_wokwi_missing_wokwi_id(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["language"] = "Wokwi"
del valid_yaml_data["project"]["top_module"]
del valid_yaml_data["project"]["source_files"]
with pytest.raises(
ProjectYamlError, match="Missing key 'wokwi_id' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_wokwi_empty_wokwi_id(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["language"] = "Wokwi"
valid_yaml_data["project"]["wokwi_id"] = ""
del valid_yaml_data["project"]["top_module"]
del valid_yaml_data["project"]["source_files"]
with pytest.raises(
ProjectYamlError, match="Please provide a valid Wokwi project ID"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_wokwi_zero_wokwi_id(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["language"] = "Wokwi"
valid_yaml_data["project"]["wokwi_id"] = "0"
del valid_yaml_data["project"]["top_module"]
del valid_yaml_data["project"]["source_files"]
with pytest.raises(
ProjectYamlError, match="Please provide a valid Wokwi project ID"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_wokwi_valid(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["language"] = "Wokwi"
valid_yaml_data["project"]["wokwi_id"] = 123456789
del valid_yaml_data["project"]["top_module"]
del valid_yaml_data["project"]["source_files"]
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.top_module == "tt_um_wokwi_123456789"
assert project_info.source_files == ["tt_um_wokwi_123456789.v", "cells.v"]
class TestProjectInfoNonWokwiLanguage:
"""Test non-Wokwi language validation"""
def test_missing_top_module(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["top_module"]
with pytest.raises(
ProjectYamlError, match="Missing key 'top_module' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_top_module_without_tt_um_prefix(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["top_module"] = "invalid_module"
with pytest.raises(
ProjectYamlError, match="Top module must start with 'tt_um_'"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_source_files(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["source_files"]
with pytest.raises(
ProjectYamlError, match="Missing key 'source_files' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_empty_source_files(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["source_files"] = []
with pytest.raises(ProjectYamlError, match="No source files specified"):
ProjectInfo(valid_yaml_data, tile_sizes)
class TestProjectInfoClockHz:
"""Test clock_hz validation"""
def test_missing_clock_hz(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["project"]["clock_hz"]
with pytest.raises(
ProjectYamlError, match="Missing key 'clock_hz' in 'project' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_clock_hz_not_integer(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["clock_hz"] = "not_int"
with pytest.raises(
ProjectYamlError,
match="Invalid value for 'clock_hz' in 'project' section, must be an integer",
):
ProjectInfo(valid_yaml_data, tile_sizes)
class TestProjectInfoPinoutSection:
"""Test pinout section validation"""
def test_missing_pinout_section(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["pinout"]
with pytest.raises(ProjectYamlError, match="Missing 'pinout' section"):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_ui_pin(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["pinout"]["ui[0]"]
with pytest.raises(
ProjectYamlError, match="Missing 'ui\\[0\\]' in 'pinout' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_uo_pin(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["pinout"]["uo[3]"]
with pytest.raises(
ProjectYamlError, match="Missing 'uo\\[3\\]' in 'pinout' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_missing_uio_pin(self, valid_yaml_data, tile_sizes):
del valid_yaml_data["pinout"]["uio[7]"]
with pytest.raises(
ProjectYamlError, match="Missing 'uio\\[7\\]' in 'pinout' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_all_empty_pinout(self, valid_yaml_data, tile_sizes):
for pin in valid_yaml_data["pinout"]:
valid_yaml_data["pinout"][pin] = ""
with pytest.raises(
ProjectYamlError, match="Please fill in the 'pinout' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_invalid_pinout_keys(self, valid_yaml_data, tile_sizes):
valid_yaml_data["pinout"]["invalid_key"] = "value"
with pytest.raises(
ProjectYamlError,
match="Invalid keys \\['invalid_key'\\] in 'pinout' section. Please remove them.",
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_ua_pins_optional_when_not_analog(self, valid_yaml_data, tile_sizes):
# ua pins should be optional when analog_pins is 0
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.pinout.ua == []
def test_ua_pins_required_when_analog(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = 2
pinout = valid_yaml_data["pinout"]
pinout["ua[0]"] = "analog_0"
pinout["ua[1]"] = "analog_1"
pinout["ua[2]"] = ""
pinout["ua[3]"] = ""
pinout["ua[4]"] = ""
pinout["ua[5]"] = ""
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
expected_ua = ["analog_0", "analog_1", "", "", "", ""]
assert project_info.pinout.ua == expected_ua
def test_missing_ua_pin_when_analog(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = 1
# If ua[0] is present, but ua[1] is missing, it should fail
valid_yaml_data["pinout"]["ua[0]"] = "analog_0"
# Missing ua[1] should fail since ua[0] exists
with pytest.raises(
ProjectYamlError, match="Missing 'ua\\[1\\]' in 'pinout' section"
):
ProjectInfo(valid_yaml_data, tile_sizes)
def test_ua_pins_completely_optional(self, valid_yaml_data, tile_sizes):
# If no ua[0] is present, then ua pins are completely optional
valid_yaml_data["project"]["analog_pins"] = 2 # Even with analog_pins > 0
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.pinout.ua == []
class TestProjectInfoValidCombinations:
"""Test valid combinations and edge cases"""
def test_valid_analog_project(self, valid_yaml_data, tile_sizes):
valid_yaml_data["project"]["analog_pins"] = 2
valid_yaml_data["project"]["uses_3v3"] = True
# Include all required ua pins
for i in range(6):
valid_yaml_data["pinout"][f"ua[{i}]"] = f"analog_{i}" if i < 2 else ""
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.analog_pins == 2
assert project_info.is_analog is True
assert project_info.uses_3v3 is True
def test_valid_digital_project(self, valid_yaml_data, tile_sizes):
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.analog_pins == 0
assert project_info.is_analog is False
assert project_info.uses_3v3 is False
def test_optional_fields(self, valid_yaml_data, tile_sizes):
# Test that optional fields work
valid_yaml_data["project"]["discord"] = "test_discord"
valid_yaml_data["project"]["doc_link"] = "https://example.com"
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.discord == "test_discord"
assert project_info.doc_link == "https://example.com"
def test_default_analog_pins(self, valid_yaml_data, tile_sizes):
# Test that analog_pins defaults to 0 when not specified
del valid_yaml_data["project"]["analog_pins"]
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.analog_pins == 0
def test_default_uses_3v3(self, valid_yaml_data, tile_sizes):
# Test that uses_3v3 defaults to False when not specified
del valid_yaml_data["project"]["uses_3v3"]
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.uses_3v3 is False
def test_all_tile_sizes(self, valid_yaml_data, tile_sizes):
# Test all valid tile sizes
for tile_size in tile_sizes.keys():
valid_yaml_data["project"]["tiles"] = tile_size
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.tiles == tile_size
def test_empty_pinout_descriptions_allowed(self, valid_yaml_data, tile_sizes):
# Some pins can be empty (just not ALL pins)
valid_yaml_data["pinout"]["ui[0]"] = ""
valid_yaml_data["pinout"]["uo[0]"] = ""
valid_yaml_data["pinout"]["uio[0]"] = ""
project_info = ProjectInfo(valid_yaml_data, tile_sizes)
assert project_info.pinout.ui[0] == ""
assert project_info.pinout.uo[0] == ""
assert project_info.pinout.uio[0] == ""
class TestMultipleErrors:
"""Test that multiple errors are collected and reported together"""
def test_multiple_project_errors(self, valid_yaml_data, tile_sizes):
"""Test that multiple errors in project section are collected"""
del valid_yaml_data["project"]["title"]
del valid_yaml_data["project"]["author"]
valid_yaml_data["project"]["description"] = ""
with pytest.raises(ProjectYamlError) as exc_info:
ProjectInfo(valid_yaml_data, tile_sizes)
assert sorted(exc_info.value.errors) == sorted(
[
"Missing key 'title' in 'project' section",
"Missing key 'author' in 'project' section",
"Project description cannot be empty",
]
)
def test_multiple_pinout_errors(self, valid_yaml_data, tile_sizes):
"""Test that multiple errors in pinout section are collected"""
del valid_yaml_data["pinout"]["ui[0]"]
del valid_yaml_data["pinout"]["uo[3]"]
del valid_yaml_data["pinout"]["uio[7]"]
with pytest.raises(ProjectYamlError) as exc_info:
ProjectInfo(valid_yaml_data, tile_sizes)
assert sorted(exc_info.value.errors) == sorted(
[
"Missing 'ui[0]' in 'pinout' section",
"Missing 'uo[3]' in 'pinout' section",
"Missing 'uio[7]' in 'pinout' section",
]
)
def test_errors_across_sections(self, valid_yaml_data, tile_sizes):
"""Test that errors across different sections are all collected"""
del valid_yaml_data["yaml_version"]
del valid_yaml_data["project"]["title"]
valid_yaml_data["project"]["analog_pins"] = -1
del valid_yaml_data["pinout"]["ui[0]"]
with pytest.raises(ProjectYamlError) as exc_info:
ProjectInfo(valid_yaml_data, tile_sizes)
assert sorted(exc_info.value.errors) == sorted(
[
"Missing 'yaml_version'",
"Missing key 'title' in 'project' section",
"Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6",
"Missing 'ui[0]' in 'pinout' section",
]
)
def test_comprehensive_validation_errors(self, valid_yaml_data, tile_sizes):
"""Test a comprehensive set of validation errors"""
valid_yaml_data["yaml_version"] = 999
valid_yaml_data["project"]["title"] = ""
valid_yaml_data["project"]["author"] = ""
valid_yaml_data["project"]["tiles"] = "invalid"
valid_yaml_data["project"]["analog_pins"] = 10
del valid_yaml_data["project"]["clock_hz"]
del valid_yaml_data["pinout"]["uo[0]"]
valid_yaml_data["pinout"]["invalid_key"] = "value"
with pytest.raises(ProjectYamlError) as exc_info:
ProjectInfo(valid_yaml_data, tile_sizes)
assert sorted(exc_info.value.errors) == sorted(
[
"Unsupported YAML version: 999, expected 6",
"Project title cannot be empty",
"Project author cannot be empty",
"Invalid value for 'tiles' in 'project' section: invalid",
"Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6",
"Missing key 'clock_hz' in 'project' section",
"Missing 'uo[0]' in 'pinout' section",
"Invalid keys ['invalid_key'] in 'pinout' section. Please remove them.",
]
)
def test_error_message_formatting(self, valid_yaml_data, tile_sizes):
"""Test that error message contains all errors separated by commas"""
del valid_yaml_data["project"]["title"]
del valid_yaml_data["project"]["author"]
with pytest.raises(ProjectYamlError) as exc_info:
ProjectInfo(valid_yaml_data, tile_sizes)
error_message = str(exc_info.value)
assert (
"Missing key 'title' in 'project' section, Missing key 'author' in 'project' section"
== error_message
)
class TestProjectInfoIntegration:
"""Integration tests with real YAML files"""
def test_load_from_yaml_string(self, tile_sizes):
yaml_content = f"""
yaml_version: {YAML_VERSION}
project:
title: "Integration Test"
author: "Test Author"
description: "Integration test description"
tiles: "1x1"
language: "Verilog"
top_module: "tt_um_integration_test"
source_files: ["integration_test.v"]
clock_hz: 25000000
pinout:
ui[0]: "clk"
ui[1]: "rst_n"
ui[2]: "enable"
ui[3]: "data_in[0]"
ui[4]: "data_in[1]"
ui[5]: "data_in[2]"
ui[6]: "data_in[3]"
ui[7]: "unused"
uo[0]: "data_out[0]"
uo[1]: "data_out[1]"
uo[2]: "data_out[2]"
uo[3]: "data_out[3]"
uo[4]: "valid"
uo[5]: "ready"
uo[6]: "error"
uo[7]: "unused"
uio[0]: "sda"
uio[1]: "scl"
uio[2]: "cs"
uio[3]: "mosi"
uio[4]: "miso"
uio[5]: "sck"
uio[6]: "interrupt"
uio[7]: "debug"
"""
yaml_data = yaml.safe_load(yaml_content)
project_info = ProjectInfo(yaml_data, tile_sizes)
assert project_info.title == "Integration Test"
assert project_info.top_module == "tt_um_integration_test"
assert project_info.clock_hz == 25000000
assert len(project_info.source_files) == 1
assert project_info.source_files[0] == "integration_test.v"
def test_load_wokwi_from_yaml_string(self, tile_sizes):
yaml_content = f"""
yaml_version: {YAML_VERSION}
project:
title: "Wokwi Integration Test"
author: "Wokwi User"
description: "Wokwi integration test"
tiles: "1x1"
language: "Wokwi"
wokwi_id: 987654321
clock_hz: 10000000
pinout:
ui[0]: "button_0"
ui[1]: "button_1"
ui[2]: "switch_0"
ui[3]: "switch_1"
ui[4]: "unused"
ui[5]: "unused"
ui[6]: "unused"
ui[7]: "unused"
uo[0]: "led_0"
uo[1]: "led_1"
uo[2]: "led_2"
uo[3]: "led_3"
uo[4]: "segment_a"
uo[5]: "segment_b"
uo[6]: "segment_c"
uo[7]: "segment_d"
uio[0]: "unused"
uio[1]: "unused"
uio[2]: "unused"
uio[3]: "unused"
uio[4]: "unused"
uio[5]: "unused"
uio[6]: "unused"
uio[7]: "unused"
"""
yaml_data = yaml.safe_load(yaml_content)
project_info = ProjectInfo(yaml_data, tile_sizes)
assert project_info.title == "Wokwi Integration Test"
assert project_info.top_module == "tt_um_wokwi_987654321"
assert project_info.source_files == ["tt_um_wokwi_987654321.v", "cells.v"]
assert hasattr(project_info, "wokwi_id")
assert project_info.wokwi_id == 987654321