Files
bambuddy/backend/tests/unit/test_slicer_preset_values.py
maziggy b6027b7138 Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.

The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.

resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:

  sidecar_outdated     -> name the fix: update the sidecar image
  sidecar_unavailable  -> the sidecar did not answer
  not_configured       -> no sidecar is configured
  preset_unresolved    -> the previous generic wording

A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.

Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.

Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
2026-08-09 11:25:09 +02:00

98 lines
4.4 KiB
Python

"""Tests for resolving a preset's effective values via the sidecar.
The slice modal's settings panel needs the values a preset actually sets, not
the option schema's compiled-in defaults. Only the sidecar can answer that: a
"Standard" pick is a ``{inherits: ...}`` stub on our side, and local/cloud
presets are deltas whose remainder lives in the sidecar's bundled profile tree.
"""
import json
import httpx
import pytest
from backend.app.services.slicer_api import SlicerApiService, SlicerApiUnavailableError
PROCESS_STUB = json.dumps({"inherits": "0.20mm Standard @BBL X1C", "from": "system"})
def _service(handler) -> SlicerApiService:
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
return SlicerApiService("http://sidecar:3003", client=client)
class TestResolveProfile:
@pytest.mark.asyncio
async def test_returns_the_flattened_values(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/profiles/resolve"
body = json.loads(request.content)
assert body["category"] == "process"
# The stub goes out as an object, not a JSON string.
assert body["profile"]["inherits"] == "0.20mm Standard @BBL X1C"
return httpx.Response(200, json={"profile": {"line_width": "0.42", "wall_loops": "2"}})
service = _service(handler)
result = await service.resolve_profile(PROCESS_STUB, "process")
assert result.values == {"line_width": "0.42", "wall_loops": "2"}
assert result.reason == "ok"
@pytest.mark.asyncio
async def test_a_sidecar_without_the_endpoint_is_reported_as_outdated(self):
# Older images 404 here. This is the dominant case in practice -- an
# install pulls SIDECAR_TAG:-latest regardless of its own release
# channel -- and it is the one with a fix the user can act on, so it
# must not be flattened into the generic failure.
service = _service(lambda request: httpx.Response(404, json={"message": "Not Found"}))
result = await service.resolve_profile(PROCESS_STUB, "process")
assert result.values is None
assert result.reason == "sidecar_outdated"
@pytest.mark.asyncio
async def test_a_sidecar_error_is_not_reported_as_outdated(self):
# A broken sidecar and an old one call for different advice.
service = _service(lambda request: httpx.Response(500, json={"message": "boom"}))
result = await service.resolve_profile(PROCESS_STUB, "process")
assert result.values is None
assert result.reason == "sidecar_unavailable"
@pytest.mark.asyncio
async def test_unreachable_sidecar_still_raises(self):
# Distinct from "too old": the caller reports this as slicing being
# unavailable rather than silently showing defaults forever.
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
with pytest.raises(SlicerApiUnavailableError):
await _service(handler).resolve_profile(PROCESS_STUB, "process")
@pytest.mark.asyncio
async def test_unparseable_preset_content_blames_the_preset(self):
service = _service(lambda request: httpx.Response(200, json={"profile": {}}))
result = await service.resolve_profile("not json", "process")
assert result.values is None
assert result.reason == "preset_unresolved"
@pytest.mark.asyncio
async def test_a_response_without_a_profile_object_returns_no_values(self):
# Guards against reading a differently-shaped body as if it were values.
service = _service(lambda request: httpx.Response(200, json={"ok": True}))
result = await service.resolve_profile(PROCESS_STUB, "process")
assert result.values is None
assert result.reason == "sidecar_unavailable"
@pytest.mark.asyncio
async def test_an_already_flat_preset_round_trips(self):
flat = json.dumps({"line_width": "0.45", "type": "process"})
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "inherits" not in body["profile"]
return httpx.Response(200, json={"profile": json.loads(flat)})
assert (await _service(handler).resolve_profile(flat, "process")).values == {
"line_width": "0.45",
"type": "process",
}