arrayview 0.35.3__tar.gz → 0.36.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {arrayview-0.35.3 → arrayview-0.36.0}/PKG-INFO +1 -1
  2. {arrayview-0.35.3 → arrayview-0.36.0}/pyproject.toml +1 -1
  3. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launcher.py +44 -0
  4. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_loading.py +7 -3
  5. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_query.py +8 -0
  6. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_session.py +5 -0
  7. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_viewer.html +64 -15
  8. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_extension.py +46 -2
  9. arrayview-0.36.0/src/arrayview/arrayview-opener.vsix +0 -0
  10. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_api.py +1 -1
  11. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_cli.py +9 -1
  12. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_instance_registry.py +3 -1
  13. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_invocation_contract_matrix.py +2 -0
  14. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_diagnostics.py +3 -0
  15. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_execution_ownership.py +4 -2
  16. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_plan.py +5 -2
  17. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_lifecycle_contract.py +15 -5
  18. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_public_invocation_regressions.py +1 -0
  19. arrayview-0.36.0/tests/test_value_formatting.py +126 -0
  20. arrayview-0.35.3/src/arrayview/arrayview-opener.vsix +0 -0
  21. {arrayview-0.35.3 → arrayview-0.36.0}/.gitignore +0 -0
  22. {arrayview-0.35.3 → arrayview-0.36.0}/LICENSE +0 -0
  23. {arrayview-0.35.3 → arrayview-0.36.0}/README.md +0 -0
  24. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/ARCHITECTURE.md +0 -0
  25. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/__init__.py +0 -0
  26. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/__main__.py +0 -0
  27. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_analysis.py +0 -0
  28. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_app.py +0 -0
  29. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_codex_open.py +0 -0
  30. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_config.py +0 -0
  31. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_dicom.py +0 -0
  32. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_diff.py +0 -0
  33. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_icon.png +0 -0
  34. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_imaging.py +0 -0
  35. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_instance_registry.py +0 -0
  36. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_io.py +0 -0
  37. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launch_plan.py +0 -0
  38. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launch_trace.py +0 -0
  39. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_lifecycle.py +0 -0
  40. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_overlays.py +0 -0
  41. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_platform.py +0 -0
  42. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_render.py +0 -0
  43. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_analysis.py +0 -0
  44. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_drop.py +0 -0
  45. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_export.py +0 -0
  46. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_persistence.py +0 -0
  47. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_preferences.py +0 -0
  48. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_preload.py +0 -0
  49. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_rendering.py +0 -0
  50. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_segmentation.py +0 -0
  51. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_state.py +0 -0
  52. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_vectorfield.py +0 -0
  53. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_websocket.py +0 -0
  54. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_segmentation.py +0 -0
  55. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_server.py +0 -0
  56. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_session_spec.py +0 -0
  57. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_shell.html +0 -0
  58. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_synthetic_mri.py +0 -0
  59. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_torch.py +0 -0
  60. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vectorfield.py +0 -0
  61. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode.py +0 -0
  62. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_browser.py +0 -0
  63. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_signal.py +0 -0
  64. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/gsap.min.js +0 -0
  65. {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/todo.md +0 -0
  66. {arrayview-0.35.3 → arrayview-0.36.0}/tests/capture_patient_loading.py +0 -0
  67. {arrayview-0.35.3 → arrayview-0.36.0}/tests/capture_v_animation.py +0 -0
  68. {arrayview-0.35.3 → arrayview-0.36.0}/tests/conftest.py +0 -0
  69. {arrayview-0.35.3 → arrayview-0.36.0}/tests/lifecycle_matrix.py +0 -0
  70. {arrayview-0.35.3 → arrayview-0.36.0}/tests/make_vectorfield_test_arrays.py +0 -0
  71. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_backend_shared.py +0 -0
  72. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_browser.py +0 -0
  73. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_codex_open.py +0 -0
  74. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_colorbar_hover_highlight.py +0 -0
  75. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_command_reachability.py +0 -0
  76. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_config.py +0 -0
  77. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_cross_mode_parametrized.py +0 -0
  78. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_dicom.py +0 -0
  79. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_display_opener_results.py +0 -0
  80. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_doctor_commands.py +0 -0
  81. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_drop_import.py +0 -0
  82. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_interactions.py +0 -0
  83. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_io.py +0 -0
  84. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_large_arrays.py +0 -0
  85. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_blackbox.py +0 -0
  86. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_trace.py +0 -0
  87. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_loading_server.py +0 -0
  88. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_consistency.py +0 -0
  89. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_matrix.py +0 -0
  90. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_roundtrip.py +0 -0
  91. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_nifti_series.py +0 -0
  93. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_preferences_ui.py +0 -0
  94. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_rgb_pixel_art.py +0 -0
  95. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_safe_server_stop.py +0 -0
  96. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_server_identity_status.py +0 -0
  97. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_session_spec.py +0 -0
  98. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_spawn_ownership.py +0 -0
  99. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_torch.py +0 -0
  100. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.35.3 → arrayview-0.36.0}/tests/ui_audit.py +0 -0
  106. {arrayview-0.35.3 → arrayview-0.36.0}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.35.3 → arrayview-0.36.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.35.3
