product-recognition-demo/recognization/code/test_rebuild_catalog.py
tngo 13ee7426e2
Some checks failed
tests / model-free (pull_request) Has been cancelled
fix: read --shelf-tags before the catalog swap
load_shelf_skus ran after write_catalog, so a missing path or a malformed list
escaped as a raw traceback over a catalog that had already been replaced. It is
now read inside the try, before anything is written, so both cases fail with the
tool's own message and the destination is untouched.

Adds BadShelfListFailsBeforeTheSwapTests, which asserts the clean message and
that the output path does not exist. Re-injecting the ordering fails both tests
with the raw FileNotFoundError. Suite 274 -> 276.
2026-09-28 23:03:08 +09:00

188 lines
7.7 KiB
Python

"""
Contract tests for the enrollment tool's reporting checks.
The refusal rules (size floors, session leakage, name/OCR, duplicates) are what
the tool exists for; these tests cover the two checks that report rather than
refuse, because both are properties of the catalog as a whole and neither is
visible from a single view: cross-SKU near-duplicates, and the completeness
rule that decides whether a bay is live-ready.
"""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
import numpy as np # noqa: E402
from rebuild_catalog import ( # noqa: E402
PYRAMID_FLOOR,
RebuildError,
below_pyramid_floor,
live_ready_verdict,
load_shelf_skus,
text_discriminated_siblings,
)
def artwork(mark=None):
"""A package face; `mark` adds the small text that separates siblings."""
image = np.full((400, 300, 3), 200, dtype="uint8")
cv2.rectangle(image, (20, 20), (280, 380), (40, 40, 180), -1)
cv2.putText(image, "MAYO", (60, 200), cv2.FONT_HERSHEY_SIMPLEX, 2.0, (255, 255, 255), 4)
if mark:
cv2.putText(image, mark, (60, 300), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (255, 255, 255), 3)
return image
class SiblingFlagTests(unittest.TestCase):
"""The near-unknown case: same artwork, different product."""
def make_source(self, images):
root = tempfile.mkdtemp(prefix="siblings-")
for name, image in images.items():
cv2.imwrite(os.path.join(root, name), image)
return root
def test_shared_artwork_across_skus_is_flagged(self):
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x2.jpg": artwork("HALF")})
flagged = text_discriminated_siblings({"sku_x1": ["sku_x1.jpg"], "sku_x2": ["sku_x2.jpg"]}, root)
self.assertEqual(len(flagged), 1, flagged)
self.assertEqual(flagged[0]["sku"], "sku_x1")
self.assertEqual(flagged[0]["sibling_sku"], "sku_x2")
self.assertGreater(flagged[0]["cosine"], 0.98)
def test_distinct_artwork_is_not_flagged(self):
other = np.full((400, 300, 3), 30, dtype="uint8")
cv2.circle(other, (150, 200), 90, (200, 200, 40), -1)
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x2.jpg": other})
self.assertEqual(
text_discriminated_siblings({"sku_x1": ["sku_x1.jpg"], "sku_x2": ["sku_x2.jpg"]}, root), []
)
def test_two_views_of_one_sku_are_not_siblings(self):
"""The same-SKU duplicate rule already covers these; this flag is cross-SKU."""
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x1_2.jpg": artwork()})
self.assertEqual(
text_discriminated_siblings({"sku_x1": ["sku_x1.jpg", "sku_x1_2.jpg"]}, root), []
)
class PyramidFloorTests(unittest.TestCase):
"""The l09 defect: a view too small to store as the protocol's pyramid."""
def make_source(self, height):
root = tempfile.mkdtemp(prefix="floor-")
image = cv2.resize(artwork(), (300, height), interpolation=cv2.INTER_NEAREST)
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), image)
return root
def test_a_small_view_is_reported(self):
root = self.make_source(305)
small = below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root)
self.assertEqual(len(small), 1, small)
self.assertEqual(small[0]["sku"], "sku_x1")
self.assertEqual(small[0]["height"], 305)
self.assertEqual(small[0]["floor"], PYRAMID_FLOOR)
def test_a_full_size_view_is_not_reported(self):
root = self.make_source(1000)
self.assertEqual(below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root), [])
class CompletenessVerdictTests(unittest.TestCase):
"""The enrollment protocol's completeness rule, as a verdict."""
def test_without_a_shelf_list_the_verdict_is_unknown(self):
"""Not a guess: the rule needs the shelf's SKU list to mean anything."""
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, None)
self.assertIsNone(verdict["live_ready"])
self.assertIn("shelf", verdict["reason"])
def test_every_shelf_sku_enrolled_is_live_ready(self):
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, {"sku_a"})
self.assertTrue(verdict["live_ready"])
def test_a_text_only_row_satisfies_the_rule(self):
"""A CSV row with no image still names and vetoes from its text."""
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A", "sku_b": "B"}, {"sku_a", "sku_b"})
self.assertTrue(verdict["live_ready"])
def test_a_shelf_sku_absent_from_the_catalog_fails(self):
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, {"sku_a", "sku_z"})
self.assertFalse(verdict["live_ready"])
self.assertEqual(verdict["missing_from_catalog"], ["sku_z"])
class ShelfTagListTests(unittest.TestCase):
def write(self, text):
path = os.path.join(tempfile.mkdtemp(prefix="tags-"), "tags.json")
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
return path
def test_a_json_list_is_read(self):
self.assertEqual(load_shelf_skus(self.write('["sku_a", "sku_b"]')), {"sku_a", "sku_b"})
def test_one_sku_per_line_is_read(self):
self.assertEqual(load_shelf_skus(self.write("sku_a\nsku_b\n")), {"sku_a", "sku_b"})
def test_no_path_means_no_list(self):
self.assertIsNone(load_shelf_skus(None))
def test_a_json_object_is_refused(self):
with self.assertRaises(RebuildError):
load_shelf_skus(self.write('{"sku_a": 1}'))
class BadShelfListFailsBeforeTheSwapTests(unittest.TestCase):
"""A bad --shelf-tags must not leave a replaced catalog behind.
The list is read before write_catalog runs, so a malformed file or a missing
path fails with the tool's own message and the destination is untouched. Read
after the swap, the same error would escape as a traceback over a catalog
that had already been replaced.
"""
def make_source(self):
root = tempfile.mkdtemp(prefix="guard-src-")
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), artwork())
with open(os.path.join(root, "stock.csv"), "w", encoding="utf-8") as handle:
handle.write("SKU_ID,product_name,price,number_in,number_out,number_on\n")
handle.write("sku_x1,Alpha,100,1,0,1\n")
return root
def run_tool(self, shelf_tags):
from rebuild_catalog import main
source = self.make_source()
output = os.path.join(tempfile.mkdtemp(prefix="guard-out-"), "catalog")
with self.assertRaises(SystemExit) as caught:
main(["--source", source, "--output", output, "--csv",
os.path.join(source, "stock.csv"), "--min-views", "1",
"--ocr", "none", "--shelf-tags", shelf_tags])
return str(caught.exception), output
def test_a_malformed_list_fails_cleanly_and_writes_nothing(self):
path = os.path.join(tempfile.mkdtemp(prefix="bad-tags-"), "tags.json")
with open(path, "w", encoding="utf-8") as handle:
handle.write('{"sku_x1": 1}')
message, output = self.run_tool(path)
self.assertIn("expected a JSON list", message)
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
def test_a_missing_list_fails_cleanly_and_writes_nothing(self):
message, output = self.run_tool("/tmp/definitely-not-a-shelf-list.json")
self.assertIn("No such file", message)
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
if __name__ == "__main__":
unittest.main()