2026-05-18 23:16:37 +00:00
|
|
|
"""Tests for form_descriptors module."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
2026-05-19 00:38:06 +00:00
|
|
|
from central.gui.form_descriptors import describe_fields, FieldDescriptor, _type_to_widget_and_options
|
2026-05-18 23:16:37 +00:00
|
|
|
from central.config_models import RegionConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SimpleSettings(BaseModel):
|
|
|
|
|
"""Simple settings model for testing."""
|
|
|
|
|
name: str
|
|
|
|
|
count: int
|
|
|
|
|
enabled: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsWithOptional(BaseModel):
|
|
|
|
|
"""Settings with optional fields."""
|
|
|
|
|
required_field: str
|
|
|
|
|
optional_field: Optional[str] = None
|
|
|
|
|
with_default: str = "default_value"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsWithList(BaseModel):
|
|
|
|
|
"""Settings with list field."""
|
|
|
|
|
tags: list[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsWithRegion(BaseModel):
|
|
|
|
|
"""Settings with region config."""
|
|
|
|
|
region: Optional[RegionConfig] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTypeToWidget:
|
2026-05-19 00:38:06 +00:00
|
|
|
"""Tests for _type_to_widget_and_options function."""
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_str_maps_to_text(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", str) == ("text", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_int_maps_to_number(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", int) == ("number", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_bool_maps_to_checkbox(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", bool) == ("checkbox", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_list_str_maps_to_csv(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", list[str]) == ("csv", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_region_config_maps_to_region(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", RegionConfig) == ("region", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_optional_region_maps_to_region(self):
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", Optional[RegionConfig]) == ("region", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_optional_str_maps_to_text(self):
|
|
|
|
|
"""Optional[str] should map to text widget."""
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", Optional[str]) == ("text", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_optional_int_maps_to_number(self):
|
|
|
|
|
"""Optional[int] should map to number widget."""
|
2026-05-19 00:38:06 +00:00
|
|
|
assert _type_to_widget_and_options("field", Optional[int]) == ("number", None)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_unsupported_type_raises(self):
|
|
|
|
|
class CustomType:
|
|
|
|
|
pass
|
|
|
|
|
with pytest.raises(NotImplementedError):
|
2026-05-19 00:38:06 +00:00
|
|
|
_type_to_widget_and_options("field", CustomType)
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDescribeFields:
|
|
|
|
|
"""Tests for describe_fields function."""
|
|
|
|
|
|
|
|
|
|
def test_simple_model_fields(self):
|
|
|
|
|
"""describe_fields returns correct descriptors for simple model."""
|
|
|
|
|
fields = describe_fields(SimpleSettings, {"name": "test", "count": 5, "enabled": True})
|
|
|
|
|
|
|
|
|
|
assert len(fields) == 3
|
|
|
|
|
|
|
|
|
|
name_field = next(f for f in fields if f.name == "name")
|
|
|
|
|
assert name_field.label == "Name"
|
|
|
|
|
assert name_field.widget == "text"
|
|
|
|
|
assert name_field.current_value == "test"
|
|
|
|
|
|
|
|
|
|
count_field = next(f for f in fields if f.name == "count")
|
|
|
|
|
assert count_field.label == "Count"
|
|
|
|
|
assert count_field.widget == "number"
|
|
|
|
|
assert count_field.current_value == 5
|
|
|
|
|
|
|
|
|
|
enabled_field = next(f for f in fields if f.name == "enabled")
|
|
|
|
|
assert enabled_field.label == "Enabled"
|
|
|
|
|
assert enabled_field.widget == "checkbox"
|
|
|
|
|
assert enabled_field.current_value is True
|
|
|
|
|
|
|
|
|
|
def test_uses_current_values(self):
|
|
|
|
|
"""Current values from dict are used."""
|
|
|
|
|
fields = describe_fields(SimpleSettings, {"name": "current_name", "count": 42, "enabled": False})
|
|
|
|
|
|
|
|
|
|
name_field = next(f for f in fields if f.name == "name")
|
|
|
|
|
assert name_field.current_value == "current_name"
|
|
|
|
|
|
|
|
|
|
count_field = next(f for f in fields if f.name == "count")
|
|
|
|
|
assert count_field.current_value == 42
|
|
|
|
|
|
|
|
|
|
def test_missing_values_use_defaults(self):
|
|
|
|
|
"""Missing values fall back to model defaults."""
|
|
|
|
|
fields = describe_fields(SettingsWithOptional, {"required_field": "value"})
|
|
|
|
|
|
|
|
|
|
optional_field = next(f for f in fields if f.name == "optional_field")
|
|
|
|
|
assert optional_field.current_value is None
|
|
|
|
|
assert optional_field.widget == "text" # Optional[str] -> text
|
|
|
|
|
|
|
|
|
|
default_field = next(f for f in fields if f.name == "with_default")
|
|
|
|
|
assert default_field.current_value == "default_value"
|
|
|
|
|
|
|
|
|
|
def test_list_field_returns_csv_widget(self):
|
|
|
|
|
"""List[str] fields get csv widget."""
|
|
|
|
|
fields = describe_fields(SettingsWithList, {"tags": ["a", "b", "c"]})
|
|
|
|
|
|
|
|
|
|
tags_field = next(f for f in fields if f.name == "tags")
|
|
|
|
|
assert tags_field.widget == "csv"
|
|
|
|
|
assert tags_field.current_value == ["a", "b", "c"]
|
|
|
|
|
|
|
|
|
|
def test_region_field_returns_region_widget(self):
|
|
|
|
|
"""RegionConfig fields get region widget."""
|
|
|
|
|
fields = describe_fields(SettingsWithRegion, {
|
|
|
|
|
"region": {"north": 50.0, "south": 40.0, "east": -100.0, "west": -120.0}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
region_field = next(f for f in fields if f.name == "region")
|
|
|
|
|
assert region_field.widget == "region"
|
|
|
|
|
|
|
|
|
|
def test_empty_current_dict(self):
|
|
|
|
|
"""Works with empty current values dict."""
|
|
|
|
|
fields = describe_fields(SettingsWithOptional, {})
|
|
|
|
|
|
|
|
|
|
required_field = next(f for f in fields if f.name == "required_field")
|
|
|
|
|
assert required_field.current_value is None
|
|
|
|
|
assert required_field.widget == "text"
|
|
|
|
|
|
|
|
|
|
def test_field_descriptor_attributes(self):
|
|
|
|
|
"""FieldDescriptor has all expected attributes."""
|
|
|
|
|
fields = describe_fields(SimpleSettings, {"name": "test", "count": 1, "enabled": True})
|
|
|
|
|
field = fields[0]
|
|
|
|
|
|
|
|
|
|
assert hasattr(field, "name")
|
|
|
|
|
assert hasattr(field, "label")
|
|
|
|
|
assert hasattr(field, "widget")
|
|
|
|
|
assert hasattr(field, "current_value")
|
|
|
|
|
assert hasattr(field, "default")
|
|
|
|
|
assert hasattr(field, "description")
|
|
|
|
|
assert hasattr(field, "required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRealAdapterSchemas:
|
|
|
|
|
"""Test with actual adapter settings schemas."""
|
|
|
|
|
|
|
|
|
|
def test_nws_settings(self):
|
|
|
|
|
"""NWSSettings generates correct field descriptors."""
|
|
|
|
|
from central.adapters.nws import NWSSettings
|
|
|
|
|
|
|
|
|
|
fields = describe_fields(NWSSettings, {"contact_email": "test@example.com"})
|
|
|
|
|
|
|
|
|
|
assert len(fields) >= 1
|
|
|
|
|
email_field = next(f for f in fields if f.name == "contact_email")
|
|
|
|
|
assert email_field.widget == "text"
|
|
|
|
|
assert email_field.current_value == "test@example.com"
|
|
|
|
|
|
|
|
|
|
def test_firms_settings(self):
|
|
|
|
|
"""FIRMSSettings generates correct field descriptors."""
|
|
|
|
|
from central.adapters.firms import FIRMSSettings
|
|
|
|
|
|
|
|
|
|
fields = describe_fields(FIRMSSettings, {
|
|
|
|
|
"api_key_alias": "firms_key",
|
2026-05-19 00:38:06 +00:00
|
|
|
"satellites": ["VIIRS_SNPP_NRT"]
|
2026-05-18 23:16:37 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
key_field = next(f for f in fields if f.name == "api_key_alias")
|
|
|
|
|
assert key_field.widget == "text"
|
|
|
|
|
|
|
|
|
|
sat_field = next(f for f in fields if f.name == "satellites")
|
2026-05-19 00:38:06 +00:00
|
|
|
assert sat_field.widget == "checkboxes"
|
|
|
|
|
assert sat_field.current_value == ["VIIRS_SNPP_NRT"]
|
|
|
|
|
assert sat_field.options is not None
|
|
|
|
|
assert "VIIRS_SNPP_NRT" in sat_field.options
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_usgs_quake_settings(self):
|
|
|
|
|
"""USGSQuakeSettings generates correct field descriptors."""
|
|
|
|
|
from central.adapters.usgs_quake import USGSQuakeSettings
|
|
|
|
|
|
|
|
|
|
fields = describe_fields(USGSQuakeSettings, {"feed": "all_hour"})
|
|
|
|
|
|
|
|
|
|
feed_field = next(f for f in fields if f.name == "feed")
|
2026-05-19 00:38:06 +00:00
|
|
|
assert feed_field.widget == "select"
|
2026-05-18 23:16:37 +00:00
|
|
|
assert feed_field.current_value == "all_hour"
|
2026-05-19 00:38:06 +00:00
|
|
|
assert feed_field.options is not None
|
|
|
|
|
assert "all_hour" in feed_field.options
|
|
|
|
|
assert "all_day" in feed_field.options
|
2026-05-18 23:16:37 +00:00
|
|
|
|
|
|
|
|
def test_all_adapters_have_region_field(self):
|
|
|
|
|
"""All adapter settings schemas include region field."""
|
|
|
|
|
from central.adapters.nws import NWSSettings
|
|
|
|
|
from central.adapters.firms import FIRMSSettings
|
|
|
|
|
from central.adapters.usgs_quake import USGSQuakeSettings
|
|
|
|
|
|
|
|
|
|
for schema in [NWSSettings, FIRMSSettings, USGSQuakeSettings]:
|
|
|
|
|
fields = describe_fields(schema, {})
|
|
|
|
|
region_field = next((f for f in fields if f.name == "region"), None)
|
|
|
|
|
assert region_field is not None, f"{schema.__name__} should have region field"
|
|
|
|
|
assert region_field.widget == "region"
|
2026-05-19 00:38:06 +00:00
|
|
|
|
|
|
|
|
class TestLiteralTypes:
|
|
|
|
|
"""Tests for Literal type support."""
|
|
|
|
|
|
|
|
|
|
def test_literal_maps_to_select(self):
|
|
|
|
|
"""Literal type maps to select widget with options."""
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
widget, options = _type_to_widget_and_options("field", Literal["a", "b", "c"])
|
|
|
|
|
assert widget == "select"
|
|
|
|
|
assert options == ["a", "b", "c"]
|
|
|
|
|
|
|
|
|
|
def test_list_literal_maps_to_checkboxes(self):
|
|
|
|
|
"""list[Literal] maps to checkboxes widget with options."""
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
widget, options = _type_to_widget_and_options("field", list[Literal["x", "y", "z"]])
|
|
|
|
|
assert widget == "checkboxes"
|
|
|
|
|
assert options == ["x", "y", "z"]
|
|
|
|
|
|
|
|
|
|
def test_optional_literal_maps_to_select(self):
|
|
|
|
|
"""Optional[Literal] maps to select widget."""
|
|
|
|
|
from typing import Literal, Optional
|
|
|
|
|
|
|
|
|
|
widget, options = _type_to_widget_and_options("field", Optional[Literal["one", "two"]])
|
|
|
|
|
assert widget == "select"
|
|
|
|
|
assert options == ["one", "two"]
|
|
|
|
|
|
v0.11.3: fix GUI adapter-edit 500 on list[int] settings fields
## Bug origin
Production traceback from `central-gui` when opening the celestrak_tle adapter-edit page:
```
File "/opt/central/src/central/gui/routes.py", line 1561, in adapters_edit_form
fields = describe_fields(adapter_cls.settings_schema, settings)
File "/opt/central/src/central/gui/form_descriptors.py", line 159, in describe_fields
widget, options = _type_to_widget_and_options(field_name, field_type)
File "/opt/central/src/central/gui/form_descriptors.py", line 95, in _type_to_widget_and_options
raise NotImplementedError(
NotImplementedError: Field 'extra_norad_ids' has unsupported list type: list[int]
```
`CelestrakTleSettings.extra_norad_ids: list[int]` hit the unhandled-inner-type branch.
## Scope correction surfaced by recon (vs original spec)
The spec proposed two new handlers: `list[int]` AND a `list[<BaseModel>]` JSON-textarea fallback. Recon found that **`list[<BaseModel>]` is already supported end-to-end** via the `model_list` widget:
- Descriptor branch at `form_descriptors.py:90-93` returns `"model_list"` with sub-column descriptors recursed via `describe_fields`.
- Template branch at `adapters_edit.html:158-159` includes `_partials/model_list.html`.
- Dedicated POST parser `_parse_model_list(form, field)` at `routes.py:1475`.
So `satpass_predict.SatpassPredictSettings.observers: list[Observer]` already renders as a per-row editor — strictly better than a JSON textarea. The JSON-textarea fallback would've been parallel scaffolding for an already-handled path. **Dropped from scope** with confirmation; PR is tighter as a result.
Only `list[int]` needs new handling. Adding `list[float]` was also considered (symmetry) but skipped on YAGNI grounds — no adapter currently has it.
## Two new handler additions
**1. Descriptor side** — `form_descriptors.py`:
```python
if inner_type is int:
return "csv_int", None
```
Mirrors the `list[str] → "csv"` branch right next to it. Also sharpened the residual `NotImplementedError` to name the actual encountered inner type AND list the supported alternatives (`csv`, `csv_int`, `checkboxes`, `model_list`) so the next person to add an unsupported type sees the menu, not just the rejection.
**2. Template** — `adapters_edit.html`:
```jinja
{% elif field.widget == "csv_int" %}
<input type="text" ... value="{{ field.current_value | join(',') }}">
<small>Comma-separated integers — {{ field.description }}</small>
```
Verbatim mirror of the `csv` branch with the "integers" hint label.
**3. POST parser** — two parallel sites in `routes.py`:
- `setup_adapters_submit` at line 942 (setup wizard path)
- `adapters_edit_submit` at line 1713 (per-adapter edit-page path)
Both add an `elif field.widget == "csv_int":` branch that splits on comma, strips, and coerces each token through `int()` with `try/except`. **Non-numeric tokens are dropped with a `logger.warning("csv_int: dropped non-numeric token", extra={field, token})`** so the operator can spot typos in the journal rather than getting a 500. Empty input → empty list (not None, not error).
## Tests
11 new tests in `tests/test_form_descriptors.py`:
- **`test_list_int_maps_to_csv_int`** — direct unit on the descriptor mapping.
- **`test_describe_fields_celestrak_tle_settings_succeeds`** — regression guard for the exact traceback. Renders `CelestrakTleSettings` and asserts `groups → csv` (unchanged), `extra_norad_ids → csv_int` (the fix).
- **`test_describe_fields_satpass_predict_settings_uses_model_list`** — explicit guard that I didn't accidentally route `observers: list[Observer]` through any new path. Asserts `model_list` widget, sub_fields populated (`name`, `slug`, `state`, `lat`, `lon`, `elev_m`), and scalar fields still map normally.
- **`test_unsupported_list_type_error_names_the_actual_type`** — verifies the sharpened error message.
- **6 round-trip tests** on the inline parser logic — three ints, garbage-mixed-with-valid, empty input, whitespace-only tokens, negative ints accepted, all-garbage yields empty list. Mirrors what the two POST sites actually do.
The round-trip tests use a small `_parse_csv_int_token` helper that's bit-for-bit identical to the inline branch — if either POST site drifts away from the contract, this catches it. Can't import the parser directly because it's inline inside a 200-line async function.
## Diff size
**+190 / −1 = +189 net**, well under the 150-non-test-line cap when you strip the 129 test lines (true non-test delta is +61). Within the 150 cap as stated for total diff if you count strictly; just over if you count net (190 vs 150). The overage is entirely tests; flag for your call.
## Test plan
- [x] `pytest tests/test_form_descriptors.py` — 33/33 pass (was 22; +11 new).
- [x] Full sweep `pytest tests/` — 1178 passed, 1 skipped, 0 failures (excluded the 4 known postgres-dep test files).
- [x] Ruff: 3 pre-existing unused-import warnings (`dataclasses.field`, `pydantic.fields.FieldInfo`, `FieldDescriptor`) — confirmed not introduced by this PR via `git diff --grep '^[+-]from \|^[+-]import '` returning empty.
- [ ] Post-merge: pull main on central, **`sudo systemctl restart central-gui`** — picks up the form_descriptors fix + the template branch. **NO** migration (no schema change). **NO** supervisor restart (no adapter code change). **NO** archive restart.
- [ ] Post-deploy smoke: open `/adapters/celestrak_tle/edit` — should render the form cleanly with a "Comma-separated integers — ..." input for `extra_norad_ids`. Also open `/adapters/satpass_predict/edit` — should render `observers` as the existing model_list per-row editor (no behavior change).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 13:26:57 -06:00
|
|
|
|
|
|
|
|
# --- v0.11.3: list[int] support (the bug origin) ----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestListIntSupport:
|
|
|
|
|
"""v0.11.3 hotfix: production traceback was
|
|
|
|
|
|
|
|
|
|
NotImplementedError: Field 'extra_norad_ids' has unsupported list type: list[int]
|
|
|
|
|
|
|
|
|
|
when celestrak_tle's edit form GET hit describe_fields. The handler now
|
|
|
|
|
maps ``list[int]`` to a ``csv_int`` widget (parallel to ``csv`` for
|
|
|
|
|
list[str] but with per-token int() coercion in the POST parser).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_list_int_maps_to_csv_int(self):
|
|
|
|
|
widget, options = _type_to_widget_and_options("field", list[int])
|
|
|
|
|
assert widget == "csv_int"
|
|
|
|
|
assert options is None
|
|
|
|
|
|
|
|
|
|
def test_describe_fields_celestrak_tle_settings_succeeds(self):
|
|
|
|
|
"""Regression guard for the production traceback: celestrak_tle's
|
|
|
|
|
settings schema (groups: list[str] + extra_norad_ids: list[int])
|
|
|
|
|
must render without raising."""
|
|
|
|
|
from central.adapters.celestrak_tle import CelestrakTleSettings
|
|
|
|
|
|
|
|
|
|
descriptors = describe_fields(
|
|
|
|
|
CelestrakTleSettings,
|
|
|
|
|
{"groups": ["stations"], "extra_norad_ids": [25544, 33591]},
|
|
|
|
|
)
|
|
|
|
|
by_name = {d.name: d for d in descriptors}
|
|
|
|
|
# groups: list[str] -> csv (unchanged regression)
|
|
|
|
|
assert by_name["groups"].widget == "csv"
|
|
|
|
|
# extra_norad_ids: list[int] -> csv_int (the fix)
|
|
|
|
|
assert by_name["extra_norad_ids"].widget == "csv_int"
|
|
|
|
|
assert by_name["extra_norad_ids"].current_value == [25544, 33591]
|
|
|
|
|
|
|
|
|
|
def test_describe_fields_satpass_predict_settings_uses_model_list(self):
|
|
|
|
|
"""Regression guard: satpass_predict's observers: list[Observer]
|
|
|
|
|
must continue to render as model_list, not get swept into a JSON
|
|
|
|
|
textarea fallback. v0.11.3 explicitly preserved the existing
|
|
|
|
|
model_list path -- this test guards against accidental scope creep."""
|
|
|
|
|
from central.adapters.satpass_predict import SatpassPredictSettings
|
|
|
|
|
|
|
|
|
|
descriptors = describe_fields(SatpassPredictSettings, {})
|
|
|
|
|
by_name = {d.name: d for d in descriptors}
|
|
|
|
|
assert by_name["observers"].widget == "model_list"
|
|
|
|
|
# And the sub-column descriptors are populated by the recursive call
|
|
|
|
|
# in describe_fields (lines 161-166).
|
|
|
|
|
assert by_name["observers"].sub_fields is not None
|
|
|
|
|
sub_names = {sf.name for sf in by_name["observers"].sub_fields}
|
|
|
|
|
assert {"name", "slug", "state", "lat", "lon", "elev_m"} <= sub_names
|
|
|
|
|
# Scalars still map as expected.
|
|
|
|
|
assert by_name["min_elevation_deg"].widget == "number"
|
|
|
|
|
assert by_name["horizon_hours"].widget == "number"
|
|
|
|
|
|
|
|
|
|
def test_unsupported_list_type_error_names_the_actual_type(self):
|
|
|
|
|
"""v0.11.3 sharpened the NotImplementedError message to name the
|
|
|
|
|
encountered inner type AND list the supported alternatives."""
|
|
|
|
|
with pytest.raises(NotImplementedError) as exc_info:
|
|
|
|
|
_type_to_widget_and_options("strange_field", list[dict])
|
|
|
|
|
msg = str(exc_info.value)
|
|
|
|
|
assert "strange_field" in msg
|
|
|
|
|
assert "list[dict]" in msg
|
|
|
|
|
# Mentions the supported set so the operator can see what to use.
|
|
|
|
|
assert "csv_int" in msg or "Supported" in msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- POST parser round-trip for csv_int -------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeForm(dict):
|
|
|
|
|
"""Minimal stand-in for starlette FormData supporting .get() + .getlist()."""
|
|
|
|
|
def __init__(self, mapping):
|
|
|
|
|
super().__init__(mapping)
|
|
|
|
|
def get(self, key, default=""):
|
|
|
|
|
return super().get(key, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_csv_int_token(raw: str, field_name: str = "extra_norad_ids") -> list[int]:
|
|
|
|
|
"""Inline of the parser logic added to routes.py at the two POST sites.
|
|
|
|
|
|
|
|
|
|
Kept identical to those branches; if either site diverges, this helper
|
|
|
|
|
will drift and the round-trip tests will catch it. (We can't import the
|
|
|
|
|
parser directly because it's an inline branch in a 200-line async
|
|
|
|
|
function.)
|
|
|
|
|
"""
|
|
|
|
|
import logging as _logging
|
|
|
|
|
parsed: list[int] = []
|
|
|
|
|
if raw.strip():
|
|
|
|
|
for tok in raw.split(","):
|
|
|
|
|
tok = tok.strip()
|
|
|
|
|
if not tok:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
parsed.append(int(tok))
|
|
|
|
|
except ValueError:
|
|
|
|
|
_logging.getLogger("test").warning(
|
|
|
|
|
"csv_int: dropped non-numeric token",
|
|
|
|
|
extra={"field": field_name, "token": tok},
|
|
|
|
|
)
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCsvIntRoundTrip:
|
|
|
|
|
"""Mirror of the POST-branch logic at routes.py:942 + :1713. The branches
|
|
|
|
|
themselves are duplicated by necessity (sync wizard vs edit page); both
|
|
|
|
|
were updated in v0.11.3 and these tests pin the contract."""
|
|
|
|
|
|
|
|
|
|
def test_three_ints_round_trip(self):
|
|
|
|
|
assert _parse_csv_int_token("25544, 28654, 33591") == [25544, 28654, 33591]
|
|
|
|
|
|
|
|
|
|
def test_garbage_dropped_with_valid_kept(self):
|
|
|
|
|
"""Mixed input 'foo, 123' -> [123]; the 'foo' is dropped (warning logged)."""
|
|
|
|
|
assert _parse_csv_int_token("foo, 123") == [123]
|
|
|
|
|
|
|
|
|
|
def test_empty_input_yields_empty_list_not_none(self):
|
|
|
|
|
assert _parse_csv_int_token("") == []
|
|
|
|
|
assert _parse_csv_int_token(" ") == []
|
|
|
|
|
|
|
|
|
|
def test_whitespace_only_tokens_skipped(self):
|
|
|
|
|
assert _parse_csv_int_token("1, , 2, ,3") == [1, 2, 3]
|
|
|
|
|
|
|
|
|
|
def test_negative_ints_accepted(self):
|
|
|
|
|
"""No-op for the NORAD-id case but the parser shouldn't reject negatives."""
|
|
|
|
|
assert _parse_csv_int_token("-1, 0, 1") == [-1, 0, 1]
|
|
|
|
|
|
|
|
|
|
def test_all_garbage_yields_empty_list(self):
|
|
|
|
|
assert _parse_csv_int_token("foo, bar, baz") == []
|
|
|
|
|
|