3
+ Version: 0.36.0
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.35.3"
7
+ version = "0.36.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -2590,6 +2590,11 @@ _PERSIST_DAEMON_CONNECT_TIMEOUT_SECONDS = float(
2590
2590
  _PERSIST_DAEMON_IDLE_SECONDS = float(
2591
2591
  os.environ.get("ARRAYVIEW_PERSIST_IDLE_SECONDS", "1800")
2592
2592
  )
2593
+ # How long a daemon whose initial load failed keeps serving so the opener can
2594
+ # read the reason from /metadata/<sid> before the port is released.
2595
+ _FAILED_LOAD_EXIT_GRACE_SECONDS = float(
2596
+ os.environ.get("ARRAYVIEW_FAILED_LOAD_EXIT_GRACE_SECONDS", "30")
2597
+ )
2593
2598
 
2594
2599
 
2595
2600
  def _join_query_values(values: _CSVValues) -> str:
@@ -4169,6 +4174,7 @@ def _serve_daemon(
4169
4174
  owner="persistent" if persist else "transient",
4170
4175
  )
4171
4176
  # Register sid as pending so /metadata can poll while data loads.
4177
+ _initial_load_failed = threading.Event()
4172
4178
  _session_mod.PENDING_SESSIONS.add(sid)
4173
4179
  _pending_event = threading.Event()
4174
4180
  _session_mod.PENDING_SESSION_EVENTS[sid] = _pending_event
@@ -4349,6 +4355,18 @@ def _serve_daemon(
4349
4355
  sid_tag=sid_tag,
4350
4356
  error_type=type(exc).__name__,
4351
4357
  )
4358
+ # This runs on a daemon thread, so re-raising only kills the thread
4359
+ # and writes to a stderr nobody reads. Record why the sid will never
4360
+ # become ready so /metadata can report it instead of returning 404
4361
+ # until the opener gives up.
4362
+ _session_mod.FAILED_PENDING_SESSIONS[sid] = (
4363
+ str(exc) or type(exc).__name__
4364
+ )
4365
+ _vprint(
4366
+ f"[ArrayView] Failed to load {filepath}: {exc}",
4367
+ flush=True,
4368
+ )
4369
+ _initial_load_failed.set()
4352
4370
  raise
4353
4371
  finally:
4354
4372
  _session_mod.PENDING_SESSIONS.discard(sid)
