Spaces:
Sleeping
Sleeping
Commit ·
bd6f3d2
1
Parent(s): 816f700
chore: remove CLAUDE.md and tests from HF Space
Browse filesNot needed in deployment; .gitignore prevents accidental re-add.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .gitignore +2 -0
- CLAUDE.md +0 -51
- tests/test_app.py +0 -249
- tests/test_inventory.py +0 -46
- tests/test_server.py +0 -32
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CLAUDE.md
|
| 2 |
+
tests/
|
CLAUDE.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 1 |
-
# CLAUDE.md
|
| 2 |
-
|
| 3 |
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
-
|
| 5 |
-
## Setup & running
|
| 6 |
-
|
| 7 |
-
```bash
|
| 8 |
-
source venv/bin/activate
|
| 9 |
-
python ui.py # Gradio UI at http://localhost:7860
|
| 10 |
-
```
|
| 11 |
-
|
| 12 |
-
The Radxa inference box (`192.168.68.119:8088`) must be reachable on the LAN. Start llama-server on it with:
|
| 13 |
-
```bash
|
| 14 |
-
taskset -c 4-7 llama-server --threads 4 --jinja --ctx-size 4096 --host 0.0.0.0 --port 8088 \
|
| 15 |
-
-m <model.gguf> --mmproj <mmproj.gguf>
|
| 16 |
-
```
|
| 17 |
-
|
| 18 |
-
## Architecture
|
| 19 |
-
|
| 20 |
-
Two files: `app.py` (logic) + `ui.py` (CSS, layout). Pipeline:
|
| 21 |
-
|
| 22 |
-
1. `encode_image` — PIL resize to 960px longest side → base64 JPEG (A/B tested 2026-06-08: 640px loses numeric codes on small-print instruction sheet photos; 960px = 100% recall, +~60s vs 640; 1280px identical recall at +90s — not worth it. Shop screenshots are already ≤640px so unaffected.)
|
| 23 |
-
2. `call_llama` — POST to `llama-server` OpenAI-compatible endpoint (`/v1/chat/completions`) with image as base64 data URI; CPU inference, ~90–180s depending on image type
|
| 24 |
-
3. `extract_codes` — json_schema constraint → json.loads → numeric extraction; falls back to tolerant regex for non-json modes
|
| 25 |
-
4. Gradio `gr.Blocks` UI — single-page, upload → analyze → display raw output + extracted codes
|
| 26 |
-
|
| 27 |
-
Each request must be a fresh conversation (no message history) — otherwise llama-server reuses the previous image encoding.
|
| 28 |
-
|
| 29 |
-
## Model
|
| 30 |
-
|
| 31 |
-
InternVL3.5-2B Q6_K_L GGUF + mmproj f16 GGUF on Radxa Dragon Q6A (CPU-only, Ubuntu).
|
| 32 |
-
|
| 33 |
-
## Pending (in priority order)
|
| 34 |
-
|
| 35 |
-
1. ~~Humbrol → Tamiya conversion table~~ — done (CSV lookup + mojehobby.pl shop links)
|
| 36 |
-
2. Structured model output (JSON-in-prompt or GBNF grammar instead of free-text regex)
|
| 37 |
-
3. ~~Brand autodetection~~ — solved by brand dropdown (deterministic, no extra work needed)
|
| 38 |
-
4. ~~HF Space deployment~~ — done (`build-small-hackathon/paint_match`, branch `hf-deploy`); Cloudflare Tunnel pending (do weekendu)
|
| 39 |
-
5. Airfix Dogfighter aesthetic UI (deferred until pipeline is solid)
|
| 40 |
-
6. ~~Paint inventory integration~~ — done (gspread + service account; `GOOGLE_SERVICE_ACCOUNT_JSON` + `GOOGLE_SHEET_ID` env vars; sheet "Hakaton", sheet1; owned badge "✓ ISSUED" replaces shop link)
|
| 41 |
-
|
| 42 |
-
## Hackathon constraints
|
| 43 |
-
|
| 44 |
-
"Off-the-grid": inference must stay on the Radxa — no hosted LLM APIs. Cloudflare Tunnel and HF Space are acceptable front-door plumbing. Deadline: 2026-06-15.
|
| 45 |
-
|
| 46 |
-
## Known pitfalls
|
| 47 |
-
|
| 48 |
-
- Port 8080 on the Radxa is taken; llama-server uses 8088.
|
| 49 |
-
- First request after server start is warmup — disregard its timing.
|
| 50 |
-
- macOS `base64` is BSD: no `-w0`. Use `base64 -i file` or `base64 < file`.
|
| 51 |
-
- `hf` not `huggingface-cli` in this venv.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_app.py
DELETED
|
@@ -1,249 +0,0 @@
|
|
| 1 |
-
import sys, os
|
| 2 |
-
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 3 |
-
|
| 4 |
-
from app import tamiya_shop_url, _load_humbrol_tamiya
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def test_url_xf_series():
|
| 8 |
-
assert tamiya_shop_url("XF17", "Sea Blue") == \
|
| 9 |
-
"https://www.mojehobby.pl/products/XF-17-Sea-Blue.html"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def test_url_x_series():
|
| 13 |
-
assert tamiya_shop_url("X5", "Green") == \
|
| 14 |
-
"https://www.mojehobby.pl/products/X-5-Green.html"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def test_url_multiword_name():
|
| 18 |
-
assert tamiya_shop_url("XF25", "Light Sea Grey") == \
|
| 19 |
-
"https://www.mojehobby.pl/products/XF-25-Light-Sea-Grey.html"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def test_url_dotted_name():
|
| 23 |
-
assert tamiya_shop_url("XF12", "J.N. Grey") == \
|
| 24 |
-
"https://www.mojehobby.pl/products/XF-12-JN-Grey.html"
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def test_loader_returns_name():
|
| 28 |
-
table = _load_humbrol_tamiya()
|
| 29 |
-
code, name = table["104"]
|
| 30 |
-
assert code == "XF17"
|
| 31 |
-
assert name == "Sea Blue"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def test_loader_empty_tamiya_returns_empty_strings():
|
| 35 |
-
table = _load_humbrol_tamiya()
|
| 36 |
-
code, name = table["9"]
|
| 37 |
-
assert code == ""
|
| 38 |
-
assert name == ""
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def test_loader_path_uses_data_dir():
|
| 42 |
-
"""_load_humbrol_tamiya must read from data/ subdirectory."""
|
| 43 |
-
import inspect
|
| 44 |
-
source = inspect.getsource(_load_humbrol_tamiya)
|
| 45 |
-
assert '"data"' in source, "loader must reference data/ subdirectory"
|
| 46 |
-
table = _load_humbrol_tamiya()
|
| 47 |
-
assert len(table) > 100
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def test_shop_format_parses_plain_code_list():
|
| 51 |
-
"""Regression: the model returns a plain '- code: name' list even under the
|
| 52 |
-
shop format. The unified regex must parse it (the old shop regex matched none)."""
|
| 53 |
-
from app import FORMATS, strip_parens, parse_entries
|
| 54 |
-
raw = ("The paint codes from the product list are:\n\n"
|
| 55 |
-
"- 11: Silver\n- 33: Matt Black\n"
|
| 56 |
-
"- 118+34: Matt Desert Sand (4 parts 118, 3 parts 34)")
|
| 57 |
-
entries = parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"])
|
| 58 |
-
assert [e["code"] for e in entries] == ["11", "33", "118+34"]
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def test_parser_handles_noisy_shop_output():
|
| 62 |
-
"""Real shop-screenshot output: the model echoes the full product line and
|
| 63 |
-
appends a literal 'CODE: N - Name'. The tolerant parser must still extract
|
| 64 |
-
code+name and dedupe the repeated code on each line."""
|
| 65 |
-
from app import FORMATS, strip_parens, parse_entries, expand_codes
|
| 66 |
-
raw = (
|
| 67 |
-
"- Acrylic Paint 11 - Silver - Metallic - (14ml) - CODE: 11 - Silver\n"
|
| 68 |
-
"- Acrylic Paint 33 - Black - Matt - (14ml) - CODE: 33 - Black\n"
|
| 69 |
-
"- Acrylic Paint 168 - Hemp/Camouflage Beige - Matt - (14ml) - CODE: 168 - Hemp/Camouflage Beige"
|
| 70 |
-
)
|
| 71 |
-
expanded = expand_codes(parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"]))
|
| 72 |
-
by_code = {e["code"]: e["name"] for e in expanded}
|
| 73 |
-
assert by_code == {"11": "Silver", "33": "Black", "168": "Hemp/Camouflage Beige"}
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def test_json_parser_extracts_code_name():
|
| 77 |
-
"""json_schema mode returns a JSON array; parse_json_entries pulls code+name."""
|
| 78 |
-
from app import parse_json_entries
|
| 79 |
-
raw = '[{"code": "11", "name": "Silver"}, {"code": "118+34", "name": "Desert Sand"}]'
|
| 80 |
-
entries = parse_json_entries(raw)
|
| 81 |
-
assert [(e["code"], e["name"]) for e in entries] == \
|
| 82 |
-
[("11", "Silver"), ("118+34", "Desert Sand")]
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def test_json_parser_drops_malformed_codes():
|
| 86 |
-
"""Non-numeric / junk codes are filtered; a non-array yields nothing."""
|
| 87 |
-
from app import parse_json_entries
|
| 88 |
-
raw = '[{"code": "33", "name": "Black"}, {"code": "n/a"}, {"name": "no code"}]'
|
| 89 |
-
assert [e["code"] for e in parse_json_entries(raw)] == ["33"]
|
| 90 |
-
assert parse_json_entries('not json at all') == []
|
| 91 |
-
assert parse_json_entries('{"code": "11"}') == [] # object, not array
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def test_json_parser_extracts_code_from_polluted_field():
|
| 95 |
-
"""json_schema enforces shape but not semantics: the 2B model dumps the whole
|
| 96 |
-
echoed product line into `code`. The parser must pull the numeric code out."""
|
| 97 |
-
from app import parse_json_entries
|
| 98 |
-
raw = ('[{"code": "Acrylic Paint 11 - Silver - Metallic", "name": "Silver - Metallic"},'
|
| 99 |
-
' {"code": "Acrylic Paint 118+34 - Desert Sand", "name": "Desert Sand"}]')
|
| 100 |
-
assert [e["code"] for e in parse_json_entries(raw)] == ["11", "118+34"]
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def test_json_parser_feeds_expand_codes():
|
| 104 |
-
"""The JSON path plugs into the shared downstream: composite split + dedupe."""
|
| 105 |
-
from app import parse_json_entries, expand_codes
|
| 106 |
-
raw = '[{"code": "118+34", "name": "Mix"}, {"code": "11", "name": "Silver"}]'
|
| 107 |
-
expanded = expand_codes(parse_json_entries(raw))
|
| 108 |
-
assert [e["code"] for e in expanded] == ["118", "34", "11"]
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def test_is_paint_name_filters_decals_and_junk():
|
| 112 |
-
"""Decal/stencil refs, bare numbers and years are not paints; colour names and
|
| 113 |
-
a missing name (code still matters) are kept."""
|
| 114 |
-
from app import is_paint_name
|
| 115 |
-
assert is_paint_name("Silver - Metallic") is True
|
| 116 |
-
assert is_paint_name("Gun Metal") is True
|
| 117 |
-
assert is_paint_name(None) is True
|
| 118 |
-
assert is_paint_name("") is True
|
| 119 |
-
assert is_paint_name("Decal No. 87 May 1991") is False
|
| 120 |
-
assert is_paint_name("Stencil 12") is False
|
| 121 |
-
assert is_paint_name("11") is False # name is just a number
|
| 122 |
-
assert is_paint_name("May 1991") is False # year
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def test_extract_entries_drops_decal_entries():
|
| 126 |
-
"""End-to-end on a JSON payload mimicking paper.JPG: decal/junk rows fall out,
|
| 127 |
-
real paint rows survive."""
|
| 128 |
-
import app
|
| 129 |
-
raw = ('[{"code": "87", "name": "Decal No. 87 May 1991"},'
|
| 130 |
-
' {"code": "70", "name": "11"},'
|
| 131 |
-
' {"code": "53", "name": "Gun Metal"},'
|
| 132 |
-
' {"code": "11", "name": "Silver"}]')
|
| 133 |
-
orig = app.OUTPUT_MODE
|
| 134 |
-
app.OUTPUT_MODE = "json"
|
| 135 |
-
try:
|
| 136 |
-
entries = app.extract_entries(raw, app.CODE_REGEX)
|
| 137 |
-
finally:
|
| 138 |
-
app.OUTPUT_MODE = orig
|
| 139 |
-
assert [e["code"] for e in entries] == ["53", "11"]
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def test_json_parser_salvages_truncated_array():
|
| 143 |
-
"""max_tokens can cut the JSON mid-entry; the parser must still recover the
|
| 144 |
-
complete objects before the break instead of losing everything."""
|
| 145 |
-
from app import parse_json_entries
|
| 146 |
-
truncated = ('[\n {"code": "11", "name": "Silver"},\n'
|
| 147 |
-
' {"code": "33", "name": "Black"},\n'
|
| 148 |
-
' {"code": "Acrylic Paint') # cut off mid-entry, no closing ]
|
| 149 |
-
assert [e["code"] for e in parse_json_entries(truncated)] == ["11", "33"]
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def test_meng_format_dropped():
|
| 153 |
-
"""Meng is out of scope for the hackathon (codes don't map to Tamiya)."""
|
| 154 |
-
from app import FORMATS
|
| 155 |
-
assert "meng_color_reference" not in FORMATS
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def test_analyze_final_yield_is_two_value_tuple():
|
| 159 |
-
"""analyze() is a generator (status updates, then a final (raw_str, html_str))."""
|
| 160 |
-
from unittest.mock import patch
|
| 161 |
-
from PIL import Image
|
| 162 |
-
import numpy as np
|
| 163 |
-
from app import analyze
|
| 164 |
-
|
| 165 |
-
img = Image.fromarray(np.zeros((10, 10, 3), dtype=np.uint8))
|
| 166 |
-
with patch("app.call_llama", return_value="- 33: Flat Black\n- 98: Sky Grey"):
|
| 167 |
-
updates = list(analyze(img, "airfix_instruction"))
|
| 168 |
-
|
| 169 |
-
final = updates[-1]
|
| 170 |
-
assert isinstance(final, tuple)
|
| 171 |
-
assert len(final) == 2
|
| 172 |
-
raw, html = final
|
| 173 |
-
assert isinstance(raw, str)
|
| 174 |
-
assert isinstance(html, str)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
def test_encode_image_applies_exif_orientation():
|
| 178 |
-
"""Phone photos carry an EXIF orientation flag; encode_image must straighten
|
| 179 |
-
them. server.py opens raw files (PIL.Image.open) which bypass Gradio's own
|
| 180 |
-
exif_transpose, so the correction has to live in encode_image itself."""
|
| 181 |
-
import base64, io
|
| 182 |
-
from PIL import Image
|
| 183 |
-
from app import encode_image
|
| 184 |
-
|
| 185 |
-
# Portrait 100x200 stored sideways with orientation=6 (rotate to view as landscape).
|
| 186 |
-
img = Image.new("RGB", (100, 200), "white")
|
| 187 |
-
exif = img.getexif()
|
| 188 |
-
exif[274] = 6 # tag 274 = Orientation
|
| 189 |
-
buf = io.BytesIO()
|
| 190 |
-
img.save(buf, format="JPEG", exif=exif)
|
| 191 |
-
buf.seek(0)
|
| 192 |
-
reopened = Image.open(buf) # mirrors PIL.Image.open(path) in server.py
|
| 193 |
-
|
| 194 |
-
decoded = Image.open(io.BytesIO(base64.b64decode(encode_image(reopened))))
|
| 195 |
-
# After exif_transpose the displayed image is landscape: width > height.
|
| 196 |
-
assert decoded.width > decoded.height
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def test_llama_server_defaults_to_lan(monkeypatch):
|
| 200 |
-
import importlib, app
|
| 201 |
-
monkeypatch.delenv("LLAMA_SERVER", raising=False)
|
| 202 |
-
importlib.reload(app)
|
| 203 |
-
assert app.LLAMA_SERVER == "http://192.168.68.119:8088"
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def test_llama_server_reads_env(monkeypatch):
|
| 207 |
-
import importlib, app
|
| 208 |
-
monkeypatch.setenv("LLAMA_SERVER", "https://tunnel.example.com")
|
| 209 |
-
try:
|
| 210 |
-
importlib.reload(app)
|
| 211 |
-
assert app.LLAMA_SERVER == "https://tunnel.example.com"
|
| 212 |
-
finally:
|
| 213 |
-
monkeypatch.delenv("LLAMA_SERVER", raising=False)
|
| 214 |
-
importlib.reload(app)
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
def test_manifest_shows_badge_for_owned_paint(monkeypatch):
|
| 218 |
-
"""When a Tamiya code is in OWNED, the manifest li must carry class='owned'."""
|
| 219 |
-
import inventory, app
|
| 220 |
-
monkeypatch.setattr(inventory, "OWNED", frozenset({"XF17"})) # no dash, matches lookup format
|
| 221 |
-
|
| 222 |
-
from unittest.mock import patch as mpatch
|
| 223 |
-
from PIL import Image
|
| 224 |
-
img = Image.new("RGB", (10, 10))
|
| 225 |
-
|
| 226 |
-
# JSON_SCHEMA produces a bare array. Humbrol 104 → XF17 Sea Blue.
|
| 227 |
-
fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
|
| 228 |
-
with mpatch.object(app, "call_llama", return_value=fake_raw):
|
| 229 |
-
raw, html = app._run_analysis(img, "airfix_shop")
|
| 230 |
-
|
| 231 |
-
assert 'class="owned"' in html, f"Expected owned badge, got: {html}"
|
| 232 |
-
assert "mojehobby" not in html, f"Shop link should not appear for owned paint"
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def test_manifest_shows_shop_link_for_unowned_paint(monkeypatch):
|
| 236 |
-
"""When a Tamiya code is NOT in OWNED, the manifest li must contain a shop link."""
|
| 237 |
-
import inventory, app
|
| 238 |
-
monkeypatch.setattr(inventory, "OWNED", frozenset())
|
| 239 |
-
|
| 240 |
-
from unittest.mock import patch as mpatch
|
| 241 |
-
from PIL import Image
|
| 242 |
-
img = Image.new("RGB", (10, 10))
|
| 243 |
-
|
| 244 |
-
fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
|
| 245 |
-
with mpatch.object(app, "call_llama", return_value=fake_raw):
|
| 246 |
-
raw, html = app._run_analysis(img, "airfix_shop")
|
| 247 |
-
|
| 248 |
-
assert "mojehobby" in html, f"Expected shop link, got: {html}"
|
| 249 |
-
assert 'class="owned"' not in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_inventory.py
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
import sys, os
|
| 2 |
-
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 3 |
-
|
| 4 |
-
import importlib
|
| 5 |
-
from unittest.mock import MagicMock, patch
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def make_sheet_rows(*codes):
|
| 9 |
-
return [[c] for c in codes]
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def test_owned_is_frozenset():
|
| 13 |
-
import inventory
|
| 14 |
-
assert isinstance(inventory.OWNED, frozenset)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def test_owned_empty_when_env_missing(monkeypatch):
|
| 18 |
-
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_JSON", raising=False)
|
| 19 |
-
monkeypatch.delenv("GOOGLE_SHEET_ID", raising=False)
|
| 20 |
-
import inventory
|
| 21 |
-
importlib.reload(inventory)
|
| 22 |
-
assert inventory.OWNED == frozenset()
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def test_owned_normalises_to_uppercase(monkeypatch):
|
| 26 |
-
monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
|
| 27 |
-
monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
|
| 28 |
-
fake_ws = MagicMock()
|
| 29 |
-
fake_ws.get_all_values.return_value = make_sheet_rows("Kod", "x-25", "XF-24", " XF-2 ")
|
| 30 |
-
fake_gc = MagicMock()
|
| 31 |
-
fake_gc.open_by_key.return_value.sheet1 = fake_ws
|
| 32 |
-
with patch("gspread.service_account_from_dict", return_value=fake_gc):
|
| 33 |
-
import inventory
|
| 34 |
-
importlib.reload(inventory)
|
| 35 |
-
assert "X25" in inventory.OWNED
|
| 36 |
-
assert "XF24" in inventory.OWNED
|
| 37 |
-
assert "XF2" in inventory.OWNED
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def test_owned_empty_on_auth_error(monkeypatch):
|
| 41 |
-
monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
|
| 42 |
-
monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
|
| 43 |
-
with patch("gspread.service_account_from_dict", side_effect=Exception("auth failed")):
|
| 44 |
-
import inventory
|
| 45 |
-
importlib.reload(inventory)
|
| 46 |
-
assert inventory.OWNED == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_server.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
import sys, os
|
| 2 |
-
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 3 |
-
|
| 4 |
-
import PIL.Image
|
| 5 |
-
import server
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def test_analyze_opens_filedata_dict_and_returns_manifest(monkeypatch, tmp_path):
|
| 9 |
-
# gr.Server delivers the image as a FileData dict {"path": ...}, not a PIL image.
|
| 10 |
-
# This covers server.analyze's dict-unwrap branch (its only novel logic).
|
| 11 |
-
# Force the regex parser so the line-format mock parses regardless of the
|
| 12 |
-
# default OUTPUT_MODE — this test is about the dict unwrap, not the constraint.
|
| 13 |
-
monkeypatch.setattr("app.OUTPUT_MODE", "free")
|
| 14 |
-
monkeypatch.setattr(
|
| 15 |
-
"app.call_llama",
|
| 16 |
-
lambda img_b64, prompt: "- 33: Black",
|
| 17 |
-
)
|
| 18 |
-
img_path = tmp_path / "swatch.png"
|
| 19 |
-
PIL.Image.new("RGB", (32, 32), "white").save(img_path)
|
| 20 |
-
|
| 21 |
-
updates = list(server.analyze({"path": str(img_path)}, "airfix_shop"))
|
| 22 |
-
|
| 23 |
-
assert len(updates) >= 1
|
| 24 |
-
raw, manifest = updates[-1]
|
| 25 |
-
assert "33" in raw
|
| 26 |
-
assert "Humbrol 33" in manifest
|
| 27 |
-
assert manifest.startswith("<ul>")
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_analyze_handles_no_image():
|
| 31 |
-
updates = list(server.analyze(None, "airfix_shop"))
|
| 32 |
-
assert updates[-1] == ("No image provided.", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|