# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # Unit tests for the Fischerscope thickness-report parser. # Pure-Python tests — no Odoo DB needed. Builds synthetic .docx files # matching the real XDAL 600 export layout and verifies extraction. import io from datetime import datetime from odoo.tests.common import TransactionCase # Lazy import inside methods to avoid the circular-import trap that # fires during test-module discovery (the package __init__ pulls in # `lib`; if tests/__init__ also resolves `..lib` at top-level, Python # sees a partially-initialised parent package). class TestFischerscopeParser(TransactionCase): """Round-trip tests against the parser. We build a known-shape .docx in memory, parse it back, and assert the structure matches what the real Fischerscope XDAL 600 produces (see screenshot 2026-05-19).""" @classmethod def setUpClass(cls): super().setUpClass() try: import docx # python-docx — required for tests cls.docx = docx except ImportError: cls.docx = None # Resolve the parser by absolute path at first use — relative # `from ..lib import` at module top trips the test loader's # partially-initialised-package check. from odoo.addons.fusion_plating_certificates.lib import ( fischerscope_parser as _fp, ) cls.fischerscope_parser = _fp def _make_sample_docx(self, with_image=False): """Build a .docx that matches the screenshot layout.""" if not self.docx: self.skipTest('python-docx not available') doc = self.docx.Document() doc.add_paragraph('Fischerscope® XDAL 600') doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030') doc.add_paragraph('Directory: NiP products for flat samples') doc.add_paragraph('Application: 16 / NiP/Al-alloys') doc.add_paragraph('') doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568') doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %') doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %') doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %') doc.add_paragraph('') doc.add_paragraph(' NiP 1 mils Ni 1 % P 1 %') doc.add_paragraph('Mean 0.5689 92.258 7.7415') doc.add_paragraph('Standard Deviation 0.1037 0.9282 0.9282') doc.add_paragraph('CoV (%) 18.22 1.01 11.99') doc.add_paragraph('Range 0.1836 1.8562 1.8562') doc.add_paragraph('Number of readings 3 3 3') doc.add_paragraph('Measuring time 120 sec') doc.add_paragraph('Operator: BK 4755 1') doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM') if with_image: # Embed a tiny valid 1x1 PNG so the image-extraction path # is exercised. Bytes from # https://github.com/mathiasbynens/small/blob/master/png-transparent.png png = bytes.fromhex( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4' '890000000a49444154789c63000100000500010d0a2db40000000049454e44ae' '426082' ) img_buf = io.BytesIO(png) doc.add_picture(img_buf) buf = io.BytesIO() doc.save(buf) return buf.getvalue() # ---- happy path: full Fischerscope export ---------------------------- def test_parse_extracts_three_readings(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.docx', self._make_sample_docx(), ) self.assertTrue(result['success']) self.assertEqual(len(result['readings']), 3) self.assertEqual(result['readings'][0], { 'reading_number': 1, 'nip_mils': 0.6885, 'ni_percent': 91.323, 'p_percent': 8.6771, }) self.assertEqual(result['readings'][2]['reading_number'], 3) def test_parse_extracts_metadata(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.docx', self._make_sample_docx(), ) meta = result['metadata'] self.assertIn('Fischerscope', (meta.get('equipment_model') or '')) self.assertIn('XDAL 600', (meta.get('equipment_model') or '')) self.assertEqual(meta.get('product_ref'), '2805031 / NiP/Al-alloys 2805030') self.assertEqual(meta.get('calibration_std_ref'), 'NiP/Al STD SET SN 100174568') self.assertEqual(meta.get('measuring_time_seconds'), 120) self.assertEqual(meta.get('operator_name'), 'BK') self.assertEqual(meta.get('reading_datetime'), datetime(2026, 5, 15, 12, 24, 46)) def test_parse_extracts_image_when_present(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.docx', self._make_sample_docx(with_image=True), ) self.assertIsNotNone(result['image']) self.assertGreater(len(result['image']), 50) # python-docx writes the relationship type to image; mime is content_type. self.assertTrue((result.get('image_mime') or '').startswith('image/')) def test_parse_handles_no_image(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.docx', self._make_sample_docx(with_image=False), ) self.assertIsNone(result['image']) # ---- fallback / error paths ----------------------------------------- def test_parse_unknown_extension(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.csv', b'irrelevant', ) self.assertFalse(result['success']) self.assertEqual(result['readings'], []) self.assertTrue(result['errors']) self.assertIn('Unsupported', result['errors'][0]) def test_parse_legacy_doc_extension(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.doc', b'%PDF', ) self.assertFalse(result['success']) self.assertIn('.doc', result['errors'][0]) def test_parse_corrupt_docx(self): result = self.fischerscope_parser.parse_fischerscope_file( 'sample.docx', b'not a real docx file', ) self.assertFalse(result['success']) self.assertEqual(result['readings'], []) self.assertTrue(result['errors']) def test_parse_empty_docx_no_readings(self): if not self.docx: self.skipTest('python-docx not available') doc = self.docx.Document() doc.add_paragraph('Just a blank report') buf = io.BytesIO() doc.save(buf) result = self.fischerscope_parser.parse_fischerscope_file( 'blank.docx', buf.getvalue(), ) self.assertFalse(result['success']) self.assertEqual(result['readings'], []) # raw_text should still be populated for debug self.assertIn('blank report', result['raw_text']) # ---- robustness: variation in spacing / channel digits -------------- def test_parse_tolerates_whitespace_variation(self): if not self.docx: self.skipTest('python-docx not available') doc = self.docx.Document() doc.add_paragraph('Calibr. Std. Set TESTSTD SN 999') # Tighter spacing, no channel digit (some exports omit "1") doc.add_paragraph('n=1 NiP= 0.50 mils Ni = 92.0 % P = 8.0 %') # Looser spacing, channel digit "1" doc.add_paragraph('n = 2 NiP 1 = 0.55 mils Ni 1 = 91.5 % P 1 = 8.5 %') buf = io.BytesIO() doc.save(buf) result = self.fischerscope_parser.parse_fischerscope_file( 'variant.docx', buf.getvalue(), ) self.assertTrue(result['success']) self.assertEqual(len(result['readings']), 2) self.assertAlmostEqual(result['readings'][0]['nip_mils'], 0.50) self.assertAlmostEqual(result['readings'][1]['nip_mils'], 0.55)