Coverage for tests/pex25d/reader_test.py: 100%

105 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX  

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25from __future__ import annotations 

26 

27import allure 

28import os 

29import tempfile 

30import unittest 

31 

32from klayout_pex.pex25d.diagnostics import DiagnosticsReport, Severity 

33from klayout_pex.pex25d.reader import ReadError, read_pex25d_text 

34 

35from .pex25d_fixtures import ( 

36 HEADER, MINIMAL, STACK_ONLY, codes, read, read_codes, replacing, 

37) 

38 

39 

40@allure.parent_suite("Unit Tests") 

41@allure.tag("PEX25D", "Reader") 

42class Pex25DReaderTest(unittest.TestCase): 

43 # ------------------------------------------------------------- happy path 

44 

45 def test_reads_the_fixture_without_diagnostics(self): 

46 report = DiagnosticsReport() 

47 pex25d_file = read(MINIMAL, report=report) 

48 assert codes(report) == [] 

49 assert pex25d_file.format_version_major == 1 

50 assert pex25d_file.format_version_minor == 0 

51 assert pex25d_file.format_version_suffix == 'rc1' 

52 assert [m.name for m in pex25d_file.metals] == ['met1', 'met2'] 

53 assert [v.name for v in pex25d_file.vias] == ['via1'] 

54 assert [d.name for d in pex25d_file.dielectrics] == ['fox', 'lint', 'ild'] 

55 assert [c.name for c in pex25d_file.conductors] == ['A', 'B'] 

56 assert len(pex25d_file.shapes) == 4 

57 assert len(pex25d_file.terminals) == 1 

58 

59 def test_lengths_become_exact_grid_integers(self): 

60 pex25d_file = read(MINIMAL) 

61 met1 = pex25d_file.metals[0] 

62 assert (met1.zlow, met1.zhigh) == (10000, 14000) 

63 assert pex25d_file.ground_plane.zlow == -4000 

64 box = pex25d_file.shapes[0].box 

65 assert (box.lower_left.x, box.upper_right.x) == (0, 10000) 

66 

67 def test_a_polygon_ring_is_not_closed_explicitly(self): 

68 polygon = read(MINIMAL).shapes[3].polygon 

69 assert len(polygon.outer.points) == 4 

70 assert (polygon.outer.points[0].x, polygon.outer.points[0].y) != \ 

71 (polygon.outer.points[-1].x, polygon.outer.points[-1].y) 

72 

73 def test_a_value_a_modulo_check_would_reject(self): 

74 # 0.3262 % 0.0001 is 9.9999999999e-05 in binary floating point, so an 

75 # exact-modulo grid check rejects a legal file. Divide-round-compare 

76 # does not, and this is the value that first showed it. 

77 pex25d_file = read(replacing('METAL met1 Z_OFFSETS 1.0 1.4', 

78 'METAL met1 Z_OFFSETS 0.3262 1.4')) 

79 assert pex25d_file.metals[0].zlow == 3262 

80 

81 def test_source_refs_are_off_unless_asked_for(self): 

82 assert not read(MINIMAL).metals[0].HasField('source') 

83 with_refs = read(MINIMAL, with_source_refs=True) 

84 assert with_refs.metals[0].source.line > 0 

85 

86 # ----------------------------------------------------------- syntax tier 

87 

88 def test_e0101_record_ends_early(self): 

89 assert 'PEX25D-E0101' in read_codes( 

90 replacing('METAL met1 Z_OFFSETS 1.0 1.4', 'METAL met1 Z_OFFSETS 1.0')) 

91 

92 def test_e0102_wrong_clause_keyword(self): 

93 assert 'PEX25D-E0102' in read_codes( 

94 replacing('VIA via1 CONNECTS met1 met2', 'VIA via1 BETWEEN met1 met2')) 

95 

96 def test_e0103_a_dropped_continuation(self): 

97 # A clause keyword where a record should start is almost always a lost 

98 # trailing backslash, and skipping it would build a different scene. 

99 assert 'PEX25D-E0103' in read_codes( 

100 replacing(' THICKNESS_OVER_WRAPPED 0.1 \\\n THICKNESS_BESIDE_WRAPPED', 

101 ' THICKNESS_OVER_WRAPPED 0.1\n THICKNESS_BESIDE_WRAPPED')) 

102 

103 def test_e0103_trailing_tokens(self): 

104 assert 'PEX25D-E0103' in read_codes( 

105 replacing('METAL met2 Z_OFFSETS 2.0 2.4', 

106 'METAL met2 Z_OFFSETS 2.0 2.4 2.8')) 

107 

108 def test_e0104_bad_literal(self): 

109 assert 'PEX25D-E0104' in read_codes( 

110 replacing('METAL met1 Z_OFFSETS 1.0 1.4', 'METAL met1 Z_OFFSETS one 1.4')) 

