266 lines
13 KiB
Python
266 lines
13 KiB
Python
import json
|
|
import io
|
|
import http.server
|
|
from urllib.parse import quote
|
|
import math
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
|
|
import r2c
|
|
|
|
|
|
class DrawingReconstructionTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.entities, cls.blocks, cls.raw = r2c.read_dxf(r2c.DEFAULT_DXF)
|
|
cls.model = r2c.extract_model(cls.entities, cls.raw)
|
|
cls.meshes = r2c.make_mesh(cls.model)
|
|
|
|
def test_diameter_and_dimensions_match_both_section_and_table(self):
|
|
m = self.model
|
|
self.assertEqual(m['diameter'], 1000)
|
|
self.assertAlmostEqual(m['length'], 1620)
|
|
self.assertAlmostEqual(m['width'], 1540)
|
|
self.assertAlmostEqual(m['height'], 1486)
|
|
self.assertAlmostEqual(m['axisHeight'], 666)
|
|
self.assertEqual(len(m['table']), 7)
|
|
self.assertEqual([r['D'] for r in m['table']], [700, 800, 900, 1000, 1100, 1200, 1350])
|
|
|
|
def test_inlet_is_rectangular_and_outlet_is_circular(self):
|
|
profiles = self.model['profiles']
|
|
self.assertEqual(profiles[0]['kind'], 'rectangle')
|
|
self.assertAlmostEqual(profiles[0]['halfWidth'] * 2, 1310)
|
|
self.assertAlmostEqual(profiles[0]['top'] + profiles[0]['bottom'], 1205)
|
|
self.assertEqual(profiles[2]['kind'], 'circle')
|
|
self.assertEqual(profiles[2]['radius'], 500)
|
|
self.assertEqual(profiles[2]['x'], 1400)
|
|
self.assertAlmostEqual(profiles[-1]['x'], 1620)
|
|
self.assertAlmostEqual(profiles[-1]['radius'], 615.8)
|
|
|
|
def test_pipe_is_actual_revolved_polyline(self):
|
|
self.assertEqual(self.model['evidence']['pipeProfile'], '195')
|
|
profile = self.model['pipeProfile']
|
|
self.assertAlmostEqual(max(p[0] for p in profile), 2620)
|
|
self.assertAlmostEqual(min(p[1] for p in profile), 500)
|
|
self.assertAlmostEqual(max(p[1] for p in profile), 666)
|
|
|
|
def test_closed_meshes_have_no_boundary_edges(self):
|
|
for name, mesh in self.meshes.items():
|
|
edges = Counter()
|
|
for face in mesh['faces']:
|
|
self.assertEqual(len(set(face)), len(face))
|
|
for a, b in zip(face, face[1:] + face[:1]):
|
|
edges[tuple(sorted((a, b)))] += 1
|
|
self.assertTrue(all(count == 2 for count in edges.values()), name)
|
|
self.assertTrue(all(math.isfinite(n) for p in mesh['vertices'] for n in p))
|
|
|
|
def test_r2c_bounds_match_native_drawing(self):
|
|
pts = self.meshes['r2c']['vertices']
|
|
for axis, expected in enumerate([1620, 1540, 1486]):
|
|
self.assertAlmostEqual(max(p[axis] for p in pts) - min(p[axis] for p in pts), expected, places=5)
|
|
|
|
def test_svg_preserves_curves_sections_and_korean_text(self):
|
|
svg, omitted = r2c.drawing_svg(self.entities, self.blocks)
|
|
self.assertIn('단면 A-A', svg)
|
|
self.assertIn('단면 B-B', svg)
|
|
self.assertIn('1,620', svg)
|
|
self.assertNotIn('%%C', svg)
|
|
self.assertGreater(svg.count('<polyline'), 800)
|
|
self.assertIn('HATCH', omitted)
|
|
|
|
def test_static_package_has_visible_geometry_without_javascript(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
m = r2c.build(output=directory, update_entry=False)
|
|
out = Path(directory)
|
|
source = (out / 'index.html').read_text()
|
|
self.assertNotIn('__INITIAL_SCENE__', source)
|
|
self.assertNotIn('__MODEL_JSON__', source)
|
|
self.assertNotIn('__DRAWING_SVG__', source)
|
|
self.assertNotIn('__VIEWER_JS__', source)
|
|
self.assertIn('function drawingRegions()', source)
|
|
scene = source.split('id="scene"', 1)[1].split('</svg>', 1)[0]
|
|
self.assertGreater(scene.count('<polygon'), 900)
|
|
self.assertTrue((out / 'drawing' / r2c.DEFAULT_DXF.name).is_file())
|
|
self.assertEqual(json.loads((out / 'model.json').read_text())['sha256'], m['sha256'])
|
|
self.assertTrue((out / 'model.obj').read_text().startswith('# R2C'))
|
|
|
|
def test_file_entry_redirects_to_configured_server_port(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
r2c.build(output=directory, update_entry=False, port=8080)
|
|
source = (Path(directory) / 'index.html').read_text()
|
|
self.assertNotIn('__SERVER_PORT__', source)
|
|
self.assertIn("if (location.protocol === 'file:')", source)
|
|
self.assertIn("location.replace('http://localhost:8080/' + location.search + location.hash)", source)
|
|
|
|
def test_web_handler_serves_generated_files_only(self):
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
r2c.build(output=directory, update_entry=False)
|
|
for path, status, content in [
|
|
('/index.html', b'200 OK', b'<polygon'),
|
|
('/model.obj', b'200 OK', b'# R2C'),
|
|
('/drawing/' + quote(r2c.DEFAULT_DXF.name), b'200 OK', b'SECTION'),
|
|
('/.git/config', b'404', b'File not found'),
|
|
]:
|
|
handler = object.__new__(Handler)
|
|
handler.directory = directory
|
|
handler.path = path
|
|
handler.command = 'GET'
|
|
handler.request_version = 'HTTP/1.1'
|
|
handler.requestline = 'GET ' + path + ' HTTP/1.1'
|
|
handler.headers = {}
|
|
handler.wfile = io.BytesIO()
|
|
handler.do_GET()
|
|
response = handler.wfile.getvalue()
|
|
self.assertIn(status, response.split(b'\r\n')[0])
|
|
self.assertIn(content, response)
|
|
|
|
def test_every_table_specification_has_its_own_closed_mesh(self):
|
|
for row in self.model['table']:
|
|
with self.subTest(diameter=row['D']):
|
|
variant = r2c.specification_model(self.model, row)
|
|
self.assertEqual(variant['dimensions'], row)
|
|
mesh = variant['meshes']['r2c']
|
|
for axis, key in enumerate(['L', 'B', 'H']):
|
|
self.assertAlmostEqual(max(p[axis] for p in mesh['vertices']) - min(p[axis] for p in mesh['vertices']), row[key], places=5)
|
|
self.assertAlmostEqual(variant['axisHeight'], row['h6'])
|
|
self.assertAlmostEqual(variant['profiles'][0]['halfWidth'] * 2, row['b3'])
|
|
self.assertAlmostEqual(variant['profiles'][2]['radius'] * 2, row['D'])
|
|
edges = Counter()
|
|
for face in mesh['faces']:
|
|
for a, b in zip(face, face[1:] + face[:1]):
|
|
edges[tuple(sorted((a, b)))] += 1
|
|
self.assertTrue(all(n == 2 for n in edges.values()))
|
|
self.assertTrue(all(math.isfinite(n) for point in mesh['vertices'] for n in point))
|
|
|
|
def test_drawn_specification_retains_exact_source_profiles(self):
|
|
v = r2c.specification_model(self.model, self.model['dimensions'])
|
|
self.assertEqual(v['basis'], 'drawn')
|
|
self.assertEqual(v['profiles'], self.model['profiles'])
|
|
self.assertEqual(v['meshes']['r2c'], self.meshes['r2c'])
|
|
self.assertEqual(v['warnings'], [])
|
|
|
|
def test_nominal_specifications_report_conflicting_socket_dimensions(self):
|
|
v = r2c.specification_model(self.model, self.model['table'][0])
|
|
self.assertEqual(v['basis'], 'table')
|
|
self.assertTrue(v['warnings'])
|
|
self.assertAlmostEqual(v['profiles'][3]['x'], v['length'] - v['dimensions']['L2'])
|
|
self.assertAlmostEqual(v['profiles'][-2]['x'], v['length'] - v['dimensions']['L3'])
|
|
self.assertNotAlmostEqual(v['length'] / self.model['length'], v['width'] / self.model['width'])
|
|
|
|
def test_invalid_table_is_rejected(self):
|
|
bad = dict(self.model['table'][0], B=100)
|
|
with self.assertRaises(ValueError):
|
|
r2c.specification_model(self.model, bad)
|
|
|
|
def api_request(self, raw, filename='upload.dxf', length=None, path='/api/convert'):
|
|
class Handler(r2c.ViewerHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
handler = object.__new__(Handler)
|
|
handler.path = path
|
|
handler.command = 'POST'
|
|
handler.request_version = 'HTTP/1.1'
|
|
handler.requestline = 'POST ' + path + ' HTTP/1.1'
|
|
handler.headers = {'Content-Length': str(len(raw) if length is None else length), 'X-Filename': quote(filename)}
|
|
handler.rfile = io.BytesIO(raw)
|
|
handler.wfile = io.BytesIO()
|
|
handler.do_POST()
|
|
header, body = handler.wfile.getvalue().split(b'\r\n\r\n', 1)
|
|
return int(header.split()[1]), json.loads(body)
|
|
|
|
def test_upload_converts_actual_file_and_provides_all_specs(self):
|
|
status, payload = self.api_request(self.raw, '첨부 도면.dxf')
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['model']['source'], '첨부 도면.dxf')
|
|
self.assertEqual(len(payload['model']['variants']), 7)
|
|
self.assertEqual(payload['model']['sha256'], self.model['sha256'])
|
|
self.assertTrue(payload['drawingSvg'].startswith('<svg'))
|
|
self.assertEqual(r2c.DEFAULT_DXF.read_bytes(), self.raw)
|
|
|
|
def test_second_upload_uses_uploaded_table_instead_of_default_file(self):
|
|
changed = self.raw.replace(b'1,605', b'1,607')
|
|
self.assertNotEqual(changed, self.raw)
|
|
status, payload = self.api_request(changed, 'another.dxf')
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['model']['variants'][0]['length'], 1607)
|
|
self.assertNotEqual(payload['model']['sha256'], self.model['sha256'])
|
|
self.assertEqual(r2c.DEFAULT_DXF.read_bytes(), self.raw)
|
|
|
|
def test_upload_rejects_invalid_oversized_or_truncated_input(self):
|
|
for raw, filename, length, expected in [
|
|
(b'', 'empty.dxf', None, 400),
|
|
(b'not a dxf', 'wrong.dxf', None, 422),
|
|
(b'AutoCAD Binary DXF', 'binary.dxf', None, 422),
|
|
(b'data', 'wrong.pdf', None, 415),
|
|
(b'x', 'large.dxf', r2c.MAX_UPLOAD_BYTES + 1, 413),
|
|
(b'x', 'missing.dxf', 10, 400),
|
|
]:
|
|
with self.subTest(expected=expected, filename=filename):
|
|
status, payload = self.api_request(raw, filename, length)
|
|
self.assertEqual(status, expected)
|
|
self.assertIn('error', payload)
|
|
status, payload = self.api_request(b'x', path='/wrong')
|
|
self.assertEqual(status, 404)
|
|
|
|
def test_inner_profiles_must_leave_positive_wall_thickness(self):
|
|
for changes in ({'D2': 5000}, {'D1': 5000}, {'b1': 5000},
|
|
{'h4': 5000}, {'D1': 10}):
|
|
with self.subTest(changes=changes):
|
|
row = dict(self.model['table'][0], **changes)
|
|
with self.assertRaisesRegex(ValueError, '관로 단면'):
|
|
r2c.specification_model(self.model, row)
|
|
|
|
def test_upload_rejects_dxf_missing_end_marker(self):
|
|
lines = self.raw.splitlines(keepends=True)
|
|
self.assertEqual(lines[-1].strip(), b'EOF')
|
|
truncated = b''.join(lines[:-2])
|
|
status, payload = self.api_request(truncated)
|
|
self.assertEqual(status, 422)
|
|
self.assertIn('EOF', payload['error'])
|
|
|
|
def test_spline_validation_and_valid_linear_interpolation(self):
|
|
def spline(weights=('1', '1'), knots=('0', '0', '1', '1'), degree='1'):
|
|
return {'type': 'SPLINE', 'tags': [
|
|
(71, degree), (10, '0'), (20, '0'), (10, '10'), (20, '20'),
|
|
*[(41, w) for w in weights], *[(40, k) for k in knots]]}
|
|
points = r2c.spline_points(spline())
|
|
self.assertEqual(points[0], [0, 0])
|
|
self.assertEqual(points[32], [5, 10])
|
|
self.assertEqual(points[-1], [10, 20])
|
|
for entity in (spline(weights=('0', '0')), spline(weights=('1',)),
|
|
spline(knots=('0', '1', '0', '1')),
|
|
spline(knots=('0', '0', '0', '0')), spline(degree='-1')):
|
|
with self.subTest(entity=entity), self.assertRaises(ValueError):
|
|
r2c.spline_points(entity)
|
|
|
|
def test_upload_with_invalid_spline_returns_json_error(self):
|
|
lines = self.raw.decode('utf-8-sig').splitlines()
|
|
start = next(i + 2 for i in range(0, len(lines), 2)
|
|
if lines[i].strip() == '2' and lines[i + 1].strip() == 'ENTITIES')
|
|
end = next(i for i in range(start, len(lines), 2)
|
|
if lines[i].strip() == '0' and lines[i + 1].strip() == 'ENDSEC')
|
|
extra = ('0 SPLINE 71 1 10 0 20 0 41 0 10 1 20 1 41 0 '
|
|
'40 0 40 0 40 1 40 1').split()
|
|
raw = '\n'.join(lines[:end] + extra + lines[end:]).encode('utf-8')
|
|
status, payload = self.api_request(raw)
|
|
self.assertEqual(status, 422)
|
|
self.assertIn('SPLINE', payload['error'])
|
|
|
|
def test_nonfinite_coordinates_are_rejected_before_geometry(self):
|
|
with self.assertRaisesRegex(ValueError, '유효하지 않은 좌표'):
|
|
r2c.read_dxf_bytes(b'10\nNaN\n')
|
|
|
|
def test_unrelated_drawing_does_not_produce_a_guessed_model(self):
|
|
with self.assertRaisesRegex(ValueError, '변화치수표'):
|
|
r2c.extract_model([], b'')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|