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
« 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#
25from __future__ import annotations
27import allure
28import os
29import tempfile
30import unittest
32from klayout_pex.pex25d.diagnostics import DiagnosticsReport, Severity
33from klayout_pex.pex25d.reader import ReadError, read_pex25d_text
35from .pex25d_fixtures import (
36 HEADER, MINIMAL, STACK_ONLY, codes, read, read_codes, replacing,
37)
40@allure.parent_suite("Unit Tests")
41@allure.tag("PEX25D", "Reader")
42class Pex25DReaderTest(unittest.TestCase):
43 # ------------------------------------------------------------- happy path
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
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)
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)
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
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
86 # ----------------------------------------------------------- syntax tier
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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)
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))
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))
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')) == []
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'))
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']
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'))
162 # ---------------------------------------------------------------- include
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')
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')
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']
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')