@@ -4357,6 +4375,32 @@ def _serve_daemon(
4357
4375
 
4358
4376
  threading.Thread(target=_load, daemon=True).start()
4359
4377
 
4378
+ def _exit_if_initial_load_failed() -> None:
4379
+ """Release the port when the file this daemon exists to serve won't load.
4380
+
4381
+ uvicorn starts before the load finishes, so a failed load otherwise
4382
+ leaves a server with no session holding the port until the connect
4383
+ timeout expires minutes later — and answering /ping the whole time. The
4384
+ grace period is only so the opener can read the failure from
4385
+ /metadata/<sid> before the port goes away.
4386
+ """
4387
+ _initial_load_failed.wait()
4388
+ time.sleep(_FAILED_LOAD_EXIT_GRACE_SECONDS)
4389
+ if _session_mod.SESSIONS or _session_mod.VIEWER_SOCKETS:
4390
+ return # something else is using this server; leave it alone
4391
+ _trace_launch_event(
4392
+ "daemon.exiting",
4393
+ reason="initial_load_failed",
4394
+ instance_tag=_launch_trace_tag(record.instance_id),
4395
+ )
4396
+ try:
4397
+ registry.remove(record.instance_id)
4398
+ except Exception:
4399
+ pass
4400
+ os._exit(0)
4401
+
4402
+ threading.Thread(target=_exit_if_initial_load_failed, daemon=True).start()
4403
+
4360
4404
  if persist:
4361
4405
  _wait_for_viewer_close(
4362
4406
  idle_seconds=_PERSIST_DAEMON_IDLE_SECONDS,
@@ -18,6 +18,7 @@ from arrayview._lifecycle import (
18
18
  )
19
19
  from arrayview._session import (
20
20
  CANCELLED_PENDING_SESSIONS,
21
+ FAILED_PENDING_SESSIONS,
21
22
  PENDING_SESSION_EVENTS,
22
23
  PENDING_SESSIONS,
23
24
  SESSIONS,
@@ -263,6 +264,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
263
264
  # the real Session instead of seeing a transient 404.
264
265
  sid = requested_sid or uuid.uuid4().hex
265
266
  pending_event = threading.Event()
267
+ FAILED_PENDING_SESSIONS.pop(sid, None)
266
268
  PENDING_SESSIONS.add(sid)
267
269
  PENDING_SESSION_EVENTS[sid] = pending_event
268
270
  signature_before_load = file_signature(abs_path)
@@ -293,12 +295,14 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
293
295
  ]
294
296
  commit_pending_session(sid, session)
295
297
  except Exception as exc:
296
- # Keep the server alive and make the failure visible in its
297
- # log. The opener will fail closed when metadata never
298
- # becomes ready, while a retry creates a fresh request.
298
+ # Keep the server alive, and record why this sid will never
299
+ # become ready. Without the record the opener cannot tell a
300
+ # failed load from a slow one and burns its whole deadline
301
+ # on a file that raised immediately.
299
302
  import traceback
300
303
 
301
304
  traceback.print_exc()
305
+ FAILED_PENDING_SESSIONS[sid] = str(exc) or exc.__class__.__name__
302
306
  finally:
303
307
  PENDING_SESSIONS.discard(sid)
304
308
  CANCELLED_PENDING_SESSIONS.discard(sid)
@@ -211,6 +211,14 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
211
211
  # request; the opener retries until the pending Session is published.
212
212
  if sid in _session_mod.PENDING_SESSIONS:
213
213
  return Response(status_code=404, headers={"Retry-After": "1"})
214
+ # A load that raised is terminal. Say so with a distinct status and the
215
+ # real reason, so the caller stops polling and can show the user why
216
+ # this file will never open instead of waiting out its deadline.
217
+ load_error = _session_mod.FAILED_PENDING_SESSIONS.get(sid)
218
+ if load_error is not None:
219
+ return Response(
220
+ status_code=422, content=load_error.encode(), media_type="text/plain"
221
+ )
214
222
  session = await wait_for_session_ready(sid)
215
223
  if not session:
216
224
  return Response(status_code=404)
@@ -41,6 +41,10 @@ _window_process = None
41
41
  PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
42
42
  PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
43
43
  CANCELLED_PENDING_SESSIONS: set[str] = set()
44
+ # sid -> why its background load failed. A load that raises must be reportable:
45
+ # without this the opener cannot tell "still loading" from "will never load" and
46
+ # waits out its whole deadline on a file that failed in the first second.
47
+ FAILED_PENDING_SESSIONS: dict[str, str] = {}
44
48
 
45
49
 
46
50
  def file_signature(filepath: str):
@@ -575,6 +579,7 @@ __all__ = [
575
579
  "_window_process",
576
580
  "PENDING_SESSIONS",
577
581
  "PENDING_SESSION_EVENTS",
582
+ "FAILED_PENDING_SESSIONS",
578
583
  "wait_for_session_ready",
579
584
  # Render thread pool
580
585
  "_RENDER_QUEUE",
@@ -9278,7 +9278,20 @@
9278
9278
  function formatValueGroup(values, options = {}) {
9279
9279
  const finite = (Array.isArray(values) ? values : [values]).filter(Number.isFinite);
9280
9280
  const cap = Math.max(0, Math.min(8, Math.trunc(options.maxDecimals ?? 7)));
9281
- const minDecimals = Math.max(0, Math.min(cap, Math.trunc(options.minDecimals ?? 2)));
9281
+ // Decimals worth showing depend on magnitude. Two was applied at
9282
+ // every scale, so a range topping out near 1890 read as "1890.04"
9283
+ // when the hundredths carry no information. The floor is only ever
9284
+ // lowered for large values, never raised, so small ranges keep the
9285
+ // precision they had. The search below can still add decimals when
9286
+ // values would otherwise collide.
9287
+ const _defaultMinDecimals = (vals) => {
9288
+ const m = Math.max(...vals.filter(Number.isFinite).map(v => Math.abs(v)));
9289
+ if (!(m > 0) || !Number.isFinite(m)) return 2;
9290
+ return Math.max(0, Math.min(2, 3 - Math.floor(Math.log10(m))));
9291
+ };
9292
+ const minDecimals = Math.max(0, Math.min(cap, Math.trunc(
9293
+ options.minDecimals ?? _defaultMinDecimals(finite)
9294
+ )));
9282
9295
  const ordinaryLo = options.ordinaryLo ?? 1e-4;
9283
9296
  const ordinaryHi = options.ordinaryHi ?? 1e6;
9284
9297
  const fallback = {
@@ -14242,6 +14255,41 @@
14242
14255
 
14243
14256
  /* ── JS: Initialization and Metadata Fetch ────────────────── */
14244
14257
 
14258
+ // The backend serves this page as soon as its port is up, which can be
14259
+ // seconds before a large array has finished loading. While the session
14260
+ // is still loading /metadata answers 404 with a Retry-After header; an
14261
+ // unknown or released sid answers 404 without one, and a load that
14262
+ // failed answers 422 with the reason. Keep waiting only for the first.
14263
+ async function fetchMetadataWithRetry(sid, timeoutMs = 180000) {
14264
+ const deadline = Date.now() + timeoutMs;
14265
+ for (;;) {
14266
+ const res = await proxyFetch(`/metadata/${sid}`);
14267
+ if (res.ok) return { data: await res.json() };
14268
+ if (res.status === 422) {
14269
+ let reason = '';
14270
+ try { reason = (await res.text()).trim(); } catch (_e) {}
14271
+ return { error: reason || 'This file could not be loaded.', status: 422 };
14272
+ }
14273
+ const pending = res.status === 404 && res.headers.get('Retry-After');
14274
+ if (!pending || Date.now() >= deadline) {
14275
+ return { error: null, status: res.status };
14276
+ }
14277
+ await new Promise(r => setTimeout(r, 300));
14278
+ }
14279
+ }
14280
+
14281
+ function showMetadataError(result) {
14282
+ const msg = result.error
14283
+ ? result.error
14284
+ : (result.status === 404
14285
+ ? 'Session not found or expired.'
14286
+ : `Server error ${result.status} — check Python console for details.`);
14287
+ document.getElementById('loading-overlay').innerHTML =
14288
+ `<div style="color:#c55;font-size:16px;font-family:monospace;max-width:60ch;text-align:center;line-height:1.5;padding:0 2em">${
14289
+ msg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14290
+ }</div>`;
14291
+ }
14292
+
14245
14293
  async function init() {
14246
14294
  // Loading spinner is in static HTML — body.av-loading set by server template.
14247
14295
  if (!sid) { hideLoadingOverlay(); drawPlasmaDemo(); return; }
@@ -14260,26 +14308,24 @@
14260
14308
  );
14261
14309
  data = await Promise.race([_metadataFromWs, httpFallback]);
14262
14310
  if (!data) {
14263
- // Both failed — try direct HTTP without timeout
14264
- const res = await proxyFetch(`/metadata/${sid}`);
14265
- if (!res.ok) {
14266
- reportParent('metadata-http-error', { status: res.status, sid });
14267
- const msg = res.status === 404 ? 'Session not found or expired.' : `Server error ${res.status} — check Python console for details.`;
14268
- document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
14311
+ // Both failed — wait out a still-loading session.
14312
+ const result = await fetchMetadataWithRetry(sid);
14313
+ if (!result.data) {
14314
+ reportParent('metadata-http-error', { status: result.status, sid });
14315
+ showMetadataError(result);
14269
14316
  return;
14270
14317
  }
14271
- data = await res.json();
14318
+ data = result.data;
14272
14319
  }
14273
14320
  } else {
14274
14321
  // HTTP transport mode (VS Code) — use traditional fetch
14275
- const res = await proxyFetch(`/metadata/${sid}`);
14276
- if (!res.ok) {
14277
- reportParent('metadata-http-error', { status: res.status, sid });
14278
- const msg = res.status === 404 ? 'Session not found or expired.' : `Server error ${res.status} — check Python console for details.`;
14279
- document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
14322
+ const result = await fetchMetadataWithRetry(sid);
14323
+ if (!result.data) {
14324
+ reportParent('metadata-http-error', { status: result.status, sid });
14325
+ showMetadataError(result);
14280
14326
  return;
14281
14327
  }
14282
- data = await res.json();
14328
+ data = result.data;
14283
14329
  }
14284
14330
 
14285
14331
  await _loadOverlayLabelValues();
@@ -35919,9 +35965,12 @@
35919
35965
  return `${x.toFixed(1)},${y.toFixed(1)}`;
35920
35966
  }).join(' ');
35921
35967
  const distLabel = distance.toFixed(1) + ' px';
35968
+ // Share the colorbar's formatter so the same value does not read
35969
+ // differently here than it does on the colorbar.
35970
+ const _lpFmt = formatValueGroup([vMin, vMax]);
35922
35971
  const fmtVal = v => {
35923
35972
  if (v === null || !isFinite(v)) return '?';
35924
- return Math.abs(v) < 0.01 || Math.abs(v) >= 1e4 ? v.toExponential(2) : v.toFixed(2);
35973
+ return _lpFmt.format(v);
35925
35974
  };
35926
35975
  _lineProfileOverlay.innerHTML = `
35927
35976
  <svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
@@ -54,7 +54,7 @@ _VSCODE_EXT_INSTALLED = False # cached so we only check once per process
54
54
  _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
55
55
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
56
56
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
57
- _VSCODE_EXT_VERSION = "0.14.80" # current bundled extension version
57
+ _VSCODE_EXT_VERSION = "0.14.87" # current bundled extension version
58
58
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
59
59
 
60
60
  def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
@@ -633,6 +633,47 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
633
633
  # ---------------------------------------------------------------------------
634
634
 
635
635
 
636
+ def _port_has_listener(port: int) -> bool:
637
+ """Return True when something is listening on ``port`` on loopback."""
638
+ import socket
639
+
640
+ for family, addr in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
641
+ sock = socket.socket(family, socket.SOCK_STREAM)
642
+ try:
643
+ sock.settimeout(0.15)
644
+ if sock.connect_ex((addr, port)) == 0:
645
+ return True
646
+ except OSError:
647
+ continue
648
+ finally:
649
+ sock.close()
650
+ return False
651
+
652
+
653
+ def _stale_arrayview_port_keys(attrs: dict, keep_port: int) -> list[str]:
654
+ """Return ArrayView port entries that no longer have a live server.
655
+
656
+ Every launch on a fresh port used to leave its entry behind for good. On a
657
+ tunnel those entries keep telling VS Code to forward ports nothing serves,
658
+ which competes for a limited per-tunnel forwarding budget. Only entries
659
+ ArrayView wrote are considered, and only when their port is dead, so a
660
+ second concurrent ArrayView server is never disturbed.
661
+ """
662
+ stale: list[str] = []
663
+ for key, value in list(attrs.items()):
664
+ if not isinstance(value, dict) or value.get("label") != "ArrayView":
665
+ continue
666
+ if key == str(keep_port):
667
+ continue
668
+ try:
669
+ candidate = int(key)
670
+ except (TypeError, ValueError):
671
+ continue
672
+ if not _port_has_listener(candidate):
673
+ stale.append(key)
674
+ return stale
675
+
676
+
636
677
  def _configure_vscode_port_preview(
637
678
  port: int,
638
679
  *,
@@ -686,8 +727,11 @@ def _configure_vscode_port_preview(
686
727
  }
687
728
  current = attrs.get(str(port))
688
729
  updated = {**current, **desired} if isinstance(current, dict) else desired
689
- if current == updated:
730
+ stale = _stale_arrayview_port_keys(attrs, port)
731
+ if current == updated and not stale:
690
732
  return
733
+ for key in stale:
734
+ attrs.pop(key, None)
691
735
  attrs[str(port)] = updated
692
736
  with open(path, "w") as f:
693
737
  json.dump(settings, f, indent=2)
@@ -6754,7 +6754,7 @@ def _isolate_view_planner(
6754
6754
  if port in alive_ports
6755
6755
  else (),
6756
6756
  "1" if port in alive_ports else None,
6757
- server_uid=os.geteuid() if port in alive_ports else None,
6757
+ server_uid=os.geteuid() if hasattr(os, "geteuid") and port in alive_ports else None,
6758
6758
  ),
6759
6759
  )
6760
6760
  if alive_ports:
@@ -13,6 +13,7 @@ import pytest
13
13
  import arrayview._app as appmod
14
14
  import arrayview._launch_plan as _launch_plan_mod
15
15
  import arrayview._launcher as _launcher_mod
16
+ import arrayview._platform as _platform_mod
16
17
 
17
18
 
18
19
  class _DummyResponse:
@@ -60,7 +61,7 @@ def _mock_launch_server_snapshot(monkeypatch, *, alive: bool, busy: bool | None
60
61
  None,
61
62
  ("identity-fenced-load", "identity-fenced-mutations") if alive else (),
62
63
  "1" if alive else None,
63
- server_uid=os.geteuid() if alive else None,
64
+ server_uid=os.geteuid() if hasattr(os, "geteuid") and alive else None,
64
65
  ),
65
66
  )
66
67
  if alive:
@@ -263,6 +264,7 @@ def test_cli_stack_existing_server_has_remote_flag_before_register(monkeypatch,
263
264
  assert calls[0]["dir_patterns"] == [os.path.abspath(base_pattern)]
264
265
 
265
266
 
267
+ @pytest.mark.skipif(sys.platform == "win32", reason="VSCode terminal readiness test is Unix-specific")
266
268
  def test_cli_vscode_terminal_requires_extension_readiness_ack(tmp_path):
267
269
  arr_path = tmp_path / "base.npy"
268
270
  np.save(arr_path, np.zeros((8, 8), dtype=np.float32))
@@ -690,6 +692,8 @@ def test_cli_existing_server_native_injection_skips_browser(monkeypatch, tmp_pat
690
692
  _mock_launch_server_snapshot(monkeypatch, alive=True)
691
693
  monkeypatch.setattr(_launch_plan_mod, "_native_window_gui", lambda: "webview")
692
694
  monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
695
+ monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
696
+ monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
693
697
  monkeypatch.setattr(
694
698
  _launcher_mod,
695
699
  "_open_browser",
@@ -737,6 +741,8 @@ def test_cli_existing_server_native_shell_connection_skips_browser(
737
741
  _mock_launch_server_snapshot(monkeypatch, alive=True)
738
742
  monkeypatch.setattr(_launch_plan_mod, "_native_window_gui", lambda: "webview")
739
743
  monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
744
+ monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
745
+ monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
740
746
  monkeypatch.setattr(
741
747
  _launcher_mod,
742
748
  "_open_webview_cli_tracked",
@@ -805,6 +811,8 @@ def test_cli_spawn_daemon_opens_viewer_url(monkeypatch, tmp_path):
805
811
  lambda port: ("spawned-server", "process-start", 43210),
806
812
  )
807
813
  monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
814
+ monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
815
+ monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
808
816
  monkeypatch.setattr(
809
817
  _launcher_mod.subprocess,
810
818
  "Popen",
@@ -4,6 +4,7 @@ import os
4
4
  from pathlib import Path
5
5
  import subprocess
6
6
  import sys
7
+ import sys
7
8
  import time
8
9
 
9
10
  import pytest
@@ -164,11 +165,12 @@ def test_runtime_directory_override(monkeypatch, tmp_path):
164
165
  assert runtime_directory() == tmp_path / "custom"
165
166
 
166
167
 
168
+ @pytest.mark.skipif(sys.platform == "win32", reason="UID-based directory resolution is Unix-only")
167
169
  def test_runtime_directory_is_per_uid_without_override(monkeypatch, tmp_path):
168
170
  monkeypatch.delenv("ARRAYVIEW_RUNTIME_DIR", raising=False)
169
171
  monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
170
172
  monkeypatch.setattr("arrayview._instance_registry.tempfile.gettempdir", lambda: str(tmp_path))
171
- current_uid = os.getuid()
173
+ current_uid = os.getuid() if hasattr(os, "getuid") else 1000
172
174
  first = runtime_directory()
173
175
  monkeypatch.setattr("arrayview._instance_registry.os.getuid", lambda: current_uid + 1)
174
176
  second = runtime_directory()
@@ -7,6 +7,7 @@ remote extension host and therefore belong in the tunnel handoff, not mocks.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  from dataclasses import dataclass
11
12
 
12
13
  import pytest
@@ -147,6 +148,7 @@ def test_compatible_server_reuse_is_identical_for_every_adapter(case, platform):
147
148
  "contract-process-start",
148
149
  ("identity-fenced-load", "identity-fenced-mutations"),
149
150
  "1",
151
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
150
152
  )
151
153
 
152
154
  plan = plan_launch(
@@ -3,6 +3,7 @@ import socket
3
3
  import sys
4
4
 
5
5
  import arrayview._launcher as launcher
6
+ import arrayview._platform as _platform_mod
6
7
 
7
8
 
8
9
  def _free_port() -> int:
@@ -18,6 +19,8 @@ def test_diagnose_serializes_shared_snapshot_and_plan(monkeypatch, capsys):
18
19
  "argv",
19
20
  ["arrayview", "--diagnose", "--port", str(port), "--window", "browser"],
20
21
  )
22
+ monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
23
+ monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
21
24
 
22
25
  launcher.arrayview()
23
26
 
@@ -168,7 +168,8 @@ def test_http_load_plan_fails_closed_when_selected_server_changes(
168
168
  "planned-process-start",
169
169
  ("identity-fenced-load", "identity-fenced-mutations"),
170
170
  "1",
171
- server_uid=os.geteuid(),
171
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
172
+
172
173
  ),
173
174
  )
174
175
  monkeypatch.setattr(
@@ -224,7 +225,8 @@ def test_display_error_after_http_registration_is_not_relabelled(monkeypatch):
224
225
  "planned-process-start",
225
226
  ("identity-fenced-load", "identity-fenced-mutations"),
226
227
  "1",
227
- server_uid=os.geteuid(),
228
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
229
+
228
230
  ),
229
231
  )
230
232
  monkeypatch.setattr(
@@ -586,7 +586,8 @@ def test_remote_python_reuses_healthy_cli_default_server():
586
586
  "remote-process-start",
587
587
  ("identity-fenced-load", "identity-fenced-mutations"),
588
588
  "1",
589
- server_uid=os.geteuid(),
589
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
590
+
590
591
  ),
591
592
  )
592
593
  plan = plan_launch(LaunchIntent(Invocation.PYTHON, 8123), facts)
@@ -662,7 +663,8 @@ def test_existing_server_is_reused_through_http():
662
663
  "process-start",
663
664
  ("identity-fenced-load", "identity-fenced-mutations"),
664
665
  "1",
665
- server_uid=os.geteuid(),
666
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
667
+
666
668
  )
667
669
  )
668
670
  plan = plan_launch(LaunchIntent(Invocation.CLI, 8123), facts)
@@ -671,6 +673,7 @@ def test_existing_server_is_reused_through_http():
671
673
  assert plan.registration.value == "http_load"
672
674
 
673
675
 
676
+ @pytest.mark.skipif(sys.platform == "win32", reason="UID checks are Unix-only")
674
677
  @pytest.mark.parametrize(
675
678
  "server_uid",
676
679
  [None, os.geteuid() + 1 if hasattr(os, "geteuid") else -1],
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
2
  import os
3
+ import sys
3
4
  from threading import Thread as RealThread
4
5
 
5
6
  import numpy as np
@@ -388,6 +389,7 @@ def test_vscode_tunnel_open_request_is_remote_only(monkeypatch, tmp_path):
388
389
  assert captured[0]["remoteOnly"] is True
389
390
 
390
391
 
392
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
391
393
  def test_vscode_extension_disk_check_is_scoped_to_remote_host(monkeypatch, tmp_path):
392
394
  import arrayview._vscode_extension as extension
393
395
 
@@ -405,6 +407,7 @@ def test_vscode_extension_disk_check_is_scoped_to_remote_host(monkeypatch, tmp_p
405
407
  assert extension._extension_on_disk("1.2.3", remote=True) is True
406
408
 
407
409
 
410
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
408
411
  @pytest.mark.parametrize("remote", [False, True])
409
412
  def test_vscode_extension_missing_hash_verifies_content_without_reinstall(
410
413
  monkeypatch, tmp_path, remote
@@ -503,6 +506,7 @@ def test_vscode_port_settings_preserve_unreadable_jsonc(monkeypatch, tmp_path):
503
506
  assert settings_path.read_text() == original
504
507
 
505
508
 
509
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
506
510
  def test_vscode_port_settings_are_idempotent_and_preserve_extra_keys(
507
511
  monkeypatch, tmp_path
508
512
  ):
@@ -535,6 +539,7 @@ def test_vscode_port_settings_are_idempotent_and_preserve_extra_keys(
535
539
  assert settings_path.read_text() == first
536
540
 
537
541
 
542
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
538
543
  def test_vscode_extension_cleanup_never_removes_newer_version(monkeypatch, tmp_path):
539
544
  import arrayview._vscode_extension as extension
540
545
 
@@ -554,14 +559,14 @@ def test_vscode_extension_cleanup_never_removes_newer_version(monkeypatch, tmp_p
554
559
  def test_vscode_extension_keeps_newer_install_without_downgrading(monkeypatch, tmp_path):
555
560
  import arrayview._vscode_extension as extension
556
561
 
557
- home = tmp_path / "home"
558
562
  newer_version = "99.0.0"
559
- (home / ".vscode" / "extensions" / f"arrayview.arrayview-opener-{newer_version}").mkdir(
560
- parents=True
561
- )
562
- monkeypatch.setenv("HOME", str(home))
563
563
  monkeypatch.setattr(extension, "_is_vscode_remote", lambda: False)
564
564
  monkeypatch.setattr(extension, "_active_extension_version", lambda: newer_version)
565
+ monkeypatch.setattr(extension, "_active_extension_registration", lambda: None)
566
+ monkeypatch.setattr(extension, "_extension_on_disk", lambda *args, **kwargs: False)
567
+ monkeypatch.setattr(
568
+ extension, "_newer_extension_on_disk", lambda *args, **kwargs: newer_version
569
+ )
565
570
  monkeypatch.setattr(
566
571
  extension.subprocess,
567
572
  "run",
@@ -600,6 +605,7 @@ def test_vscode_extension_install_timeout_fails_closed_for_stale_host(monkeypatc
600
605
  assert extension._VSCODE_EXT_RELOAD_REQUIRED is True
601
606
 
602
607
 
608
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
603
609
  def test_remote_vscode_launch_automatically_runs_exact_installer(monkeypatch, tmp_path):
604
610
  import subprocess
605
611
  import arrayview._vscode_extension as extension
@@ -634,6 +640,7 @@ def test_remote_vscode_launch_automatically_runs_exact_installer(monkeypatch, tm
634
640
  assert extension._VSCODE_EXT_RELOAD_REQUIRED is False
635
641
 
636
642
 
643
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
637
644
  def test_active_extension_version_prefers_live_ipc_over_stale_terminal_env(
638
645
  monkeypatch, tmp_path
639
646
  ):
@@ -660,6 +667,7 @@ def test_active_extension_version_prefers_live_ipc_over_stale_terminal_env(
660
667
  assert extension._active_extension_version() == "0.14.70"
661
668
 
662
669
 
670
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
663
671
  def test_active_extension_version_recovers_revived_terminal_from_exact_server(
664
672
  monkeypatch, tmp_path
665
673
  ):
@@ -772,6 +780,7 @@ def test_extension_activation_wait_requires_fresh_exact_registration(monkeypatch
772
780
  )
773
781
 
774
782
 
783
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
775
784
  def test_remote_vscode_setup_touches_only_active_extension_base(
776
785
  monkeypatch, tmp_path
777
786
  ):
@@ -811,6 +820,7 @@ def test_remote_vscode_setup_touches_only_active_extension_base(
811
820
  assert (remote_base / f"arrayview.arrayview-opener-{old_version}").is_dir()
812
821
 
813
822
 
823
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
814
824
  def test_remote_vscode_install_requires_exact_activation_before_launch(
815
825
  monkeypatch, tmp_path
816
826
  ):
@@ -91,6 +91,7 @@ def test_public_cli_explicit_native_in_local_vscode_never_signals_extension(
91
91
  None,
92
92
  ("identity-fenced-load", "identity-fenced-mutations"),
93
93
  "1",
94
+ server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
94
95
  ),
95
96
  )
96
97
  monkeypatch.setattr(
@@ -0,0 +1,126 @@
1
+ """Behavioural coverage for the viewer's shared vmin/vmax formatter.
2
+
3
+ `formatValueGroup` decides how many decimals every colorbar label, range
4
+ readout and line-profile axis shows. The rule is numeric, so asserting on the
5
+ source text would not catch a change in what the user actually sees. These
6
+ tests extract the function and execute it.
7
+ """
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import textwrap
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ VIEWER_HTML = Path(__file__).parent.parent / "src" / "arrayview" / "_viewer.html"
18
+
19
+ pytestmark = pytest.mark.skipif(
20
+ shutil.which("node") is None, reason="node is required to execute viewer JS"
21
+ )
22
+
23
+
24
+ def _extract_function(source: str, name: str) -> str:
25
+ """Return the full text of a top-level `function <name>(...) { ... }`.
26
+
27
+ Brace counting has to start after the parameter list: a default such as
28
+ `options = {}` would otherwise close the body before it opened.
29
+ """
30
+ start = source.index(f"function {name}(")
31
+ paren_depth = 0
32
+ body_start = None
33
+ for index in range(start, len(source)):
34
+ char = source[index]
35
+ if char == "(":
36
+ paren_depth += 1
37
+ elif char == ")":
38
+ paren_depth -= 1
39
+ if paren_depth == 0:
40
+ body_start = source.index("{", index)
41
+ break
42
+ assert body_start is not None, f"could not find parameter list of {name}"
43
+
44
+ depth = 0
45
+ for index in range(body_start, len(source)):
46
+ char = source[index]
47
+ if char == "{":
48
+ depth += 1
49
+ elif char == "}":
50
+ depth -= 1
51
+ if depth == 0:
52
+ return source[start : index + 1]
53
+ raise AssertionError(f"unbalanced braces while extracting {name}")
54
+
55
+
56
+ def _format_pairs(pairs):
57
+ """Format each [lo, hi] pair through the real viewer implementation."""
58
+ fn = _extract_function(VIEWER_HTML.read_text(), "formatValueGroup")
59
+ script = textwrap.dedent(
60
+ """
61
+ const _escapeHtml = (s) => String(s);
62
+ __FN__
63
+ const pairs = __PAIRS__;
64
+ const out = pairs.map((pair) => {
65
+ const [values, options] = pair;
66
+ const fmt = formatValueGroup(values, options || {});
67
+ return values.map((v) => fmt.format(v));
68
+ });
69
+ process.stdout.write(JSON.stringify(out));
70
+ """
71
+ ).replace("__FN__", fn).replace("__PAIRS__", json.dumps(pairs))
72
+ result = subprocess.run(
73
+ ["node", "-e", script], capture_output=True, text=True, timeout=60
74
+ )
75
+ assert result.returncode == 0, result.stderr
76
+ return json.loads(result.stdout)
77
+
78
+
79
+ def test_large_ranges_drop_meaningless_decimals():
80
+ """A range topping out near 1890 should not advertise hundredths."""
81
+ (near_two_thousand,), = _format_pairs([[[0, 1890.04], None]]),
82
+ assert near_two_thousand == ["0", "1890"]
83
+
84
+
85
+ def test_small_ranges_keep_their_precision():
86
+ """Lowering the floor for large values must not coarsen small ones."""
87
+ results = _format_pairs(
88
+ [
89
+ [[0, 0.5], None],
90
+ [[1.23456, 9.87654], None],
91
+ [[0, 3.14159265], None],
92
+ [[0, 0.00123], None],
93
+ ]
94
+ )
95
+ assert results[0] == ["0.00", "0.50"]
96
+ assert results[1] == ["1.23", "9.88"]
97
+ assert results[2] == ["0.00", "3.14"]
98
+ assert results[3] == ["0.000", "0.001"]
99
+
100
+
101
+ def test_vmin_and_vmax_share_a_decimal_count():
102
+ """The pair must never read as e.g. "0.1" beside "1890.0421"."""
103
+ for values in ([0, 1890.04], [0.5, 18.9], [100.5, 100.6], [-1890.04, 1890.04]):
104
+ low, high = _format_pairs([[values, None]])[0]
105
+ assert low.partition(".")[2].__len__() == high.partition(".")[2].__len__(), (
106
+ f"{values} rendered as {low} / {high}"
107
+ )
108
+
109
+
110
+ def test_close_values_stay_distinguishable():
111
+ """Rounding must never collapse two different values into one label."""
112
+ for values in ([100.5, 100.6], [1000000, 1000001], [-0.001, 0.001]):
113
+ low, high = _format_pairs([[values, None]])[0]
114
+ assert low != high, f"{values} both rendered as {low}"
115
+
116
+
117
+ def test_non_finite_and_degenerate_inputs_are_safe():
118
+ results = _format_pairs([[[0, 0], None]])
119
+ assert results[0] == ["0", "0"]
120
+
121
+
122
+ def test_explicit_min_decimals_still_wins():
123
+ """The pixel readout asks for three decimals; that override must hold."""
124
+ low, high = _format_pairs([[[0, 1890.04], {"minDecimals": 3, "maxDecimals": 7}]])[0]
125
+ assert high == "1890.040"
126
+ assert low == "0.000"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes