fix: subset selection validation and remove unnecessary hash computation

Two bugs fixed in animal selection with checkboxes:

1. Confirmation dialog showed wrong count (e.g., "35 animals" instead of
   "2 animals" when only 2 were selected). Fixed by using valid_selected
   count in diff.server_count instead of full filter resolution count.

2. Spurious "Selection Changed" dialogs due to race condition in async
   hash computation. Fixed by removing client-side hash computation
   entirely - it was unnecessary since the server validates selected_ids
   directly against the filter resolution.

Changes:
- validation.py: Remove hash comparison in _validate_subset(), validate
  IDs directly, fix server_count in diff
- animal_select.py: Remove computeSelectionHash(), hidden roster_hash
  field, and related async fetch code
- test_selection_validation.py: Add tests for subset mode validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 13:01:07 +00:00
parent cccd76a44c
commit 628d5cc6e6
3 changed files with 101 additions and 75 deletions

View File

@@ -185,39 +185,30 @@ def _validate_subset(
# Find selected IDs that no longer match the filter
invalid_ids = selected_set - resolved_set
if not invalid_ids:
# All selected IDs are valid - compute hash from selected IDs
subset_hash = compute_roster_hash(selected_ids, context.from_location_id)
# Compute valid selected IDs (those that still match the filter)
valid_selected = [sid for sid in selected_ids if sid in resolved_set]
# Verify hash matches what client sent
if subset_hash == context.roster_hash:
if not invalid_ids:
# All selected IDs are still in the filter resolution - valid
# No hash comparison needed: we validate IDs directly
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
roster_hash=compute_roster_hash(selected_ids, context.from_location_id),
diff=None,
)
# Some selected IDs are no longer valid, or hash mismatch
# Some selected IDs are no longer valid
# Compute diff: removed = invalid_ids, added = none
# In subset mode, server_count reflects valid selected count, not full filter
diff = SelectionDiff(
added=[],
removed=sorted(invalid_ids),
server_count=len(resolved_ids),
server_count=len(valid_selected),
client_count=len(selected_ids),
)
if context.confirmed and not invalid_ids:
# Client confirmed, and all IDs are still valid
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
diff=diff,
)
# Invalid - return with valid selected IDs (those that still match)
valid_selected = [sid for sid in selected_ids if sid in resolved_set]
# Return invalid with valid selected IDs (those that still match)
new_hash = compute_roster_hash(valid_selected, context.from_location_id)
return SelectionValidationResult(

View File

@@ -98,8 +98,6 @@ def animal_checkbox_list(
*resolved_id_fields,
# Hidden field to indicate subset selection mode
Input(type="hidden", name="subset_mode", value="true"),
# Hidden field for roster_hash - will be updated via JS
Input(type="hidden", name="roster_hash", id="roster-hash-field"),
# Script for selection management
selection_script(len(animals)),
id="animal-selection-list",
@@ -126,8 +124,6 @@ def selection_script(total_count: int) -> Div:
if (countText) {{
countText.textContent = checked + ' of {total_count} selected';
}}
// Trigger hash recomputation if needed
computeSelectionHash();
}}
function selectAllAnimals(selectAll) {{
@@ -137,50 +133,6 @@ def selection_script(total_count: int) -> Div:
}});
updateSelectionCount();
}}
function getSelectedIds() {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}}
function computeSelectionHash() {{
// Get selected IDs and compute hash via API
var selectedIds = getSelectedIds();
var fromLocationId = document.querySelector('input[name="from_location_id"]');
var fromLoc = fromLocationId ? fromLocationId.value : '';
fetch('/api/compute-hash', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
selected_ids: selectedIds,
from_location_id: fromLoc
}})
}})
.then(response => {{
if (!response.ok) {{
console.error('Hash computation failed:', response.status);
return {{}};
}}
var contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {{
return response.json();
}}
return {{}};
}})
.then(data => {{
var hashField = document.getElementById('roster-hash-field');
if (hashField && data.roster_hash) {{
hashField.value = data.roster_hash;
}}
}})
.catch(err => console.error('Hash computation error:', err));
}}
// Initialize hash on load
document.addEventListener('DOMContentLoaded', function() {{
computeSelectionHash();
}});
""")

View File

@@ -456,3 +456,86 @@ class TestSelectionMismatchError:
assert error.result is result
assert error.result.diff is diff
# ============================================================================
# Tests for validate_selection - subset mode
# ============================================================================
class TestValidateSelectionSubsetMode:
"""Tests for validate_selection with subset_mode=True."""
def test_subset_mode_returns_valid_when_all_selected_match(
self, seeded_db, animal_service, strip1_location_id
):
"""validate_selection returns valid=True when all selected IDs are in filter."""
# Create cohort of 5 animals
ts_utc = int(time.time() * 1000)
payload = make_cohort_payload(strip1_location_id, count=5)
event = animal_service.create_cohort(payload, ts_utc, "test_user")
all_ids = event.entity_refs["animal_ids"]
# User selects only 2 of them
selected_ids = all_ids[:2]
subset_hash = compute_roster_hash(selected_ids, None)
ctx = SelectionContext(
filter="species:duck",
resolved_ids=all_ids, # Full filter resolution
roster_hash=subset_hash, # Hash of selected subset
ts_utc=ts_utc,
from_location_id=None,
subset_mode=True,
selected_ids=selected_ids,
)
result = validate_selection(seeded_db, ctx)
assert result.valid is True
assert result.resolved_ids == selected_ids
assert result.diff is None
def test_subset_mode_diff_server_count_is_valid_selected_count(
self, seeded_db, animal_service, strip1_location_id, strip2_location_id
):
"""In subset mode, diff.server_count should be count of valid selected IDs, not full filter."""
# Create cohort of 5 animals
ts_create = int(time.time() * 1000)
payload = make_cohort_payload(strip1_location_id, count=5)
event = animal_service.create_cohort(payload, ts_create, "test_user")
all_ids = event.entity_refs["animal_ids"]
# User selects 2 animals
selected_ids = all_ids[:2]
subset_hash = compute_roster_hash(selected_ids, None)
# Move one selected animal away (makes it invalid for the filter)
ts_move = ts_create + 1000
move_payload = AnimalMovedPayload(
resolved_ids=[selected_ids[0]],
from_location_id=strip1_location_id,
to_location_id=strip2_location_id,
)
animal_service.move_animals(move_payload, ts_move, "test_user")
# Now validate at ts_move - one of the selected animals is no longer at Strip 1
ctx = SelectionContext(
filter="location:'Strip 1'",
resolved_ids=all_ids, # Full filter resolution at creation time
roster_hash=subset_hash,
ts_utc=ts_move, # Validate at move time
from_location_id=None,
subset_mode=True,
selected_ids=selected_ids,
)
result = validate_selection(seeded_db, ctx)
assert result.valid is False
assert result.diff is not None
# BUG: diff.server_count is currently len(resolved_ids) = 4 (5 minus moved)
# SHOULD BE: len(valid_selected) = 1 (2 selected minus 1 moved)
assert result.diff.server_count == 1 # Only 1 valid selected animal remains
assert result.diff.client_count == 2 # User selected 2
assert selected_ids[0] in result.diff.removed # The moved animal is invalid