New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.
browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.
Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.
43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
503 lines
18 KiB
GDScript
503 lines
18 KiB
GDScript
class_name TestBrowserAdapter
|
|
extends GdUnitTestSuite
|
|
## Unit tests for browser_adapter.gd (T-1133, D-254 §4).
|
|
##
|
|
## Scope: the pure translation-layer logic between Oscar's T-1131
|
|
## BrowseRequest/BrowseResponse wire contract and the browser screens' view-
|
|
## models — kind metadata, index row extraction/filtering, and detail
|
|
## payload unwrap + per-kind field mapping. All exercised with plain
|
|
## Dictionary literals standing in for decoded msgpack (no live server, no
|
|
## SimBridge, no protocol.gd round-trip needed to cover this logic).
|
|
##
|
|
## Deliberately NOT covered here: the actual wire encode/decode round-trip
|
|
## (that is protocol.gd's own concern — encode_browse_request/
|
|
## browse_response_from_raw), and the screens' rendering of the adapter's
|
|
## output into ImplantPanel components (covered by the live make atlas
|
|
## verification per the ticket's tiering — the screens are thin renderers
|
|
## over what this adapter already validates).
|
|
const SCRIPT_PATH := "res://ui/implant/apps/browser/browser_adapter.gd"
|
|
|
|
|
|
func _adapter():
|
|
return load(SCRIPT_PATH)
|
|
|
|
|
|
# =============================================================================
|
|
# Kind metadata
|
|
# =============================================================================
|
|
|
|
|
|
func test_kind_order_has_six_entries() -> void:
|
|
var a = _adapter()
|
|
assert_int(a.KIND_ORDER.size()).is_equal(6)
|
|
|
|
|
|
func test_kind_order_matches_d254_v1_list_order() -> void:
|
|
# D-254 §4's v1 entity scope list order: star systems, bodies, stations,
|
|
# corporations, commodities, trait catalog.
|
|
var a = _adapter()
|
|
assert_array(a.KIND_ORDER).is_equal(
|
|
[
|
|
a.KIND_STAR_SYSTEM,
|
|
a.KIND_BODY,
|
|
a.KIND_STATION,
|
|
a.KIND_CORPORATION,
|
|
a.KIND_COMMODITY,
|
|
a.KIND_TRAIT_TEMPLATE,
|
|
]
|
|
)
|
|
|
|
|
|
func test_is_valid_kind_accepts_all_six() -> void:
|
|
var a = _adapter()
|
|
for kind: String in a.KIND_ORDER:
|
|
assert_bool(a.is_valid_kind(kind)).override_failure_message(
|
|
"%s should be a valid kind" % kind
|
|
).is_true()
|
|
|
|
|
|
func test_is_valid_kind_rejects_unknown_string() -> void:
|
|
var a = _adapter()
|
|
assert_bool(a.is_valid_kind("Spaceship")).is_false()
|
|
assert_bool(a.is_valid_kind("")).is_false()
|
|
|
|
|
|
func test_kind_label_every_kind_has_a_non_empty_label() -> void:
|
|
var a = _adapter()
|
|
for kind: String in a.KIND_ORDER:
|
|
assert_str(a.kind_label(kind)).override_failure_message(
|
|
"%s must have a display label" % kind
|
|
).is_not_empty()
|
|
|
|
|
|
func test_kind_label_unknown_kind_falls_back_to_the_kind_string() -> void:
|
|
var a = _adapter()
|
|
assert_str(a.kind_label("Nonsense")).is_equal("Nonsense")
|
|
|
|
|
|
# =============================================================================
|
|
# index_rows — Ready vs. non-Ready responses
|
|
# =============================================================================
|
|
|
|
|
|
func test_index_rows_ready_status_extracts_rows() -> void:
|
|
var a = _adapter()
|
|
var response := {
|
|
"kind": "StarSystem",
|
|
"status": "Ready",
|
|
"index": [
|
|
{"id": "GJ-1", "primary": "GJ-1", "secondary": "K-type"},
|
|
{"id": "GJ-2", "primary": "GJ-2", "secondary": "M-type"},
|
|
],
|
|
}
|
|
var rows: Array = a.index_rows(response)
|
|
assert_int(rows.size()).is_equal(2)
|
|
assert_str(rows[0]["id"]).is_equal("GJ-1")
|
|
assert_str(rows[0]["primary"]).is_equal("GJ-1")
|
|
assert_str(rows[0]["secondary"]).is_equal("K-type")
|
|
|
|
|
|
func test_index_rows_missing_secondary_defaults_to_empty_string_not_null() -> void:
|
|
var a = _adapter()
|
|
var response := {
|
|
"kind": "TraitTemplate",
|
|
"status": "Ready",
|
|
"index": [{"id": "tag_1", "primary": "Some Trait"}],
|
|
}
|
|
var rows: Array = a.index_rows(response)
|
|
assert_str(rows[0]["secondary"]).is_equal("")
|
|
|
|
|
|
func test_index_rows_error_status_returns_empty_array() -> void:
|
|
var a = _adapter()
|
|
var response := {"kind": "Body", "status": "Error", "index": null}
|
|
assert_array(a.index_rows(response)).is_empty()
|
|
|
|
|
|
func test_index_rows_not_found_status_returns_empty_array() -> void:
|
|
# D-254/Oscar's contract: Index requests never produce NotFound in
|
|
# practice, but the adapter must not crash or misbehave if one somehow
|
|
# arrives — treat any non-Ready status uniformly as "no rows".
|
|
var a = _adapter()
|
|
var response := {"kind": "Body", "status": "NotFound", "index": null}
|
|
assert_array(a.index_rows(response)).is_empty()
|
|
|
|
|
|
func test_index_rows_ready_but_index_field_not_an_array_returns_empty() -> void:
|
|
var a = _adapter()
|
|
var response := {"kind": "Body", "status": "Ready", "index": null}
|
|
assert_array(a.index_rows(response)).is_empty()
|
|
|
|
|
|
func test_index_rows_ready_with_genuinely_empty_list_returns_empty_array() -> void:
|
|
# The "unknown body -> Ready with empty list" convention (Oscar's
|
|
# message) must round-trip cleanly, not be conflated with an error.
|
|
var a = _adapter()
|
|
var response := {"kind": "Body", "status": "Ready", "index": []}
|
|
assert_array(a.index_rows(response)).is_empty()
|
|
|
|
|
|
# =============================================================================
|
|
# filter_rows — live search box
|
|
# =============================================================================
|
|
|
|
|
|
func _sample_rows() -> Array:
|
|
return [
|
|
{"id": "gate-corporation", "primary": "Gate Corporation", "secondary": "corporation"},
|
|
{"id": "vethara", "primary": "Vethara", "secondary": "combine"},
|
|
{"id": "free-traders", "primary": "Free Traders Syndic", "secondary": "syndic"},
|
|
]
|
|
|
|
|
|
func test_filter_rows_empty_query_returns_all_rows_unchanged() -> void:
|
|
var a = _adapter()
|
|
var rows := _sample_rows()
|
|
assert_array(a.filter_rows(rows, "")).is_equal(rows)
|
|
|
|
|
|
func test_filter_rows_matches_primary_case_insensitively() -> void:
|
|
var a = _adapter()
|
|
var filtered: Array = a.filter_rows(_sample_rows(), "vethara")
|
|
assert_int(filtered.size()).is_equal(1)
|
|
assert_str(filtered[0]["id"]).is_equal("vethara")
|
|
|
|
|
|
func test_filter_rows_matches_secondary_column() -> void:
|
|
var a = _adapter()
|
|
var filtered: Array = a.filter_rows(_sample_rows(), "syndic")
|
|
assert_int(filtered.size()).is_equal(1)
|
|
assert_str(filtered[0]["id"]).is_equal("free-traders")
|
|
|
|
|
|
func test_filter_rows_matches_id() -> void:
|
|
var a = _adapter()
|
|
var filtered: Array = a.filter_rows(_sample_rows(), "gate-corp")
|
|
assert_int(filtered.size()).is_equal(1)
|
|
assert_str(filtered[0]["id"]).is_equal("gate-corporation")
|
|
|
|
|
|
func test_filter_rows_no_match_returns_empty_array() -> void:
|
|
var a = _adapter()
|
|
assert_array(a.filter_rows(_sample_rows(), "nonexistent-zzz")).is_empty()
|
|
|
|
|
|
func test_filter_rows_partial_word_matches_across_multiple_rows() -> void:
|
|
var a = _adapter()
|
|
# "corp" is a substring of both "Gate Corporation" (primary) and
|
|
# "corporation" (secondary) — same row matches twice, must not duplicate.
|
|
var filtered: Array = a.filter_rows(_sample_rows(), "corp")
|
|
assert_int(filtered.size()).is_equal(1)
|
|
|
|
|
|
# =============================================================================
|
|
# response_status / response_error
|
|
# =============================================================================
|
|
|
|
|
|
func test_response_status_ready() -> void:
|
|
var a = _adapter()
|
|
assert_str(a.response_status({"status": "Ready"})).is_equal("Ready")
|
|
|
|
|
|
func test_response_status_not_a_dictionary_returns_empty_string() -> void:
|
|
var a = _adapter()
|
|
assert_str(a.response_status(null)).is_equal("")
|
|
assert_str(a.response_status("not a dict")).is_equal("")
|
|
|
|
|
|
func test_response_error_returns_message_only_for_error_status() -> void:
|
|
var a = _adapter()
|
|
var response := {"status": "Error", "error": "db unavailable"}
|
|
assert_str(a.response_error(response)).is_equal("db unavailable")
|
|
|
|
|
|
func test_response_error_empty_for_ready_status() -> void:
|
|
var a = _adapter()
|
|
var response := {"status": "Ready", "error": ""}
|
|
assert_str(a.response_error(response)).is_equal("")
|
|
|
|
|
|
func test_response_error_empty_for_not_found_status() -> void:
|
|
var a = _adapter()
|
|
var response := {"status": "NotFound", "error": ""}
|
|
assert_str(a.response_error(response)).is_equal("")
|
|
|
|
|
|
# =============================================================================
|
|
# detail_payload — BrowseDetail enum-variant unwrap
|
|
# =============================================================================
|
|
|
|
|
|
func test_detail_payload_unwraps_kind_keyed_variant() -> void:
|
|
var a = _adapter()
|
|
var response := {
|
|
"kind": "Commodity",
|
|
"status": "Ready",
|
|
"detail": {"Commodity": {"commodity_id": "fusion_fuel", "name": "FUSION FUEL"}},
|
|
}
|
|
var payload: Dictionary = a.detail_payload(response)
|
|
assert_str(payload.get("commodity_id", "")).is_equal("fusion_fuel")
|
|
assert_str(payload.get("name", "")).is_equal("FUSION FUEL")
|
|
|
|
|
|
func test_detail_payload_non_ready_status_returns_empty_dict() -> void:
|
|
var a = _adapter()
|
|
var response := {"kind": "Commodity", "status": "NotFound", "detail": null}
|
|
assert_that(a.detail_payload(response)).is_equal({})
|
|
|
|
|
|
func test_detail_payload_detail_field_not_a_dictionary_returns_empty_dict() -> void:
|
|
var a = _adapter()
|
|
var response := {"kind": "Commodity", "status": "Ready", "detail": null}
|
|
assert_that(a.detail_payload(response)).is_equal({})
|
|
|
|
|
|
func test_detail_payload_falls_back_to_single_key_map_when_kind_key_missing() -> void:
|
|
# Defensive path: some enum-variant encodings may not key exactly on the
|
|
# response's own "kind" string — a single-key map should still unwrap.
|
|
var a = _adapter()
|
|
var response := {
|
|
"kind": "TraitTemplate",
|
|
"status": "Ready",
|
|
"detail": {"SomeOtherVariantName": {"tag": "spice_market"}},
|
|
}
|
|
var payload: Dictionary = a.detail_payload(response)
|
|
assert_str(payload.get("tag", "")).is_equal("spice_market")
|
|
|
|
|
|
# =============================================================================
|
|
# detail_view — per-kind dispatch + field mapping
|
|
# =============================================================================
|
|
|
|
|
|
func test_detail_view_star_system_maps_expected_fields() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"system_id": "GJ-1",
|
|
"proper_name": "Xin Chengdu",
|
|
"star_type": "K",
|
|
"population": 1200000,
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
|
assert_str(view["name"]).is_equal("Xin Chengdu")
|
|
assert_str(view["subtitle"]).is_equal("STAR SYSTEM")
|
|
var rows: Array = view["rows"]
|
|
assert_bool(rows.size() > 0).is_true()
|
|
# Spot-check one row is present with the right value.
|
|
var found := false
|
|
for row: Dictionary in rows:
|
|
if row["label"] == "star type":
|
|
assert_str(row["value"]).is_equal("K")
|
|
found = true
|
|
assert_bool(found).override_failure_message("expected a 'star type' row").is_true()
|
|
|
|
|
|
func test_detail_view_star_system_falls_back_to_system_id_when_unnamed() -> void:
|
|
var a = _adapter()
|
|
var payload := {"system_id": "GJ-999"}
|
|
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
|
assert_str(view["name"]).is_equal("GJ-999")
|
|
|
|
|
|
## Regression test for a bug found via live verification (2026-07-17): a
|
|
## real Body row (an unnamed asteroid belt, GJ0-belt) has proper_name as a
|
|
## PRESENT key with a NULL value (msgpack encodes a SQL NULL column that
|
|
## way; Dictionary.get(key, fallback)'s fallback only fires when the key is
|
|
## ABSENT, not when it's present-and-null) — the naive
|
|
## str(p.get("proper_name", p.get("body_id", "—"))) rendered the literal
|
|
## string "<null>" instead of falling back to body_id. Distinct from
|
|
## test_detail_view_star_system_falls_back_to_system_id_when_unnamed above,
|
|
## which only covers the ABSENT-key case (that test alone did not catch this
|
|
## bug — it needs its own present-but-null case).
|
|
func test_detail_view_falls_back_correctly_when_name_field_is_present_but_null() -> void:
|
|
var a = _adapter()
|
|
var payload := {"body_id": "GJ0-belt", "proper_name": null, "body_type": "asteroid_belt"}
|
|
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
|
|
assert_str(view["name"]).is_equal("GJ0-belt")
|
|
|
|
|
|
## Same present-but-null regression, for the "distance" row's unit-suffix
|
|
## special case (str(p.get("dist_ly", "—")) + " ly" would render "<null> ly"
|
|
## for Sol's own row, where dist_ly is a genuine NULL — distance from Earth
|
|
## to itself is meaningless, not zero).
|
|
func test_detail_view_star_system_distance_row_handles_null_dist_ly() -> void:
|
|
var a = _adapter()
|
|
var payload := {"system_id": "GJ 0", "proper_name": "Sol", "dist_ly": null}
|
|
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
|
var distance_value := ""
|
|
for row: Dictionary in view["rows"]:
|
|
if row["label"] == "distance":
|
|
distance_value = row["value"]
|
|
assert_str(distance_value).is_equal("—")
|
|
|
|
|
|
## Same present-but-null regression, for the two array-summary text-block
|
|
## builders (_presence_summary/_production_chains_summary) — a null entry
|
|
## field must not render as "<null>" inside the summary line.
|
|
func test_detail_view_corporation_presence_entry_with_null_operation_omits_it_cleanly() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"corp_id": "gate-corporation",
|
|
"presence": [
|
|
{"location_id": "GJ1b-S1", "location_type": "station", "primary_operation": null}
|
|
],
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
|
assert_str(view["text"]).contains("GJ1b-S1 (station)")
|
|
assert_str(view["text"]).not_contains("<null>")
|
|
|
|
|
|
func test_detail_view_body_maps_expected_fields() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"body_id": "GJ1c",
|
|
"proper_name": "Chengdu Prime",
|
|
"body_type": "planet",
|
|
"inhabited": true,
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
|
|
assert_str(view["name"]).is_equal("Chengdu Prime")
|
|
assert_str(view["subtitle"]).is_equal("CELESTIAL BODY")
|
|
var inhabited_value := ""
|
|
for row: Dictionary in view["rows"]:
|
|
if row["label"] == "inhabited":
|
|
inhabited_value = row["value"]
|
|
assert_str(inhabited_value).is_equal("yes")
|
|
|
|
|
|
func test_detail_view_station_maps_expected_fields() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"station_id": "GJ1b-S1",
|
|
"proper_name": "Horizon Station",
|
|
"station_type": "horizon",
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_STATION, payload)
|
|
assert_str(view["name"]).is_equal("Horizon Station")
|
|
assert_str(view["subtitle"]).is_equal("STATION")
|
|
|
|
|
|
func test_detail_view_corporation_maps_expected_fields() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"corp_id": "gate-corporation",
|
|
"proper_name": "Gate Corporation",
|
|
"corp_type": "corporation",
|
|
"shadow_economy_access": false,
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
|
assert_str(view["name"]).is_equal("Gate Corporation")
|
|
assert_str(view["subtitle"]).is_equal("CORPORATION")
|
|
var shadow_value := ""
|
|
for row: Dictionary in view["rows"]:
|
|
if row["label"] == "shadow economy access":
|
|
shadow_value = row["value"]
|
|
assert_str(shadow_value).is_equal("no")
|
|
|
|
|
|
func test_detail_view_corporation_with_no_presence_yields_empty_text() -> void:
|
|
var a = _adapter()
|
|
var payload := {"corp_id": "gate-corporation", "presence": []}
|
|
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
|
assert_str(view["text"]).is_equal("")
|
|
|
|
|
|
func test_detail_view_corporation_presence_array_summarized_into_text() -> void:
|
|
# CorporationDetail.presence: Vec<CorpPresenceEntry> (Oscar's T-1131
|
|
# message, 2026-07-17) — {location_id, location_type, primary_operation}.
|
|
var a = _adapter()
|
|
var payload := {
|
|
"corp_id": "gate-corporation",
|
|
"presence": [
|
|
{
|
|
"location_id": "GJ1b-S1",
|
|
"location_type": "station",
|
|
"primary_operation": "refining",
|
|
},
|
|
{"location_id": "GJ-2", "location_type": "system", "primary_operation": ""},
|
|
],
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
|
assert_str(view["text"]).contains("GJ1b-S1 (station) — refining")
|
|
assert_str(view["text"]).contains("GJ-2 (system)")
|
|
|
|
|
|
func test_detail_view_commodity_maps_expected_fields_and_description_text() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"commodity_id": "fusion_fuel",
|
|
"name": "FUSION FUEL",
|
|
"tier": "intermediate",
|
|
"description": "Refined isotopic fuel for gate-rated drives.",
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
|
assert_str(view["name"]).is_equal("FUSION FUEL")
|
|
assert_str(view["subtitle"]).is_equal("COMMODITY")
|
|
assert_str(view["text"]).is_equal("Refined isotopic fuel for gate-rated drives.")
|
|
|
|
|
|
func test_detail_view_commodity_with_no_description_or_chains_yields_empty_text() -> void:
|
|
var a = _adapter()
|
|
var payload := {"commodity_id": "fusion_fuel", "description": "", "produced_by": []}
|
|
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
|
assert_str(view["text"]).is_equal("")
|
|
|
|
|
|
func test_detail_view_commodity_produced_by_chains_summarized_into_text() -> void:
|
|
# CommodityDetail.produced_by: Vec<ProductionChainEntry> with nested
|
|
# inputs: Vec<ChainInputEntry> (Oscar's T-1131 message, 2026-07-17).
|
|
var a = _adapter()
|
|
var payload := {
|
|
"commodity_id": "fusion_fuel",
|
|
"description": "Refined isotopic fuel.",
|
|
"produced_by": [
|
|
{
|
|
"chain_id": "fusion_refining",
|
|
"output_quantity": 1.0,
|
|
"inputs": [
|
|
{"input_commodity_id": "raw_hydrogen", "quantity": 3.0},
|
|
{"input_commodity_id": "catalyst", "quantity": 0.5},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
|
# str() on a float keeps its decimal form (str(1.0) == "1.0", not "1") —
|
|
# asserting against that honest formatting rather than a rounded guess.
|
|
assert_str(view["text"]).contains("Refined isotopic fuel.")
|
|
assert_str(view["text"]).contains("fusion_refining (qty 1.0)")
|
|
assert_str(view["text"]).contains("3.0x raw_hydrogen")
|
|
assert_str(view["text"]).contains("0.5x catalyst")
|
|
|
|
|
|
func test_detail_view_trait_template_maps_expected_fields_and_cultural_description() -> void:
|
|
var a = _adapter()
|
|
var payload := {
|
|
"tag": "spice_market",
|
|
"label": "Spice Market",
|
|
"corridor_pool": "heritage",
|
|
"cultural_description": "A dense stall market smelling of scorched cardamom.",
|
|
}
|
|
var view: Dictionary = a.detail_view(a.KIND_TRAIT_TEMPLATE, payload)
|
|
assert_str(view["name"]).is_equal("Spice Market")
|
|
assert_str(view["subtitle"]).is_equal("TRAIT TEMPLATE")
|
|
assert_str(view["text"]).is_equal("A dense stall market smelling of scorched cardamom.")
|
|
|
|
|
|
func test_detail_view_unknown_kind_returns_empty_shape_not_a_crash() -> void:
|
|
var a = _adapter()
|
|
var view: Dictionary = a.detail_view("Nonsense", {"some": "payload"})
|
|
assert_str(view["name"]).is_equal("")
|
|
assert_array(view["rows"]).is_empty()
|
|
|
|
|
|
func test_detail_view_missing_field_renders_as_em_dash_placeholder() -> void:
|
|
var a = _adapter()
|
|
var view: Dictionary = a.detail_view(a.KIND_STATION, {"station_id": "GJ1b-S1"})
|
|
var docking_value := ""
|
|
for row: Dictionary in view["rows"]:
|
|
if row["label"] == "docking class":
|
|
docking_value = row["value"]
|
|
assert_str(docking_value).is_equal("—")
|