wrongmove/tests/unit/test_decision_service.py

128 lines
4.5 KiB
Python
Raw Normal View History

"""Unit tests for decision_service."""
from unittest.mock import MagicMock
import pytest
from models.decision import ListingDecision
from services import decision_service
class TestSetDecision:
def test_set_liked(self) -> None:
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT", decision="liked"
)
result = decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="liked",
)
assert result.decision == "liked"
wrongmove: add "seen" soft-hide decision with price-aware resurfacing Opening a listing's detail sheet for >3s now passively marks it `seen` and snapshots its current `total_price`. Seen listings are hidden from the main list by default but automatically resurface when the price changes (any direction). Distinct from `disliked` — explicit like/dislike always overrides the passive seen state. New "Hidden (N)" toggle on the FilterBar appears whenever at least one listing is currently being hidden by the seen filter. Toggling it reveals those rows in-place (without unmarking them) so the user can review or explicitly clear via the existing Like/Dislike/Clear flow. ## Backend - Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN price_at_decision FLOAT NULL`. - `models/decision.py`: ListingDecision gains nullable `price_at_decision: float | None`. - `services/decision_service.py`: adds `seen` to VALID_DECISIONS; set_decision accepts an optional `price_at_decision`; it's only forwarded to the repo for decision='seen' (other types null-out the column to keep semantics clean). - `repositories/decision_repository.py`: upsert_decision now carries price_at_decision through the MySQL + SQLite upsert paths. - `api/decision_routes.py`: SetDecisionRequest + DecisionResponse expose the new field. ## Frontend - `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen'; ListingDecision gains `price_at_decision?: number | null`. - `services/decisionService.ts`: setDecision sends the price only for decision='seen' (and only when it's a finite number). - `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>` (decision + price snapshot). New `markSeen(id, price, type)` short- circuits on existing liked/disliked. New `getDecisionEntry`, `seenCount`. - `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the detail sheet stays open. Filter logic in `processedListingData` hides `seen` rows whose `total_price === price_at_decision`, with `showHidden` bypass. Computes `hiddenCount` to drive the toggle. - `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only when hiddenCount > 0. ## Tests - pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_ even_if_supplied) + 1 updated to assert the new 5-arg repo signature. 24 passed. - vitest: 6 new for useDecisions (markSeen liked/disliked skip, price snapshot, re-mark, null price, seenCount) + 5 new for decisionService payload shape. 221 total passed, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
# 5th arg is the price column; liked/disliked decisions drop it.
repo.upsert_decision.assert_called_once_with(
wrongmove: add "seen" soft-hide decision with price-aware resurfacing Opening a listing's detail sheet for >3s now passively marks it `seen` and snapshots its current `total_price`. Seen listings are hidden from the main list by default but automatically resurface when the price changes (any direction). Distinct from `disliked` — explicit like/dislike always overrides the passive seen state. New "Hidden (N)" toggle on the FilterBar appears whenever at least one listing is currently being hidden by the seen filter. Toggling it reveals those rows in-place (without unmarking them) so the user can review or explicitly clear via the existing Like/Dislike/Clear flow. ## Backend - Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN price_at_decision FLOAT NULL`. - `models/decision.py`: ListingDecision gains nullable `price_at_decision: float | None`. - `services/decision_service.py`: adds `seen` to VALID_DECISIONS; set_decision accepts an optional `price_at_decision`; it's only forwarded to the repo for decision='seen' (other types null-out the column to keep semantics clean). - `repositories/decision_repository.py`: upsert_decision now carries price_at_decision through the MySQL + SQLite upsert paths. - `api/decision_routes.py`: SetDecisionRequest + DecisionResponse expose the new field. ## Frontend - `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen'; ListingDecision gains `price_at_decision?: number | null`. - `services/decisionService.ts`: setDecision sends the price only for decision='seen' (and only when it's a finite number). - `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>` (decision + price snapshot). New `markSeen(id, price, type)` short- circuits on existing liked/disliked. New `getDecisionEntry`, `seenCount`. - `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the detail sheet stays open. Filter logic in `processedListingData` hides `seen` rows whose `total_price === price_at_decision`, with `showHidden` bypass. Computes `hiddenCount` to drive the toggle. - `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only when hiddenCount > 0. ## Tests - pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_ even_if_supplied) + 1 updated to assert the new 5-arg repo signature. 24 passed. - vitest: 6 new for useDecisions (markSeen liked/disliked skip, price snapshot, re-mark, null price, seenCount) + 5 new for decisionService payload shape. 221 total passed, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
1, 100, "RENT", "liked", None
)
def test_set_disliked(self) -> None:
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT", decision="disliked"
)
result = decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="disliked",
)
assert result.decision == "disliked"
def test_invalid_decision_raises(self) -> None:
repo = MagicMock()
with pytest.raises(ValueError, match="Invalid decision"):
decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="maybe",
)
def test_invalid_listing_type_raises(self) -> None:
repo = MagicMock()
with pytest.raises(ValueError, match="Invalid listing_type"):
decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="SELL", decision="liked",
)
wrongmove: add "seen" soft-hide decision with price-aware resurfacing Opening a listing's detail sheet for >3s now passively marks it `seen` and snapshots its current `total_price`. Seen listings are hidden from the main list by default but automatically resurface when the price changes (any direction). Distinct from `disliked` — explicit like/dislike always overrides the passive seen state. New "Hidden (N)" toggle on the FilterBar appears whenever at least one listing is currently being hidden by the seen filter. Toggling it reveals those rows in-place (without unmarking them) so the user can review or explicitly clear via the existing Like/Dislike/Clear flow. ## Backend - Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN price_at_decision FLOAT NULL`. - `models/decision.py`: ListingDecision gains nullable `price_at_decision: float | None`. - `services/decision_service.py`: adds `seen` to VALID_DECISIONS; set_decision accepts an optional `price_at_decision`; it's only forwarded to the repo for decision='seen' (other types null-out the column to keep semantics clean). - `repositories/decision_repository.py`: upsert_decision now carries price_at_decision through the MySQL + SQLite upsert paths. - `api/decision_routes.py`: SetDecisionRequest + DecisionResponse expose the new field. ## Frontend - `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen'; ListingDecision gains `price_at_decision?: number | null`. - `services/decisionService.ts`: setDecision sends the price only for decision='seen' (and only when it's a finite number). - `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>` (decision + price snapshot). New `markSeen(id, price, type)` short- circuits on existing liked/disliked. New `getDecisionEntry`, `seenCount`. - `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the detail sheet stays open. Filter logic in `processedListingData` hides `seen` rows whose `total_price === price_at_decision`, with `showHidden` bypass. Computes `hiddenCount` to drive the toggle. - `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only when hiddenCount > 0. ## Tests - pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_ even_if_supplied) + 1 updated to assert the new 5-arg repo signature. 24 passed. - vitest: 6 new for useDecisions (markSeen liked/disliked skip, price snapshot, re-mark, null price, seenCount) + 5 new for decisionService payload shape. 221 total passed, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
def test_set_seen_carries_price(self) -> None:
"""`seen` decisions persist the price-at-mark so the client can
resurface listings whose price has changed since dismissal."""
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT",
decision="seen", price_at_decision=2500.0,
)
result = decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="seen",
price_at_decision=2500.0,
)
assert result.decision == "seen"
assert result.price_at_decision == 2500.0
repo.upsert_decision.assert_called_once_with(
1, 100, "RENT", "seen", 2500.0
)
def test_liked_drops_price_even_if_supplied(self) -> None:
"""price_at_decision is only meaningful for seen — liked/disliked must
ignore it so the column stays null on those rows."""
repo = MagicMock()
repo.upsert_decision.return_value = ListingDecision(
id=1, user_id=1, listing_id=100, listing_type="RENT",
decision="liked",
)
decision_service.set_decision(
repo, user_id=1, listing_id=100,
listing_type="RENT", decision="liked",
price_at_decision=999.0,
)
repo.upsert_decision.assert_called_once_with(
1, 100, "RENT", "liked", None
)
class TestGetDecisions:
def test_returns_all_decisions(self) -> None:
repo = MagicMock()
repo.get_decisions_for_user.return_value = [
ListingDecision(
id=1, user_id=1, listing_id=100,
listing_type="RENT", decision="liked",
),
]
result = decision_service.get_user_decisions(repo, user_id=1)
assert len(result) == 1
class TestClearDecision:
def test_clear_existing(self) -> None:
repo = MagicMock()
repo.delete_decision.return_value = True
result = decision_service.remove_decision(
repo, user_id=1, listing_id=100, listing_type="RENT"
)
assert result is True
def test_clear_nonexistent(self) -> None:
repo = MagicMock()
repo.delete_decision.return_value = False
result = decision_service.remove_decision(
repo, user_id=1, listing_id=100, listing_type="RENT"
)
assert result is False
class TestGetDislikedListingIds:
def test_returns_disliked_set(self) -> None:
repo = MagicMock()
repo.get_disliked_listing_ids.return_value = {200, 300}
result = decision_service.get_disliked_ids(
repo, user_id=1, listing_type="RENT"
)
assert result == {200, 300}