feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with FastAPI 4xx validation. Wires the value through to the inventory PNG encode (non-retro: stored PNGs untouched). Adds a throwaway TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox. Gates: check_requirements PASS, check_coverage PASS, settings tests 8/8.
This commit is contained in:
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
||||
"woo_create_status",
|
||||
"taxonomy_last_sync_at",
|
||||
"taxonomy_anchor",
|
||||
"crop_fill",
|
||||
}
|
||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"woo_create_status": "draft",
|
||||
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||
"crop_fill": 90,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
|
||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||
else:
|
||||
result[k] = DEFAULTS.get(k)
|
||||
if k in NUMERIC_KEYS:
|
||||
try:
|
||||
f = float(result[k])
|
||||
result[k] = int(f) if f.is_integer() else f
|
||||
except (TypeError, ValueError):
|
||||
result[k] = DEFAULTS.get(k)
|
||||
|
||||
return result
|
||||
|
||||
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
},
|
||||
)
|
||||
|
||||
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||
if "crop_fill" in payload:
|
||||
raw = payload["crop_fill"]
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = None
|
||||
if pct is None or not (1 <= pct <= 100):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "validation",
|
||||
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||
"detail": {"field": "crop_fill"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
for k, v in payload.items():
|
||||
if k not in ALL_KEYS:
|
||||
|
||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
Reference in New Issue
Block a user