Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js-component/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>openms-streamlit-vue-component</title>
<script type="module" crossorigin src="./assets/index-264a76c5.js"></script>
<script type="module" crossorigin src="./assets/index-3e659711.js"></script>
<link rel="stylesheet" href="./assets/index-a58b60d0.css">
</head>
<body>
Expand Down
15 changes: 11 additions & 4 deletions src/render/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,23 @@ def render_component(
# Get State
state = state_tracker.getState()

# Cleared selections now arrive (and are stored) as `None` rather than being
# dropped, so the frontend can round-trip a deselect. update/filter logic uses
# the "key not in selection_store" convention, so drop None-valued keys for the
# data computation while still echoing the full state (incl. nulls) back so the
# frontend can clear those fields in every component.
active_state = {k: v for k, v in state.items() if v is not None}

# Update data with current session state
data = update_data(data, out_components, state, additional_data, tool)
data = update_data(data, out_components, active_state, additional_data, tool)

# Filter data based on selection
data = filter_data(
data, out_components, state, additional_data, tool
data, out_components, active_state, additional_data, tool
)

# Hash updated. filtered data
data['hash'] = hash_complex(data)
data['hash'] = hash_complex(data)

# Render component
data['selection_store'] = state
Expand Down
74 changes: 74 additions & 0 deletions tests/test_selection_clear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Tests for the selection-clearing round-trip used by the FLASHViewer grid.

Each view (Sequence View, Tag Table, Protein Table, ...) is a separate Streamlit
component instance with its own frontend store; they share selection state only by
round-tripping through Python's StateTracker. Clearing a selection (e.g. deselecting
an amino acid, or switching proteoform) must therefore propagate back to every view.

The frontend sends a cleared field as `null`/`None` (App.vue maps `undefined -> null`
so the clear survives JSON serialization). These tests pin the two invariants the fix
relies on:

1. A cleared field is echoed back as `None` so every component can clear it.
2. render_component strips `None`-valued keys for the data computation, preserving
update.py's "key not in selection_store" convention.

They also document the original bug: when the cleared key was *dropped* from the
payload entirely, the merge-only StateTracker kept echoing the stale value.
"""

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.render.StateTracker import StateTracker


def _echo_with(tracker, **overrides):
"""Mimic a component returning the echoed state with `overrides` applied."""
state = tracker.getState() # includes counter + id, like getState() -> frontend
state.update(overrides)
return state


def _active_state(state):
"""The view render.py passes to update/filter: None == "not selected" == absent."""
return {k: v for k, v in state.items() if v is not None}


def test_selecting_a_value_round_trips():
tracker = StateTracker()
tracker.updateState(_echo_with(tracker, AApos=5))
assert tracker.getState()["AApos"] == 5
assert _active_state(tracker.getState())["AApos"] == 5


def test_clearing_a_selection_round_trips_as_none():
tracker = StateTracker()
tracker.updateState(_echo_with(tracker, AApos=5))
assert tracker.getState()["AApos"] == 5

# Deselect: the frontend sends AApos=None (App.vue maps undefined -> null).
tracker.updateState(_echo_with(tracker, AApos=None))
echoed = tracker.getState()

# (1) Echoed back as None so every component clears the field locally.
assert echoed["AApos"] is None
# (2) The data-computation view treats None as absent (not selected).
assert "AApos" not in _active_state(echoed)


def test_dropped_key_keeps_stale_value_regression():
"""Pre-fix behavior: `undefined` was dropped from the payload, so the merge-only
StateTracker never learned about the clear and kept echoing the stale value.
This is exactly the bug the null-bridge (send None instead of dropping) fixes."""
tracker = StateTracker()
tracker.updateState(_echo_with(tracker, AApos=5))

payload = tracker.getState()
payload.pop("AApos") # simulate the JSON-dropped undefined key
tracker.updateState(payload)

assert tracker.getState()["AApos"] == 5 # stale value survives -> the original bug
Loading