feat: finish the enrollment protocol's reporting checks
Three items from DECISION §8's enrollment protocol were unimplemented and none of them needed Tier-2 data: - Eval reports now record provenance: catalog hash, label-file hash, image hash and the code revision, so a number can be traced to the inputs that produced it. The catalog hash covers the CSV bytes and the view names, not image bytes: a re-encoded view of the same product is the same catalog. - rebuild_catalog flags cross-SKU near-duplicates as text-discriminated siblings. These are the near-unknown case no threshold rejects - the geometry is real because the sibling shares the brand's artwork - so they are reported rather than refused. - --shelf-tags turns the completeness rule into a live-ready verdict. Without the shelf's SKU list the verdict is 'unknown', not a guess. Adds test_rebuild_catalog.py (11 tests; the tool had no test file). Suite 261 -> 272. Also corrects score_crop_lanes' docstring, which still described the live path as averaging local scores when the local scale decides on the newest crop.
This commit is contained in:
parent
7fff0cf724
commit
e53c1ef59b
4 changed files with 301 additions and 3 deletions
|
|
@ -527,9 +527,12 @@ class RecognitionEngine:
|
||||||
"""Both lanes' evidence for one crop: local scores and text ranking.
|
"""Both lanes' evidence for one crop: local scores and text ranking.
|
||||||
|
|
||||||
The live path accumulates evidence across looks, so it needs the lanes
|
The live path accumulates evidence across looks, so it needs the lanes
|
||||||
separately rather than one fused decision: it averages the local scores
|
separately rather than one fused decision. How it combines them depends
|
||||||
over the track's window and keeps the newest text reading, then decides
|
on the scale: the local lane decides on the newest crop (its value is a
|
||||||
once. The still path calls `decide_crop`, which fuses a single crop.
|
margin over the runner-up, and averaging in an empty observation halves
|
||||||
|
it below the accept line), while the cosine and inlier scales average
|
||||||
|
the scores over the track's window. The text reading is always the
|
||||||
|
newest. The still path calls `decide_crop`, which fuses a single crop.
|
||||||
"""
|
"""
|
||||||
scores = self.score_crop(crop, method)
|
scores = self.score_crop(crop, method)
|
||||||
text_lane = self.identity_text_lane()
|
text_lane = self.identity_text_lane()
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
@ -86,6 +88,55 @@ def load_labels(path: str) -> Dict[str, Any]:
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path: str) -> Optional[str]:
|
||||||
|
"""SHA-256 of a file's bytes, or None when it cannot be read."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
return hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_hash(stock_dir: str) -> Optional[str]:
|
||||||
|
"""SHA-256 over the catalog's CSV bytes and its image names.
|
||||||
|
|
||||||
|
Image bytes are not hashed: a re-encoded view of the same product is the
|
||||||
|
same catalog, and hashing every packshot would make the number move for
|
||||||
|
reasons that do not change a single decision. A changed row or a different
|
||||||
|
set of views does change it.
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
csv_path = os.path.join(stock_dir, "stock.csv")
|
||||||
|
try:
|
||||||
|
with open(csv_path, "rb") as handle:
|
||||||
|
digest.update(handle.read())
|
||||||
|
except OSError:
|
||||||
|
digest.update(b"missing-csv")
|
||||||
|
try:
|
||||||
|
names = sorted(
|
||||||
|
name for name in os.listdir(stock_dir)
|
||||||
|
if os.path.splitext(name)[1].lower() in (".jpg", ".jpeg", ".png")
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
names = []
|
||||||
|
digest.update(b"\n")
|
||||||
|
digest.update("\n".join(names).encode("utf-8"))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def code_revision() -> Optional[str]:
|
||||||
|
"""The checked-out revision, or None outside a git worktree."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
def crop_of(image: Any, box: Sequence[int]) -> Any:
|
def crop_of(image: Any, box: Sequence[int]) -> Any:
|
||||||
x1, y1, x2, y2 = [int(v) for v in box]
|
x1, y1, x2, y2 = [int(v) for v in box]
|
||||||
return image[max(0, y1):y2, max(0, x1):x2]
|
return image[max(0, y1):y2, max(0, x1):x2]
|
||||||
|
|
@ -539,6 +590,15 @@ def main() -> int:
|
||||||
"image": image_path,
|
"image": image_path,
|
||||||
"stock_dir": args.stock_dir,
|
"stock_dir": args.stock_dir,
|
||||||
"labels": args.labels,
|
"labels": args.labels,
|
||||||
|
# The enrollment protocol's versioning rule: every report records the
|
||||||
|
# catalog hash, the label-file hash and the code revision, so a number
|
||||||
|
# can be traced to the exact inputs that produced it.
|
||||||
|
"provenance": {
|
||||||
|
"catalog_hash": catalog_hash(args.stock_dir),
|
||||||
|
"labels_hash": file_hash(args.labels),
|
||||||
|
"image_hash": file_hash(image_path),
|
||||||
|
"code_revision": code_revision(),
|
||||||
|
},
|
||||||
"facings": len(facings),
|
"facings": len(facings),
|
||||||
"leakage": {"excluded": len(refused), "labels": refused},
|
"leakage": {"excluded": len(refused), "labels": refused},
|
||||||
"hub_share_limit": HUB_SHARE,
|
"hub_share_limit": HUB_SHARE,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ images by default), whose image entries use ``file`` plus
|
||||||
``capture_session_id`` or ``session_id``. Pixel equality is not a leak test:
|
``capture_session_id`` or ``session_id``. Pixel equality is not a leak test:
|
||||||
re-cut crops pass it, so leakage is refused by capture session instead.
|
re-cut crops pass it, so leakage is refused by capture session instead.
|
||||||
|
|
||||||
|
Two checks report rather than refuse, because both are properties of the
|
||||||
|
catalog as a whole: cross-SKU near-duplicates are flagged as
|
||||||
|
text-discriminated siblings (the near-unknown case only package text
|
||||||
|
separates), and ``--shelf-tags`` turns the completeness rule into a live-ready
|
||||||
|
verdict. Without that list the verdict is ``unknown``, not a guess.
|
||||||
|
|
||||||
python code/rebuild_catalog.py --source /tmp/lopia-clean \
|
python code/rebuild_catalog.py --source /tmp/lopia-clean \
|
||||||
--output stock-lopia-clean --csv stock-lopia/stock.csv
|
--output stock-lopia-clean --csv stock-lopia/stock.csv
|
||||||
python code/rebuild_catalog.py --source /tmp/lopia-clean \
|
python code/rebuild_catalog.py --source /tmp/lopia-clean \
|
||||||
|
|
@ -278,6 +284,95 @@ def audit(
|
||||||
return kept, refused
|
return kept, refused
|
||||||
|
|
||||||
|
|
||||||
|
def text_discriminated_siblings(
|
||||||
|
kept: Dict[str, List[str]], source: str, names: Optional[Dict[str, str]] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Cross-SKU near-duplicates: the same artwork on two different products.
|
||||||
|
|
||||||
|
These are the near-unknown case no threshold rejects. The geometry is real
|
||||||
|
because the sibling shares the brand's artwork, so only the package text
|
||||||
|
separates them. They are kept - they are legitimately different products -
|
||||||
|
but flagged, because a catalog that enrolls one and not the other will
|
||||||
|
accept the missing one as the enrolled one.
|
||||||
|
"""
|
||||||
|
views: List[Tuple[str, str, np.ndarray]] = []
|
||||||
|
for sku, filenames in sorted(kept.items()):
|
||||||
|
for filename in filenames:
|
||||||
|
image = cv2.imread(os.path.join(source, filename))
|
||||||
|
if image is not None:
|
||||||
|
views.append((sku, filename, image))
|
||||||
|
flagged: List[Dict[str, Any]] = []
|
||||||
|
for index, (sku, filename, image) in enumerate(views):
|
||||||
|
for other_sku, other_name, other in views[index + 1:]:
|
||||||
|
if other_sku == sku or image.shape != other.shape:
|
||||||
|
continue
|
||||||
|
similarity = cosine(image, other)
|
||||||
|
if similarity < DUPLICATE_COSINE:
|
||||||
|
continue
|
||||||
|
flagged.append({
|
||||||
|
"sku": sku,
|
||||||
|
"view": filename,
|
||||||
|
"sibling_sku": other_sku,
|
||||||
|
"sibling_view": other_name,
|
||||||
|
"cosine": round(similarity, 4),
|
||||||
|
"name_similarity": round(
|
||||||
|
name_ocr_similarity(
|
||||||
|
(names or {}).get(sku, ""), (names or {}).get(other_sku, "")
|
||||||
|
), 3,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return flagged
|
||||||
|
|
||||||
|
|
||||||
|
def live_ready_verdict(
|
||||||
|
kept: Dict[str, List[str]], names: Optional[Dict[str, str]], shelf_skus: Optional[Set[str]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""The enrollment protocol's completeness rule, as a verdict.
|
||||||
|
|
||||||
|
A bay is live-ready when every SKU on its shelf tags is enrolled or
|
||||||
|
explicitly listed as text-only. Without a shelf-tag list the rule cannot be
|
||||||
|
evaluated, and the verdict says so rather than guessing.
|
||||||
|
"""
|
||||||
|
catalog = set(names or {})
|
||||||
|
enrolled = set(kept)
|
||||||
|
text_only = catalog - enrolled
|
||||||
|
if shelf_skus is None:
|
||||||
|
return {
|
||||||
|
"live_ready": None,
|
||||||
|
"reason": "no --shelf-tags given; the completeness rule needs the shelf's SKU list",
|
||||||
|
"enrolled": len(enrolled),
|
||||||
|
"text_only": sorted(text_only),
|
||||||
|
}
|
||||||
|
missing = sorted(shelf_skus - enrolled - text_only)
|
||||||
|
return {
|
||||||
|
"live_ready": not missing,
|
||||||
|
"reason": (
|
||||||
|
"every shelf SKU is enrolled or text-only"
|
||||||
|
if not missing
|
||||||
|
else "on the shelf but absent from the catalog: " + ", ".join(missing)
|
||||||
|
),
|
||||||
|
"enrolled": len(enrolled),
|
||||||
|
"text_only": sorted(text_only),
|
||||||
|
"shelf_skus": len(shelf_skus),
|
||||||
|
"missing_from_catalog": missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_shelf_skus(path: Optional[str]) -> Optional[Set[str]]:
|
||||||
|
"""SKUs on the shelf's tags: one per line, or a JSON list."""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
text = handle.read()
|
||||||
|
try:
|
||||||
|
payload = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {line.strip() for line in text.splitlines() if line.strip()}
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return {str(item).strip() for item in payload if str(item).strip()}
|
||||||
|
raise RebuildError(f"{path}: expected a JSON list or one SKU per line")
|
||||||
|
|
||||||
|
|
||||||
def write_catalog(kept: Dict[str, List[str]], source: str, output: str, csv_path: Optional[str]) -> None:
|
def write_catalog(kept: Dict[str, List[str]], source: str, output: str, csv_path: Optional[str]) -> None:
|
||||||
"""Copy the kept views and the whole CSV into a staging dir, then swap it in."""
|
"""Copy the kept views and the whole CSV into a staging dir, then swap it in."""
|
||||||
staging = tempfile.mkdtemp(prefix=".catalog-", dir=os.path.dirname(os.path.abspath(output)) or ".")
|
staging = tempfile.mkdtemp(prefix=".catalog-", dir=os.path.dirname(os.path.abspath(output)) or ".")
|
||||||
|
|
@ -329,6 +424,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
help="image capture metadata (default: SOURCE/manifest.json when present)")
|
help="image capture metadata (default: SOURCE/manifest.json when present)")
|
||||||
parser.add_argument("--eval-manifest",
|
parser.add_argument("--eval-manifest",
|
||||||
help="manifest of evaluation sessions; images captured in them are refused")
|
help="manifest of evaluation sessions; images captured in them are refused")
|
||||||
|
parser.add_argument("--shelf-tags",
|
||||||
|
help="the shelf's SKU list (JSON list or one per line) for the "
|
||||||
|
"completeness rule; without it the verdict is 'unknown'")
|
||||||
parser.add_argument("--json", help="write the audit here")
|
parser.add_argument("--json", help="write the audit here")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -378,6 +476,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
# A row with no kept image is a text-only entry: the text lane still names
|
# A row with no kept image is a text-only entry: the text lane still names
|
||||||
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
|
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
|
||||||
text_only = sorted(set(names) - set(kept))
|
text_only = sorted(set(names) - set(kept))
|
||||||
|
siblings = text_discriminated_siblings(kept, args.source, names)
|
||||||
|
verdict = live_ready_verdict(kept, names, load_shelf_skus(args.shelf_tags))
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"source": args.source,
|
"source": args.source,
|
||||||
|
|
@ -389,6 +489,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
"min_name_ocr": MIN_NAME_OCR,
|
"min_name_ocr": MIN_NAME_OCR,
|
||||||
"ocr": engine.name if ocr_engine is not None else "skipped",
|
"ocr": engine.name if ocr_engine is not None else "skipped",
|
||||||
"text_only_skus": text_only,
|
"text_only_skus": text_only,
|
||||||
|
"text_discriminated_siblings": siblings,
|
||||||
|
"completeness": verdict,
|
||||||
"skus": {sku: views for sku, views in sorted(kept.items())},
|
"skus": {sku: views for sku, views in sorted(kept.items())},
|
||||||
"refused": refused,
|
"refused": refused,
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +503,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
print(f" {sku:<10} {len(views)} view(s): {', '.join(views)}")
|
print(f" {sku:<10} {len(views)} view(s): {', '.join(views)}")
|
||||||
if text_only:
|
if text_only:
|
||||||
print(f"\ntext-only CSV row(s), no enrolled image ({len(text_only)}): {', '.join(text_only)}")
|
print(f"\ntext-only CSV row(s), no enrolled image ({len(text_only)}): {', '.join(text_only)}")
|
||||||
|
if siblings:
|
||||||
|
print(f"\ntext-discriminated sibling(s) ({len(siblings)}):")
|
||||||
|
for row in siblings:
|
||||||
|
print(f" {row['sku']:<10} {row['view']:<20} ~ {row['sibling_sku']} "
|
||||||
|
f"({row['sibling_view']}) cosine {row['cosine']}")
|
||||||
|
if verdict["live_ready"] is None:
|
||||||
|
print(f"\ncompleteness: unknown - {verdict['reason']}")
|
||||||
|
elif verdict["live_ready"]:
|
||||||
|
print(f"\ncompleteness: live-ready ({verdict['reason']})")
|
||||||
|
else:
|
||||||
|
print(f"\ncompleteness: NOT live-ready - {verdict['reason']}")
|
||||||
if refused:
|
if refused:
|
||||||
print(f"\nrefused {len(refused)} view(s):")
|
print(f"\nrefused {len(refused)} view(s):")
|
||||||
for row in refused:
|
for row in refused:
|
||||||
|
|
|
||||||
122
recognization/code/test_rebuild_catalog.py
Normal file
122
recognization/code/test_rebuild_catalog.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
RebuildError,
|
||||||
|
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 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}'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue