Files
siftlode/backend/tests/test_playlist_add_position.py
T
peter 1a1e9d9542 fix(playlists): what the first review of bulk actions found
An Esc during a pointer drag is spent on the drag and leaves the selection.
A wheel over the rail re-reads the target each frame, so a drop lands on the
row under the pointer. After a bulk removal the focus goes to the next row
left, and to the list's menu when the list is emptied.

remove-bulk names the videos that went, and its Undo brings back only those.
An Undo of a move keeps each row (and when it was saved), leaves out a video
that moved on from the target, closes up its place, and says how many came
back. Delete with every tick hidden removes nothing and says why. A list gone
before the lock answers 404, and a body without its videos 422.

One removal path now: the single row goes through the bulk one, the same
toast and undo, which also retires the add-at-position route. railDrop reuses
the store and scrollParent, the drag image the overlay and the z-index token,
and the sensors keep their number. The count is read out from a region that
outlives the band. Tests fill the gaps the review named, each seen failing.
2026-11-02 03:41:08 +01:00

111 lines
3.8 KiB
Python

"""Putting a video back AT a position: the list page's "Undo" of a removal.
The row has to come back where it was in ONE request. The two-call shape (append, then send the
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
also overwrite any change another tab made in between. Positions can have gaps after removals, so
the position is an index over the ORDERED rows, and the list is renumbered around it.
"""
from datetime import datetime, timezone
from app.catalog.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem
def _seed(db, user, ids, *, positions=None, **kw):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in [*ids, "vX"]:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
pl = Playlist(user_id=user.id, name="L", **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
db.commit()
return pl
def _ids(db, pl_id):
db.expire_all()
return [
r.video_id
for r in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _restore(client, pl_id, video_id, position):
return client.post(
f"/api/playlists/{pl_id}/items/restore",
json={"video_ids": [video_id], "positions": [position]},
)
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _restore(as_user(user), pl.id, "vX", 1)
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
_restore(as_user(user), pl.id, "vX", 2)
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
def test_a_position_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_restore(as_user(user), pl.id, "vX", 40)
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_an_add_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
r = as_user(user).post(f"/api/playlists/{pl.id}/items", json={"video_id": "vX"})
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
# A restore is idempotent; a position must not turn it into a reorder.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_restore(as_user(user), pl.id, "c", 0)
assert _ids(db, pl.id) == ["a", "b", "c"]
def test_a_negative_position_is_refused(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
r = _restore(as_user(user), pl.id, "vX", -1)
assert r.status_code == 422
assert _ids(db, pl.id) == ["a"]
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
client = as_user(user)
from app.sync.playlists import current_fingerprint
pl.synced_fingerprint = current_fingerprint(db, pl)
pl.dirty = False
db.commit()
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
_restore(client, pl.id, "b", 1)
db.expire_all()
assert db.get(Playlist, pl.id).dirty is False