a8538d3ecc
A blank Variant Position cell previewed position->null and aborted the confirm transaction (variant.position is NOT NULL). Resolve the clear to the variant's file order at diff time so preview and apply stay in lockstep; carry file_order on VariantPlan instead of an identity-keyed map; release the read snapshot before PreviewStale/NothingToApply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
6.0 KiB
Python
147 lines
6.0 KiB
Python
"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8)."""
|
|
from decimal import Decimal
|
|
|
|
from app.domains.products.codec import parse_csv
|
|
from app.domains.products.diff import (
|
|
CatalogImage, CatalogProduct, CatalogVariant, compute_diff,
|
|
)
|
|
from app.domains.products.validate import build_products
|
|
|
|
|
|
def _canon(*lines: str):
|
|
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
|
|
|
|
|
def _catalog_mug(**overrides):
|
|
fields = {
|
|
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
|
|
"product_type": "standalone", "google_product_category": None,
|
|
"tags": ["kitchen"], "status": "active", "published": True,
|
|
} | overrides
|
|
return {
|
|
"moon-mug": CatalogProduct(
|
|
id=1, handle="moon-mug", title=fields["title"],
|
|
option_names=(None, None, None), fields=fields,
|
|
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
|
|
fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"),
|
|
"cost": None, "weight": None, "weight_unit": None,
|
|
"volume": None, "volume_unit": None, "tax_id_1": None,
|
|
"tax_id_2": None, "inventory_tracker": None,
|
|
"inventory_qty": 40, "variant_image": None})],
|
|
images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)],
|
|
)
|
|
}
|
|
|
|
|
|
HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src"
|
|
MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg'
|
|
|
|
|
|
def test_new_handle_classifies_add():
|
|
diff = compute_diff({}, _canon(HEADER, MUG_ROW))
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1
|
|
assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0}
|
|
assert rec["detail"]["set"]["status"] == "active"
|
|
|
|
|
|
def test_identical_file_classifies_unchanged():
|
|
diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
|
assert diff.records[0]["kind"] == "unchanged"
|
|
assert diff.summary["unchanged"] == 1
|
|
|
|
|
|
def test_changed_price_classifies_update_with_before_after():
|
|
row = MUG_ROW.replace("18.00", "21.00")
|
|
diff = compute_diff(_catalog_mug(), _canon(HEADER, row))
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "update"
|
|
[vchange] = rec["detail"]["variants"]
|
|
assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"]
|
|
|
|
|
|
def test_absent_column_untouched_empty_cell_clears():
|
|
# Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change).
|
|
diff = compute_diff(
|
|
_catalog_mug(),
|
|
_canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
|
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
|
)
|
|
assert diff.records[0]["kind"] == "unchanged"
|
|
|
|
|
|
def test_empty_cell_clear_shows_in_diff():
|
|
# Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1).
|
|
diff = compute_diff(
|
|
_catalog_mug(),
|
|
_canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
|
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
|
)
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "update"
|
|
assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"]
|
|
|
|
|
|
def test_new_option_combo_is_variant_add_existing_untouched():
|
|
catalog = _catalog_mug()
|
|
diff = compute_diff(
|
|
catalog,
|
|
_canon("Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
|
"moon-mug,Moon Mug,Size,Large,25.00"),
|
|
)
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "update"
|
|
kinds = [v.get("kind") for v in rec["detail"]["variants"]]
|
|
assert "add" in kinds
|
|
|
|
|
|
POSITION_HEADER = HEADER + ",Variant Position"
|
|
|
|
|
|
def test_matching_variant_position_column_classifies_unchanged():
|
|
# position is an attribute on CatalogVariant (not in fields{}); the compare
|
|
# must read it from there, not invent a before:None.
|
|
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1"))
|
|
assert diff.records[0]["kind"] == "unchanged"
|
|
|
|
|
|
def test_changed_variant_position_reports_honest_before():
|
|
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2"))
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "update"
|
|
[ventry] = rec["detail"]["variants"]
|
|
assert {"field": "position", "before": 1, "after": 2} in ventry["changes"]
|
|
|
|
|
|
def test_blank_position_cell_resolves_to_file_order_unchanged():
|
|
# A present-but-empty Variant Position cell resets to file order (the spec's
|
|
# "defaults to file order"), never to NULL — here file order matches the
|
|
# catalog position, so nothing changes.
|
|
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ","))
|
|
assert diff.records[0]["kind"] == "unchanged"
|
|
|
|
|
|
def test_blank_position_cell_updates_to_file_order():
|
|
catalog = _catalog_mug()
|
|
catalog["moon-mug"].variants[0].position = 2
|
|
diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ","))
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "update"
|
|
[ventry] = rec["detail"]["variants"]
|
|
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
|
|
|
|
|
|
def test_error_product_classifies_error():
|
|
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
|
|
[rec] = diff.records
|
|
assert rec["kind"] == "error"
|
|
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
|
|
|
|
|
|
def test_fingerprint_stable_and_drift_sensitive():
|
|
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
|
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
|
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
|
|
assert d1.fingerprint == d2.fingerprint
|
|
assert d1.fingerprint != d3.fingerprint
|