diff --git a/alembic/versions/f6a31c8d9e42_tracker_recommendation_source.py b/alembic/versions/f6a31c8d9e42_tracker_recommendation_source.py new file mode 100644 index 0000000..46cd197 --- /dev/null +++ b/alembic/versions/f6a31c8d9e42_tracker_recommendation_source.py @@ -0,0 +1,40 @@ +"""Record the first profile that inspired a tracker item. + +Revision ID: f6a31c8d9e42 +Revises: 3c45fe305813 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'f6a31c8d9e42' +down_revision: Union[str, Sequence[str], None] = '3c45fe305813' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TABLES = ('user_movies', 'user_tv_shows', 'user_books', 'user_video_games') + + +def upgrade() -> None: + for table in TABLES: + op.add_column(table, sa.Column('source_user_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + f'fk_{table}_source_user_id_users', + table, + 'users', + ['source_user_id'], + ['pk'], + ondelete='SET NULL', + ) + op.create_index(f'ix_{table}_source_user_id', table, ['source_user_id']) + + +def downgrade() -> None: + for table in reversed(TABLES): + op.drop_index(f'ix_{table}_source_user_id', table_name=table) + op.drop_constraint( + f'fk_{table}_source_user_id_users', table, type_='foreignkey' + ) + op.drop_column(table, 'source_user_id') diff --git a/app/db/models_sandbox.py b/app/db/models_sandbox.py index ce4155f..24780ba 100644 --- a/app/db/models_sandbox.py +++ b/app/db/models_sandbox.py @@ -35,7 +35,7 @@ def rank_is_1_based(table_name: str) -> tuple: """ return ( CheckConstraint( - 'rank IS NULL OR rank >= 1', name=f'ck_{table_name}_rank_1_based' + 'rank IS NULL OR rank >= 1', name=f"ck_{table_name}_rank_1_based" ), ) @@ -98,6 +98,9 @@ class DbUserMovie(DBBaseModel): movie_id = Column(Integer, ForeignKey('movies.pk'), nullable=False) user_id = Column(Integer, ForeignKey('users.pk'), nullable=False) + source_user_id = Column( + Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True + ) # Two independent lists: a movie may be on the watchlist, in the ranked # list (with a rank position), or both. `completed` is retained from the @@ -119,7 +122,13 @@ class DbUserMovie(DBBaseModel): is_seed_data = Column(Boolean, nullable=False, default=False) movie = relationship('DbMovie', back_populates='user_movies') - user = relationship('DbUser', backref='user_movies') + user = relationship('DbUser', foreign_keys=[user_id], backref='user_movies') + source_user = relationship('DbUser', foreign_keys=[source_user_id]) + + @property + def source_handle(self): + """Return the handle of the user who inspired this tracker entry.""" + return self.source_user.handle if self.source_user else None class DbTVShow(DBBaseModel): @@ -157,6 +166,9 @@ class DbUserTVShow(DBBaseModel): tv_show_id = Column(Integer, ForeignKey('tv_shows.pk'), nullable=False) user_id = Column(Integer, ForeignKey('users.pk'), nullable=False) + source_user_id = Column( + Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True + ) # Two independent lists, mirroring the Movies tracker. `status` and # `freeze` are retained from the legacy import but no longer drive the UI. @@ -176,7 +188,13 @@ class DbUserTVShow(DBBaseModel): is_seed_data = Column(Boolean, nullable=False, default=False) tv_show = relationship('DbTVShow', back_populates='user_tv_shows') - user = relationship('DbUser', backref='user_tv_shows') + user = relationship('DbUser', foreign_keys=[user_id], backref='user_tv_shows') + source_user = relationship('DbUser', foreign_keys=[source_user_id]) + + @property + def source_handle(self): + """Return the handle of the user who inspired this tracker entry.""" + return self.source_user.handle if self.source_user else None class DbTVEpisode(DBBaseModel): @@ -245,6 +263,9 @@ class DbUserVideoGame(DBBaseModel): game_id = Column(Integer, ForeignKey('video_games.pk'), nullable=False) user_id = Column(Integer, ForeignKey('users.pk'), nullable=False) + source_user_id = Column( + Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True + ) # Two independent lists, mirroring the Movies tracker: on_watchlist is the # backlog, on_rankings the played-and-ranked list. `completed` is retained @@ -265,7 +286,13 @@ class DbUserVideoGame(DBBaseModel): is_seed_data = Column(Boolean, nullable=False, default=False) game = relationship('DbVideoGame', back_populates='user_games') - user = relationship('DbUser', backref='user_video_games') + user = relationship('DbUser', foreign_keys=[user_id], backref='user_video_games') + source_user = relationship('DbUser', foreign_keys=[source_user_id]) + + @property + def source_handle(self): + """Return the handle of the user who inspired this tracker entry.""" + return self.source_user.handle if self.source_user else None class DbBook(DBBaseModel): @@ -301,6 +328,9 @@ class DbUserBook(DBBaseModel): book_id = Column(Integer, ForeignKey('books.pk'), nullable=False) user_id = Column(Integer, ForeignKey('users.pk'), nullable=False) + source_user_id = Column( + Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True + ) # Two independent lists, mirroring the Movies tracker: on_watchlist is the # to-read list, on_rankings the read-and-ranked list. `completed` is @@ -320,4 +350,10 @@ class DbUserBook(DBBaseModel): is_seed_data = Column(Boolean, nullable=False, default=False) book = relationship('DbBook', back_populates='user_books') - user = relationship('DbUser', backref='user_books') + user = relationship('DbUser', foreign_keys=[user_id], backref='user_books') + source_user = relationship('DbUser', foreign_keys=[source_user_id]) + + @property + def source_handle(self): + """Return the handle of the user who inspired this tracker entry.""" + return self.source_user.handle if self.source_user else None diff --git a/app/router/v1/router_comparison.py b/app/router/v1/router_comparison.py new file mode 100644 index 0000000..85a95f6 --- /dev/null +++ b/app/router/v1/router_comparison.py @@ -0,0 +1,152 @@ +"""Compare the signed-in user's shelves with one visible profile (#281).""" + +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.auth.oauth2 import get_current_user +from app.db.database import get_db +from app.db.models import DbUser +from app.services.comparison import compare_shelf +from app.services.profile_access import viewer_relationship +from app.services.shelves import SHELVES, Shelf +from app.services.tracker_rules import default_completed_at +from app.services.visibility import admits, ceiling_for + +router = APIRouter(prefix='/v1', tags=['Comparison']) + + +class SaveRecommendation(BaseModel): + """The destination list; ranking placement happens later in its normal UI.""" + + destination: Literal['watchlist', 'rankings'] + + +def _not_found() -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='No visible profile here', + headers={'Vary': 'Authorization'}, + ) + + +def _target_access(db: Session, viewer: DbUser, handle: str): + target = db.query(DbUser).filter(DbUser.handle == handle.lower()).first() + if target is None or target.pk == viewer.pk: + raise _not_found() + relationship = viewer_relationship(db, target, viewer) + ceiling = ceiling_for(relationship) + if not admits(ceiling, target.visibility_profile): + raise _not_found() + visible = [ + shelf + for shelf in SHELVES + if admits(ceiling, getattr(target, shelf.visibility_tier)) + ] + if not visible: + raise _not_found() + return target, relationship, ceiling + + +@router.get('/users/me/comparison/{handle}') +def compare_with_user( + handle: str, + db: Session = Depends(get_db), + current_user: list = Depends(get_current_user), +): + """Return the four domain comparisons visible to this viewer.""" + viewer = current_user[0] + target, relationship, ceiling = _target_access(db, viewer, handle) + return { + 'handle': target.handle, + 'display_name': target.display_name, + 'relationship': relationship.value, + 'domains': [ + compare_shelf(db, viewer, target, shelf, ceiling) for shelf in SHELVES + ], + } + + +def _shelf(category: str) -> Shelf: + found = next((shelf for shelf in SHELVES if shelf.category == category), None) + if found is None: + raise HTTPException(status_code=404, detail='Domain not found') + return found + + +@router.post( + '/users/me/comparison/{handle}/{category}/{item_id}', + status_code=status.HTTP_201_CREATED, +) +def save_recommendation( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + handle: str, + category: str, + item_id: str, + request: SaveRecommendation, + db: Session = Depends(get_db), + current_user: list = Depends(get_current_user), +): + """Save a visible ranked recommendation and retain its first source.""" + viewer = current_user[0] + target, _, ceiling = _target_access(db, viewer, handle) + shelf = _shelf(category) + if not admits(ceiling, getattr(target, shelf.visibility_tier)): + raise _not_found() + + tracker_model, catalog_model = shelf.tracker_model, shelf.catalog_model + catalog = db.query(catalog_model).filter(catalog_model.id == item_id).first() + if catalog is None: + raise HTTPException(status_code=404, detail='Item not found') + target_tracker = ( + db.query(tracker_model) + .filter( + tracker_model.user_id == target.pk, + getattr(tracker_model, shelf.join_col) == catalog.pk, + tracker_model.on_rankings.is_(True), + tracker_model.rank.isnot(None), + ) + .first() + ) + if target_tracker is None: + raise _not_found() + + mine = ( + db.query(tracker_model) + .filter( + tracker_model.user_id == viewer.pk, + getattr(tracker_model, shelf.join_col) == catalog.pk, + ) + .first() + ) + created = mine is None + if mine is None: + mine = tracker_model( + user_id=viewer.pk, + source_user_id=target.pk, + **{shelf.join_col: catalog.pk}, + ) + db.add(mine) + + was_on_rankings = bool(mine.on_rankings) + if request.destination == 'watchlist': + mine.on_watchlist = True + mine.on_rankings = False + mine.rank = None + mine.ranked_at = None + else: + mine.on_watchlist = False + mine.on_rankings = True + mine.rank = None + mine.ranked_at = None + default_completed_at(mine, was_on_rankings) + db.commit() + db.refresh(mine) + return { + 'id': mine.id, + 'item_id': catalog.id, + 'destination': request.destination, + 'source_handle': mine.source_handle, + 'source_recorded': created, + } diff --git a/app/router/v1/router_visibility.py b/app/router/v1/router_visibility.py index b277719..b83ac75 100644 --- a/app/router/v1/router_visibility.py +++ b/app/router/v1/router_visibility.py @@ -31,10 +31,10 @@ from app.config import get_settings from app.db.database import get_db from app.db.db_follow import is_following -from app.db.db_friendship import are_friends from app.db.models import DbUser from app.schemas.model_schemas import InVisibilityUpdate, OutVisibility from app.services import handles +from app.services.profile_access import viewer_relationship from app.services.shelves import SHELVES, Shelf, shelf_tier_fields from app.services.visibility import ( PROFILE_TIER_FIELD, @@ -186,25 +186,6 @@ def _assert_profile_covers_shelves(tiers: dict) -> None: ) -def _viewer_relationship( - db: Session, owner: DbUser, viewer: Optional[DbUser] -) -> ViewerRelationship: - """ - What this caller is to the owner — resolved once, in one lookup. - - Ownership is checked before friendship because a user is never their own - friend (see :func:`app.db.db_friendship.are_friends`): the owner is served - by owning the profile, not by an edge in the graph. - """ - if viewer is None: - return ViewerRelationship.ANONYMOUS - if viewer.pk == owner.pk: - return ViewerRelationship.SELF - if are_friends(db, viewer.pk, owner.pk): - return ViewerRelationship.FRIEND - return ViewerRelationship.NONE - - def _shelf_payload( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals db: Session, user: DbUser, @@ -424,7 +405,7 @@ def public_profile( # pylint: disable=too-many-arguments, too-many-positional-a if user is None: raise not_found - relationship = _viewer_relationship(db, user, viewer) + relationship = viewer_relationship(db, user, viewer) ceiling = ceiling_for(relationship) if not admits(ceiling, user.visibility_profile): raise not_found diff --git a/app/run.py b/app/run.py index 966520d..f627018 100644 --- a/app/run.py +++ b/app/run.py @@ -33,6 +33,7 @@ router_search, router_summary, router_visibility, + router_comparison, router_movies, router_games, router_books, @@ -162,6 +163,7 @@ async def log_request_latency(request, call_next): app.include_router(router_export.router) app.include_router(router_import.router) app.include_router(router_visibility.router) +app.include_router(router_comparison.router) app.include_router(router_friends.router) app.include_router(router_follows.router) app.include_router(router_preferences.router) diff --git a/app/schemas/schemas_sandbox.py b/app/schemas/schemas_sandbox.py index 4bf8c04..4d76094 100644 --- a/app/schemas/schemas_sandbox.py +++ b/app/schemas/schemas_sandbox.py @@ -155,6 +155,7 @@ class UserMovieResponse(UserMovieBase): movie: MovieSummary created_at: datetime updated_at: datetime + source_handle: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -267,6 +268,7 @@ class UserTVShowResponse(UserTVShowBase): tv_show: TVShowSummary created_at: datetime updated_at: datetime + source_handle: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -454,6 +456,7 @@ class UserVideoGameResponse(UserVideoGameBase): game: VideoGameSummary created_at: datetime updated_at: datetime + source_handle: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -552,6 +555,7 @@ class UserBookResponse(UserBookBase): book: BookSummary created_at: datetime updated_at: datetime + source_handle: Optional[str] = None model_config = ConfigDict(from_attributes=True) diff --git a/app/services/comparison.py b/app/services/comparison.py new file mode 100644 index 0000000..d9c3882 --- /dev/null +++ b/app/services/comparison.py @@ -0,0 +1,144 @@ +"""Viewer-safe, four-domain comparison calculations (#281).""" + +from math import sqrt + +from sqlalchemy.orm import Session + +from app.services.shelves import Shelf +from app.services.visibility import VisibilityTier, admits + +MIN_SHARED_FOR_SCORE = 5 +RESULT_LIMIT = 5 +METHOD = ( + 'We adjust for different list sizes and give extra weight to favorites ' + 'near the top.' +) + + +def _position(rank: int, count: int) -> float: + """Top-weighted 0..1 position: zero is best, one is last.""" + if count <= 1: + return 0.0 + return sqrt((rank - 1) / (count - 1)) + + +def _item(catalog, **extra) -> dict: + return { + 'id': str(catalog.id), + 'title': catalog.title, + 'year': catalog.year, + 'poster_url': catalog.poster_url, + **extra, + } + + +def compare_shelf( # pylint: disable=too-many-locals + db: Session, + viewer, + target, + shelf: Shelf, + ceiling: VisibilityTier, +) -> dict: + """Compare one shelf without ever reading target data above the ceiling.""" + ranked_visible = admits(ceiling, getattr(target, shelf.visibility_tier)) + watchlist_visible = ranked_visible and admits( + ceiling, getattr(target, shelf.watchlist_visibility_tier) + ) + base = { + 'category': shelf.category, + 'label': shelf.label, + 'rankings_visible': ranked_visible, + 'watchlist_visible': watchlist_visible, + 'common_watchlist': [], + 'recommendations': [], + 'biggest_gaps': [], + 'most_aligned': [], + 'shared_ranked_count': 0, + 'alignment_score': None, + 'alignment_status': 'hidden' if not ranked_visible else 'not_enough_overlap', + 'method': METHOD, + } + if not ranked_visible: + return base + + tracker, catalog = shelf.tracker_model, shelf.catalog_model + target_rows = ( + db.query(tracker, catalog) + .join(catalog, getattr(tracker, shelf.join_col) == catalog.pk) + .filter( + tracker.user_id == target.pk, + tracker.on_rankings.is_(True), + tracker.rank.isnot(None), + ) + .order_by(tracker.rank.asc()) + .all() + ) + viewer_trackers = { + getattr(row, shelf.join_col): row + for row in db.query(tracker).filter(tracker.user_id == viewer.pk).all() + } + viewer_placed = [ + row + for row in viewer_trackers.values() + if row.on_rankings and row.rank is not None + ] + target_count = len(target_rows) + viewer_count = len(viewer_placed) + + base['recommendations'] = [ + _item( + item, + their_rank=target_tracker.rank, + on_your_watchlist=bool( + viewer_trackers.get(item.pk) and viewer_trackers[item.pk].on_watchlist + ), + ) + for target_tracker, item in target_rows + if not (viewer_trackers.get(item.pk) and viewer_trackers[item.pk].on_rankings) + ][:RESULT_LIMIT] + + shared = [] + for target_tracker, item in target_rows: + mine = viewer_trackers.get(item.pk) + if mine is None or not mine.on_rankings or mine.rank is None: + continue + my_position = _position(mine.rank, viewer_count) + their_position = _position(target_tracker.rank, target_count) + shared.append( + _item( + item, + your_rank=mine.rank, + their_rank=target_tracker.rank, + gap=round(abs(my_position - their_position), 4), + ) + ) + + base['shared_ranked_count'] = len(shared) + base['biggest_gaps'] = sorted( + shared, key=lambda item: (-item['gap'], item['title'].lower()) + )[:RESULT_LIMIT] + base['most_aligned'] = sorted( + shared, key=lambda item: (item['gap'], item['title'].lower()) + )[:RESULT_LIMIT] + if len(shared) >= MIN_SHARED_FOR_SCORE: + mean_gap = sum(item['gap'] for item in shared) / len(shared) + base['alignment_score'] = round(max(0.0, 1 - mean_gap) * 100) + base['alignment_status'] = 'ready' + + if watchlist_visible: + target_watchlist = ( + db.query(tracker, catalog) + .join(catalog, getattr(tracker, shelf.join_col) == catalog.pk) + .filter( + tracker.user_id == target.pk, + tracker.on_watchlist.is_(True), + ) + .order_by(tracker.created_at.desc()) + .all() + ) + base['common_watchlist'] = [ + _item(item) + for _, item in target_watchlist + if viewer_trackers.get(item.pk) and viewer_trackers[item.pk].on_watchlist + ] + return base diff --git a/app/services/profile_access.py b/app/services/profile_access.py new file mode 100644 index 0000000..860421d --- /dev/null +++ b/app/services/profile_access.py @@ -0,0 +1,22 @@ +"""Relationship resolution shared by viewer-aware profile surfaces.""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from app.db.db_friendship import are_friends +from app.db.models import DbUser +from app.services.visibility import ViewerRelationship + + +def viewer_relationship( + db: Session, owner: DbUser, viewer: Optional[DbUser] +) -> ViewerRelationship: + """Resolve the caller once before any shelf visibility checks.""" + if viewer is None: + return ViewerRelationship.ANONYMOUS + if viewer.pk == owner.pk: + return ViewerRelationship.SELF + if are_friends(db, viewer.pk, owner.pk): + return ViewerRelationship.FRIEND + return ViewerRelationship.NONE diff --git a/tests/integration/router_comparison_test.py b/tests/integration/router_comparison_test.py new file mode 100644 index 0000000..db0539b --- /dev/null +++ b/tests/integration/router_comparison_test.py @@ -0,0 +1,182 @@ +# pylint: disable=missing-function-docstring +"""Viewer-safe cross-profile comparisons and recommendation provenance.""" + +from fastapi.testclient import TestClient + +from app.db.models import DbUser +from app.db.models_sandbox import DbMovie, DbUserMovie + + +def _auth(token: str) -> dict: + return {'Authorization': f"Bearer {token}"} + + +def _user(client: TestClient, email: str) -> DbUser: + return client.test_db_session.query(DbUser).filter(DbUser.email == email).one() + + +def _public_movies(client: TestClient, token: str, watchlist='public') -> None: + response = client.put( + '/v1/users/me/visibility', + headers=_auth(token), + json={ + 'handle': 'brandon', + 'visibility_profile': 'public', + 'visibility_movies': 'public', + 'visibility_watchlist_movies': watchlist, + }, + ) + assert response.status_code == 200, response.text + + +def _stock_movies(client: TestClient) -> list[DbMovie]: + db = client.test_db_session + viewer = _user(client, client.second_user.email) + target = _user(client, client.first_user.email) + movies = [ + DbMovie(title=f"Movie {number}", imdb=f"tt90000{number}", year=2000 + number) + for number in range(1, 9) + ] + db.add_all(movies) + db.flush() + # Five shared rankings, ordered in opposite directions. Two unseen target + # picks then exercise the recommendation list; the first is already on + # the viewer's watchlist and must remain eligible with a marker. + for index, movie in enumerate(movies[:7], start=1): + db.add( + DbUserMovie( + user_id=target.pk, + movie_id=movie.pk, + on_rankings=True, + rank=index, + ) + ) + for index, movie in enumerate(reversed(movies[:5]), start=1): + db.add( + DbUserMovie( + user_id=viewer.pk, + movie_id=movie.pk, + on_rankings=True, + rank=index, + ) + ) + db.add( + DbUserMovie( + user_id=viewer.pk, + movie_id=movies[5].pk, + on_watchlist=True, + ) + ) + # Common watchlist that is not part of the ranked overlap. + db.add( + DbUserMovie( + user_id=viewer.pk, + movie_id=movies[7].pk, + on_watchlist=True, + ) + ) + db.add( + DbUserMovie( + user_id=target.pk, + movie_id=movies[7].pk, + on_watchlist=True, + ) + ) + db.commit() + return movies + + +def test_comparison_scores_visible_rankings_and_marks_watchlist( + test_client: TestClient, +): + _public_movies(test_client, test_client.first_user.token) + _stock_movies(test_client) + + response = test_client.get( + '/v1/users/me/comparison/brandon', + headers=_auth(test_client.second_user.token), + ) + assert response.status_code == 200, response.text + movies = next( + domain + for domain in response.json()['domains'] + if domain['category'] == 'movies' + ) + assert movies['shared_ranked_count'] == 5 + assert movies['alignment_status'] == 'ready' + assert isinstance(movies['alignment_score'], int) + assert [item['title'] for item in movies['common_watchlist']] == ['Movie 8'] + assert [item['title'] for item in movies['recommendations']] == [ + 'Movie 6', + 'Movie 7', + ] + assert movies['recommendations'][0]['on_your_watchlist'] is True + assert movies['recommendations'][1]['on_your_watchlist'] is False + assert movies['biggest_gaps'][0]['gap'] >= movies['biggest_gaps'][-1]['gap'] + assert movies['most_aligned'][0]['gap'] <= movies['most_aligned'][-1]['gap'] + + +def test_hidden_watchlist_does_not_block_ranked_comparison(test_client: TestClient): + _public_movies(test_client, test_client.first_user.token, watchlist='friends') + _stock_movies(test_client) + + response = test_client.get( + '/v1/users/me/comparison/brandon', + headers=_auth(test_client.second_user.token), + ) + movies = next( + domain + for domain in response.json()['domains'] + if domain['category'] == 'movies' + ) + assert movies['rankings_visible'] is True + assert movies['watchlist_visible'] is False + assert movies['common_watchlist'] == [] + assert movies['shared_ranked_count'] == 5 + + +def test_private_profile_and_unknown_handle_are_the_same_404(test_client: TestClient): + db = test_client.test_db_session + target = _user(test_client, test_client.first_user.email) + target.handle = 'brandon' + target.visibility_profile = 'private' + db.commit() + headers = _auth(test_client.second_user.token) + + hidden = test_client.get('/v1/users/me/comparison/brandon', headers=headers) + missing = test_client.get('/v1/users/me/comparison/nobody-here', headers=headers) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + + +def test_save_recommendation_records_only_its_first_source(test_client: TestClient): + _public_movies(test_client, test_client.first_user.token) + movies = _stock_movies(test_client) + headers = _auth(test_client.second_user.token) + path = f"/v1/users/me/comparison/brandon/movies/{movies[6].id}" + + first = test_client.post(path, headers=headers, json={'destination': 'watchlist'}) + assert first.status_code == 201, first.text + assert first.json()['source_handle'] == 'brandon' + assert first.json()['source_recorded'] is True + + moved = test_client.post(path, headers=headers, json={'destination': 'rankings'}) + assert moved.status_code == 201, moved.text + assert moved.json()['source_handle'] == 'brandon' + assert moved.json()['source_recorded'] is False + + viewer = _user(test_client, test_client.second_user.email) + tracker = ( + test_client.test_db_session.query(DbUserMovie) + .filter(DbUserMovie.user_id == viewer.pk, DbUserMovie.movie_id == movies[6].pk) + .one() + ) + assert tracker.on_rankings is True + assert tracker.on_watchlist is False + assert tracker.rank is None + assert tracker.source_user.handle == 'brandon' + + listed = test_client.get('/v1/users/me/movies', headers=headers) + assert listed.status_code == 200, listed.text + saved = next(row for row in listed.json() if row['movie']['id'] == movies[6].id) + assert saved['source_handle'] == 'brandon' diff --git a/tests/unit/services/comparison_test.py b/tests/unit/services/comparison_test.py new file mode 100644 index 0000000..4b3b609 --- /dev/null +++ b/tests/unit/services/comparison_test.py @@ -0,0 +1,16 @@ +# pylint: disable=missing-function-docstring +"""The ranking normalization behind comparison scores.""" + +from app.services.comparison import _position + + +def test_top_weighting_expands_differences_near_the_favorites(): + # Both spans are ten places long, but the top-of-list span deliberately + # counts for more than the same raw distance near the bottom. + top_gap = _position(11, 100) - _position(1, 100) + bottom_gap = _position(100, 100) - _position(90, 100) + assert top_gap > bottom_gap + + +def test_one_item_list_has_a_stable_best_position(): + assert _position(1, 1) == 0.0