111 

112 def test_e0104_rejects_number_forms_python_would_accept(self): 

113 # Fraction('1/2') and int('1_0') both parse; neither is PEX25D. 

114 for literal in ('1/2', '1_0', 'nan', 'inf'): 

115 with self.subTest(literal=literal): 

116 assert 'PEX25D-E0104' in read_codes( 

117 replacing('METAL met1 Z_OFFSETS 1.0 1.4', 

118 f'METAL met1 Z_OFFSETS {literal} 1.4')) 

119 

120 def test_e0105_off_grid(self): 

121 assert 'PEX25D-E0105' in read_codes( 

122 replacing('METAL met1 Z_OFFSETS 1.0 1.4', 

123 'METAL met1 Z_OFFSETS 1.00005 1.4')) 

124 

125 def test_e0106_a_length_before_units(self): 

126 text = ("PEX25D 1.0-rc1\n" 

127 "GROUND_PLANE subs Z_OFFSETS -0.4 -0.1\n" 

128 "UNITS LENGTH um GRID 0.0001\n") 

129 assert 'PEX25D-E0106' in read_codes(text) 

130 

131 def test_e0107_wrong_header(self): 

132 assert 'PEX25D-E0107' in read_codes( 

133 replacing('PEX25D 1.0-rc1', 'C25D 1.0-rc1', MINIMAL)) 

134 

135 def test_e0107_unsupported_major_version(self): 

136 assert 'PEX25D-E0107' in read_codes( 

137 replacing('PEX25D 1.0-rc1', 'PEX25D 2.0', MINIMAL)) 

138 

139 def test_an_unknown_suffix_is_accepted(self): 

140 # The suffix takes no part in compatibility and must never be a reason 

141 # to reject a file. 

142 assert read_codes(replacing('PEX25D 1.0-rc1', 'PEX25D 1.0-beta7')) == [] 

143 

144 def test_e0108_duplicate_meta_key(self): 

145 assert 'PEX25D-E0108' in read_codes( 

146 replacing('META source_cell tiny', 

147 'META source_cell tiny\nMETA technology testpdk')) 

148 

149 def test_w0110_unknown_record_is_a_warning_and_is_skipped(self): 

150 report = DiagnosticsReport() 

151 pex25d_file = read(replacing('CONDUCTOR A neta', 

152 'FUTURE_RECORD whatever 1 2 3\nCONDUCTOR A neta'), 

153 report=report) 

154 assert codes(report) == ['PEX25D-W0110'] 

155 assert report.diagnostics[0].severity == Severity.WARNING 

156 assert [c.name for c in pex25d_file.conductors] == ['A', 'B'] 

157 

158 def test_read_error_is_raised_once_errors_exist(self): 

159 with self.assertRaises(ReadError): 

160 read(replacing('METAL met1 Z_OFFSETS 1.0 1.4', 'METAL met1 Z_OFFSETS')) 

161 

162 # ---------------------------------------------------------------- include 

163 

164 def test_include_flattens_and_records_the_source_file(self): 

165 with tempfile.TemporaryDirectory() as directory: 

166 stack_path = os.path.join(directory, 'stack.pex25d') 

167 top_path = os.path.join(directory, 'top.pex25d') 

168 with open(stack_path, 'w') as f: 

169 f.write('METAL met3 Z_OFFSETS 3.0 3.4\n') 

170 with open(top_path, 'w') as f: 

171 f.write(f'{STACK_ONLY}\nINCLUDE stack.pex25d\n') 

172 

173 with open(top_path, 'rb') as f: 

174 pex25d_file = read_pex25d_text(f.read(), top_path, 

175 with_source_refs=True) 

176 assert [m.name for m in pex25d_file.metals] == ['met1', 'met2', 'met3'] 

177 assert pex25d_file.metals[2].source.file.endswith('stack.pex25d') 

178 

179 def test_e0109_include_cycle(self): 

180 with tempfile.TemporaryDirectory() as directory: 

181 path = os.path.join(directory, 'loop.pex25d') 

182 with open(path, 'w') as f: 

183 f.write(f'{HEADER}\nINCLUDE loop.pex25d\n') 

184 report = DiagnosticsReport() 

185 with open(path, 'rb') as f: 

186 data = f.read() 

187 try: 

188 read_pex25d_text(data, path, report=report) 

189 except ReadError: 

190 pass 

191 # The top-level file is on the include stack from the start, so the 

192 # cycle is caught on the first repeat rather than one level late. 

193 assert codes(report) == ['PEX25D-E0109'] 

194 

195 def test_e0109_unreadable_include(self): 

196 assert 'PEX25D-E0109' in read_codes( 

197 f'{STACK_ONLY}\nINCLUDE ./there-is-no-such-file.pex25d\n')