Coverage for tests/unit_tests/scons/test_plugin_util.py: 100%

145 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 05:08 +0000

1# This file is part of the Apio project 

2# (C) 2016-present FPGAwars 

3# Project authors: 

4# * Jesús Arroyo (Jesus89) (2016-2019) 

5# * Juan Gonzalez (obijuan) (2019-2024) 

6# * The Apio Team (2024-present) 

7# License: GPLv2 

8 

9""" 

10Tests of the scons plugin_util.py functions. 

11""" 

12 

13import re 

14import os 

15from os.path import isfile, exists, join 

16from pathlib import Path 

17import pytest 

18from SCons.Node.FS import FS 

19from SCons.Action import FunctionAction 

20from tests.unit_tests.scons.testing import make_test_apio_env 

21from tests.conftest import ApioRunner 

22from apio.common import apio_console 

23from apio.common.proto.stubs.apio_common_pb2 import ApioArch 

24from apio.common.build_report import BuildReport, ResourceReport 

25from apio.common.proto.stubs.apio_scons_pb2 import ( 

26 TargetParams, 

27 UploadParams, 

28 LintParams, 

29 ApioEnvParams, 

30) 

31from apio.scons.plugin_util import ( 

32 get_constraint_file, 

33 verilog_src_scanner, 

34 get_programmer_cmd, 

35 map_str_params, 

36 map_path_params, 

37 make_verilator_config_builder, 

38 verilator_lint_action, 

39 iverilog_action, 

40 _print_pnr_report, 

41) 

42 

43 

44def test_get_constraint_file(apio_runner: ApioRunner): 

45 """Test the get_constraint_file() method.""" 

46 

47 with apio_runner.in_sandbox() as sb: 

48 

49 apio_env = make_test_apio_env() 

50 

51 # -- If not .pcf files, should print an error and exit. 

52 with apio_runner.with_logger() as log: 

53 with pytest.raises(SystemExit) as e: 

54 result = get_constraint_file(apio_env, ".pcf") 

55 assert e.value.code == 1 

56 assert "No constraint file '*.pcf' found" in log.out 

57 

58 # -- If a single .pcf file, return it. Constraint file can also be 

59 # -- in subdirectories as we test here 

60 file1 = os.path.join("lib", "pinout.pcf") 

61 sb.write_file(file1, "content1") 

62 with apio_runner.with_logger() as log: 

63 result = get_constraint_file(apio_env, ".pcf") 

64 assert log.out == "" 

65 assert result == file1 

66 

67 # -- If there is more than one, exit with an error message. 

68 file2 = "other.pcf" 

69 sb.write_file(file2, "content2") 

70 with apio_runner.with_logger() as log: 

71 with pytest.raises(SystemExit) as e: 

72 result = get_constraint_file(apio_env, ".pcf") 

73 assert e.value.code == 1 

74 assert "Error: Found 2 constraint files '*.pcf'" in log.out 

75 

76 # -- If the user specified a valid file then return it, regardless 

77 # -- if it exists or not. 

78 apio_env.params.apio_env_params.constraint_file = "xyz.pcf" 

79 with apio_runner.with_logger() as log: 

80 result = get_constraint_file(apio_env, ".pcf") 

81 assert log.out == "" 

82 assert result == "xyz.pcf" 

83 

84 # -- File extension should match the architecture. 

85 apio_env.params.apio_env_params.constraint_file = "xyz.bad" 

86 with apio_runner.with_logger() as log: 

87 with pytest.raises(SystemExit) as e: 

88 result = get_constraint_file(apio_env, ".pcf") 

89 assert e.value.code == 1 

90 assert ( 

91 "Constraint file should have the extension '.pcf': xyz.bad" 

92 in log.out 

93 ) 

94 

95 # -- Path under _build is not allowed. 

96 apio_env.params.apio_env_params.constraint_file = "_build/xyz.pcf" 

97 with apio_runner.with_logger() as log: 

98 with pytest.raises(SystemExit) as e: 

99 result = get_constraint_file(apio_env, ".pcf") 

100 assert e.value.code == 1 

101 assert ( 

102 "Error: Constraint file should not be under _build: _build/xyz.pcf" 

103 in log.out 

104 ) 

105 

106 # -- Path should not contain '../ 

107 apio_env.params.apio_env_params.constraint_file = "a/../xyz.pcf" 

108 with apio_runner.with_logger() as log: 

109 with pytest.raises(SystemExit) as e: 

110 result = get_constraint_file(apio_env, ".pcf") 

111 assert e.value.code == 1 

112 assert ( 

113 "Error: Constraint file path should not contain '..': a/../xyz.pcf" 

114 in log.out 

115 ) 

116 

117 

118def test_verilog_src_scanner(apio_runner: ApioRunner): 

119 """Test the verilog scanner which scans a verilog file and extract 

120 reference of files it uses. 

121 """ 

122 

123 # -- Test file content with references. Contains duplicates and 

124 # -- references out of alphabetical order. 

