94f917b116
- Add edge-center sensor pair tables and delta calculations per wafer family. - Parse and interpolate wafer thickness CSV data for offscreen visualization. - Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping. - Compact E-C Delta text format to fit standard sidebar width. - Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
298 lines
9.8 KiB
Python
298 lines
9.8 KiB
Python
"""Tests for src/pygui/backend/visualization/wafer_map_item.py."""
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||
# descriptors work properly.
|
||
pytestmark = pytest.mark.usefixtures("qapp")
|
||
|
||
|
||
def test_export_image_empty_path():
|
||
"""export_image returns False for empty/None path."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
assert item.export_image("") is False
|
||
assert item.export_image(None) is False
|
||
|
||
|
||
def test_export_image_zero_size_item_fails():
|
||
"""export_image returns False when the item has no geometry to render."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
assert item.export_image("/tmp/test_wafer.png") is False
|
||
|
||
|
||
def test_export_image_unwritable_path_fails(tmp_path):
|
||
"""export_image returns False when the target directory doesn't exist."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(100)
|
||
item.setHeight(100)
|
||
item.values = [10.0, 20.0, 30.0]
|
||
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
|
||
|
||
|
||
def test_thickness_properties():
|
||
"""Thickness data properties exist with correct defaults."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
|
||
assert item._thickness_data == []
|
||
assert item._show_thickness is False
|
||
|
||
|
||
def test_thickness_setter_updates_data():
|
||
"""Setting thicknessData stores [x, y, t2] triples."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item._rebuild_thickness = MagicMock()
|
||
|
||
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||
|
||
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||
assert item._rebuild_thickness.called
|
||
assert item.hasThickness is True
|
||
|
||
|
||
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
|
||
|
||
|
||
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
|
||
p = tmp_path / "thickness.csv"
|
||
p.write_text(header + body)
|
||
return str(p)
|
||
|
||
|
||
def test_parse_thickness_csv_valid(tmp_path):
|
||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||
|
||
path = _write_csv(tmp_path,
|
||
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||
"t,1,2,7,2.5,0,-30.0,40.0\n")
|
||
points, err = parse_thickness_csv(path)
|
||
assert err == ""
|
||
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
|
||
|
||
|
||
def test_parse_thickness_csv_first_slot_only(tmp_path):
|
||
"""Rows after the Slot# changes belong to another wafer and are skipped."""
|
||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||
|
||
path = _write_csv(tmp_path,
|
||
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||
"t,1,1,8,9.9,0,0.0,0.0\n")
|
||
points, err = parse_thickness_csv(path)
|
||
assert err == ""
|
||
assert points == [[10.0, -20.0, 1.5]]
|
||
|
||
|
||
def test_parse_thickness_csv_bad_header(tmp_path):
|
||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||
|
||
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
|
||
points, err = parse_thickness_csv(path)
|
||
assert points == []
|
||
assert "header" in err
|
||
|
||
|
||
def test_parse_thickness_csv_malformed_value(tmp_path):
|
||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||
|
||
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
|
||
points, err = parse_thickness_csv(path)
|
||
assert points == []
|
||
assert "Malformed" in err
|
||
|
||
|
||
def test_parse_thickness_csv_missing_file():
|
||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||
|
||
points, err = parse_thickness_csv("/no/such/file.csv")
|
||
assert points == []
|
||
assert "Unable to read" in err
|
||
|
||
|
||
def test_load_thickness_slot(tmp_path):
|
||
"""loadThickness feeds parsed points into thicknessData."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
|
||
assert item.loadThickness(path) == ""
|
||
assert item.hasThickness is True
|
||
|
||
assert item.loadThickness("/no/such/file.csv") != ""
|
||
# Failed load keeps the previously loaded data.
|
||
assert item.hasThickness is True
|
||
|
||
|
||
def test_show_thickness_setter():
|
||
"""Setting showThickness updates _show_thickness."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
assert item._show_thickness is False
|
||
|
||
item.showThickness = True
|
||
assert item._show_thickness is True
|
||
|
||
|
||
def test_export_image_writes_real_png_headless(tmp_path):
|
||
"""export_image must produce an actual decodable image file without a
|
||
QQuickWindow — the button was silently failing because grabToImage()
|
||
resolves asynchronously and never completes headless/immediately."""
|
||
from PySide6.QtGui import QImage
|
||
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
item.values = [10.0, 20.0, 30.0]
|
||
out = tmp_path / "wafer.png"
|
||
|
||
assert item.export_image(str(out)) is True
|
||
assert out.exists()
|
||
loaded = QImage(str(out))
|
||
assert not loaded.isNull()
|
||
assert loaded.width() == 200
|
||
|
||
|
||
def test_export_image_includes_metrics_footer(tmp_path):
|
||
"""With sensor values loaded, the export gains a footer strip below the
|
||
map carrying the metric readouts (sensors / min / max / avg)."""
|
||
from PySide6.QtGui import QImage
|
||
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
item.values = [10.0, 20.0, 30.0]
|
||
out = tmp_path / "wafer.png"
|
||
|
||
assert item.export_image(str(out)) is True
|
||
loaded = QImage(str(out))
|
||
assert loaded.height() > 200 # footer strip appended
|
||
# footer is painted opaque (dark bg), not transparent
|
||
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
|
||
|
||
|
||
def test_export_image_extra_line_grows_footer(tmp_path):
|
||
"""Live export passes an extra metrics line; the footer gains a second
|
||
row for it. Review export (no extra) keeps the single-row footer."""
|
||
from PySide6.QtGui import QImage
|
||
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
item.values = [10.0, 20.0, 30.0]
|
||
|
||
review = tmp_path / "review.png"
|
||
live = tmp_path / "live.png"
|
||
assert item.export_image(str(review)) is True
|
||
assert item.export_image(str(live), "Frames: 42 Errors: 0") is True
|
||
|
||
h_review = QImage(str(review)).height()
|
||
h_live = QImage(str(live)).height()
|
||
assert h_review > 200 # readout footer present
|
||
assert h_live > h_review # extra live row appended
|
||
|
||
|
||
def test_export_image_highlights_hottest_and_coldest(tmp_path):
|
||
"""The exported map rings the hottest sensor in the high color and the
|
||
coldest in the low color. With no bands set every marker fill is the
|
||
in-range green, so any high/low-colored pixels must come from the rings."""
|
||
from PySide6.QtGui import QImage
|
||
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
item.sensors = [
|
||
{"label": "1", "x": -50.0, "y": 0.0},
|
||
{"label": "2", "x": 0.0, "y": 0.0},
|
||
{"label": "3", "x": 50.0, "y": 0.0},
|
||
]
|
||
item.values = [10.0, 20.0, 30.0]
|
||
out = tmp_path / "wafer.png"
|
||
assert item.export_image(str(out)) is True
|
||
|
||
img = QImage(str(out))
|
||
found = {img.pixelColor(x, y).name()
|
||
for y in range(img.height()) for x in range(img.width())}
|
||
assert "#ef4444" in found # hottest ring (sensorHigh)
|
||
assert "#5b9df5" in found # coldest ring (sensorLow)
|
||
|
||
|
||
def test_show_extremes_toggle_gates_peak_rings(tmp_path):
|
||
"""showExtremes (Display setting) hides the hot/cold rings when off.
|
||
Same pixel probe as the highlight test: with rings off, no high/low
|
||
colored pixels should appear anywhere."""
|
||
from PySide6.QtGui import QImage
|
||
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
assert item.showExtremes is True # rings on by default
|
||
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
item.sensors = [
|
||
{"label": "1", "x": -50.0, "y": 0.0},
|
||
{"label": "2", "x": 0.0, "y": 0.0},
|
||
{"label": "3", "x": 50.0, "y": 0.0},
|
||
]
|
||
item.values = [10.0, 20.0, 30.0]
|
||
item.showExtremes = False
|
||
out = tmp_path / "wafer.png"
|
||
assert item.export_image(str(out)) is True
|
||
|
||
img = QImage(str(out))
|
||
found = {img.pixelColor(x, y).name()
|
||
for y in range(img.height()) for x in range(img.width())}
|
||
assert "#ef4444" not in found
|
||
assert "#5b9df5" not in found
|
||
|
||
|
||
def test_readout_text_full_stats():
|
||
"""Export footer readout carries every READOUT stat with sensor indices.
|
||
|
||
Worked example: [10, 20, 30] → min 10 (#1), max 30 (#3), diff 20,
|
||
avg 20, σ = sqrt(200/3) ≈ 8.16, 3σ ≈ 24.49.
|
||
"""
|
||
from pygui.backend.visualization.wafer_map_item import readout_text
|
||
|
||
assert readout_text([10.0, 20.0, 30.0]) == (
|
||
"Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) "
|
||
"Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49"
|
||
)
|
||
|
||
|
||
def test_readout_text_empty():
|
||
from pygui.backend.visualization.wafer_map_item import readout_text
|
||
|
||
assert readout_text([]) == ""
|
||
|
||
|
||
def test_export_image_no_data_fails(tmp_path):
|
||
"""No file loaded / no stream played -> nothing meaningful to export."""
|
||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||
|
||
item = WaferMapItem()
|
||
item.setWidth(200)
|
||
item.setHeight(200)
|
||
out = tmp_path / "wafer.png"
|
||
|
||
assert item.export_image(str(out)) is False
|
||
assert not out.exists()
|