125 file_content = """ 

126 // Dummy file for testing. 

127 

128 // Icestudio reference. 

129 parameter v771499 = "v771499.list" 

130 

131 // System verilog include reference. 

132 `include "apio_testing.vh" 

133 

134 // Duplicate icestudio reference. 

135 parameter v771499 = "v771499.list" 

136 

137 // Verilog include reference. 

138 `include "apio_testing.v 

139 

140 // $readmemh() function reference. 

141 $readmemh("subdir2/my_data.hex", State_buff); 

142 """ 

143 

144 with apio_runner.in_sandbox() as sb: 

145 

146 # -- Write a test file name in the current directory. 

147 sb.write_file("subdir1/test_file.v", file_content) 

148 

149 # -- Create a scanner 

150 apio_env = make_test_apio_env() 

151 scanner = verilog_src_scanner(apio_env) 

152 

153 # -- Run the scanner. It returns a list of File. 

154 file = FS.File(FS(), "subdir1/test_file.v") 

155 dependency_files = scanner.function(file, apio_env, None) 

156 

157 # -- Files list should be empty since none of the dependency candidate 

158 # has a file. 

159 file_names = [f.name for f in dependency_files] 

160 assert file_names == [] 

161 

162 # -- Create file lists 

163 core_dependencies = [ 

164 "apio.ini", 

165 "boards.jsonc", 

166 "programmers.jsonc", 

167 "fpgas.jsonc", 

168 ] 

169 

170 file_dependencies = [ 

171 "apio_testing.vh", 

172 join("subdir2", "my_data.hex"), 

173 join("subdir1", "v771499.list"), 

174 ] 

175 

176 # -- Create dummy files. This should cause the dependencies to be 

177 # -- reported. (Candidate dependencies with no matching file are 

178 # -- filtered out) 

179 for f in core_dependencies + file_dependencies + ["non-related.txt"]: 

180 sb.write_file(f, "dummy-file") 

181 

182 # -- Run the scanner again 

183 dependency_files = scanner.function(file, apio_env, None) 

184 

185 # -- Check the dependencies 

186 file_names = [f.path for f in dependency_files] 

187 assert file_names == sorted(core_dependencies + file_dependencies) 

188 

189 

190def test_get_programmer_cmd(): 

191 """Tests the function programmer_cmd().""" 

192 

193 apio_console.configure() 

194 

195 # -- Test a valid programmer command. 

196 apio_env = make_test_apio_env( 

197 targets=["upload"], 

198 target_params=TargetParams( 

199 upload=UploadParams(programmer_cmd="my_prog aa $SOURCE bb") 

200 ), 

201 ) 

202 assert get_programmer_cmd(apio_env) == "my_prog aa $SOURCE bb" 

203 

204 

205def test_map_str_params(): 

206 """Test the map_str_params() function.""" 

207 

208 # -- Empty cases 

209 assert map_str_params([], "x_{}_y") == "" 

210 assert map_str_params(["", " "], "x_{}_y") == "" 

211 

212 # -- Non empty cases 

213 assert map_str_params(["a"], "x_{}_y") == "x_a_y" 

214 assert map_str_params([" a "], "x_{}_y") == "x_a_y" 

215 assert map_str_params(["a", "a", "b"], "x_{}_y") == "x_a_y x_a_y x_b_y" 

216 

217 

218def test_map_path_params(): 

219 """Test the map_path_params() function.""" 

220 

221 assert map_path_params([], "x_{}_y") == "" 

222 

223 assert ( 

224 map_path_params([Path("aa/bb"), Path(".")], "x/_{}_/y") 

225 == "x/_aa" + os.sep + "bb_/y" + " " + "x/_._/y" 

226 ) 

227 

228 

229def test_make_verilator_config_builder(apio_runner: ApioRunner): 

230 """Tests the make_verilator_config_builder() function.""" 

231 

232 with apio_runner.in_sandbox() as sb: 

233 

234 # -- Create a test scons env. 

235 apio_env = make_test_apio_env() 

236 

237 # -- Call the tested method to create a builder. 

238 builder = make_verilator_config_builder( 

239 sb.packages_dir, 

240 rules_to_suppress=[ 

241 "SPECIFYIGN", 

242 ], 

243 ) 

244 

245 # -- Verify builder suffixes. 

246 assert builder.src_suffix == [] 

247 assert builder.suffix == ".vlt" 

248 

249 # -- Create a target that doesn't exist yet. 

250 assert not exists("hardware.vlt") 

251 target = FS.File(FS(), "hardware.vlt") 

252 

253 # -- Invoke the builder's action to create the target. 

254 builder.action(target, [], apio_env.scons_env) 

255 assert isfile("hardware.vlt") 

256 

257 # -- Verify that the file was created with the given text. 

258 text = sb.read_file_text("hardware.vlt") 

259 assert "verilator_config" in text, text 

260 assert "lint_off -rule SPECIFYIGN" in text, text 

261 

262 

263def test_iverilog_action_has_no_vcd_output_macro(apio_runner: ApioRunner): 

264 """Tests iverilog_action() does not define the retired VCD_OUTPUT macro.""" 

265 

266 with apio_runner.in_sandbox(): 

267 

268 apio_env = make_test_apio_env() 

269 action = iverilog_action(apio_env, verbose=False, is_interactive=False) 

270 

271 normalized_cmd = re.sub(r"\s+", " ", action) 

272 

273 assert "VCD_OUTPUT" not in normalized_cmd 

274 assert "-DAPIO_SIM=0" in normalized_cmd 

275 assert normalized_cmd.startswith("iverilog -g2012") 

276 

277 action_sim = iverilog_action( 

278 apio_env, verbose=True, is_interactive=True 

279 ) 

280 normalized_sim = re.sub(r"\s+", " ", action_sim) 

281 assert "-DAPIO_SIM=1" in normalized_sim 

282 assert "iverilog -g2012 -v " in normalized_sim 

283 

284 

285def test_verilator_lint_action_min(apio_runner: ApioRunner): 

286 """Tests the verilator_lint_action() function with minimal params.""" 

287 

288 with apio_runner.in_sandbox(): 

289 

290 # -- Create apio scons env. 

291 apio_env = make_test_apio_env( 

292 targets=["lint"], target_params=TargetParams(lint=LintParams()) 

293 ) 

294 

295 # -- Call the tested function with minimal args. 

296 action = verilator_lint_action( 

297 apio_env, extra_params=None, lib_dirs=None, lib_files=None 

298 ) 

299 

300 # -- The return action is a list of two steps, a function to call and 

301 # -- a string with a command. 

302 assert isinstance(action, list) 

303 assert len(action) == 2 

304 assert isinstance(action[0], FunctionAction) 

305 assert isinstance(action[1], str) 

306 

307 # -- Collapse consecutive spaces in the string. 

308 normalized_cmd = re.sub(r"\s+", " ", action[1]) 

309 

310 # -- Verify the string 

311 assert ( 

312 "verilator_bin --lint-only --quiet --bbox-unsup --timing " 

313 "-Wno-TIMESCALEMOD -Wno-MULTITOP -DSYNTHESIZE -DAPIO_SIM=0 " 

314 "--top-module main " 

315 f"_build{os.sep}default{os.sep}hardware.vlt $SOURCES" 

316 == normalized_cmd 

317 ) 

318 

319 

320def test_verilator_lint_action_max(apio_runner: ApioRunner): 

321 """Tests the verilator_lint_action() function with maximal params.""" 

322 

323 with apio_runner.in_sandbox(): 

324 

325 # -- Create apio scons env. 

326 apio_env = make_test_apio_env( 

327 targets=["lint"], 

328 apio_env_params=ApioEnvParams( 

329 verilator_extra_options=["--opt1", "--opt2"] 

330 ), 

331 target_params=TargetParams( 

332 lint=LintParams( 

333 top_module="my_top_module", 

334 ) 

335 ), 

336 ) 

337 

338 # -- Call the tested function with minimal args. 

339 action = verilator_lint_action( 

340 apio_env, 

341 extra_params=["param1", "param2"], 

342 lib_dirs=[Path("dir1"), Path("dir2")], 

343 lib_files=[Path("file1"), Path("file2")], 

344 ) 

345 

346 # -- The return action is a list of two steps, a function to call and 

347 # -- a string with a command. 

348 assert isinstance(action, list) 

349 assert len(action) == 2 

350 assert isinstance(action[0], FunctionAction) 

351 assert isinstance(action[1], str) 

352 

353 # -- Collapse consecutive spaces in the string. 

354 normalized_cmd = re.sub(r"\s+", " ", action[1]) 

355 

356 # -- Verify the string 

357 assert ( 

358 "verilator_bin --lint-only --quiet --bbox-unsup --timing " 

359 "-Wno-TIMESCALEMOD -Wno-MULTITOP -DSYNTHESIZE -DAPIO_SIM=0 " 

360 "--opt1 --opt2 " 

361 '--top-module my_top_module param1 param2 -I"dir1" -I"dir2" ' 

362 f'_build{os.sep}default{os.sep}hardware.vlt "file1" "file2" ' 

363 "$SOURCES" == normalized_cmd 

364 ) 

365 

366 

367def test_print_pnr_report_invalid_percentage(apio_runner: ApioRunner): 

368 """Tests that the pnr report shows '--' for resources with a 

369 percentage that can't be computed or is out of range.""" 

370 

371 with apio_runner.in_sandbox(): 

372 

373 apio_console.configure() 

374 

375 build_report = BuildReport( 

376 arch=ApioArch.ice40, 

377 resources=[ 

378 ResourceReport("ICESTORM_LC", 7680, 768, 10.0), 

379 ResourceReport("ICESTORM_PLL", 0, 2, None), 

380 ], 

381 clocks=[], 

382 ) 

383 

384 with apio_runner.with_logger() as log: 

385 _print_pnr_report(build_report, verbose=True) 

386 

387 # -- Get the table lines of the two resources. 

388 lines = log.out.splitlines() 

389 lc_line = next(line for line in lines if "ICESTORM_LC" in line) 

390 pll_line = next(line for line in lines if "ICESTORM_PLL" in line) 

391 

392 assert "10%" in lc_line 

393 assert "--" in pll_line 

394 assert "%" not in pll_line