* feat(watch-together): make vote rooms actually vote selection_mode has been stored, normalized and published since the feature landed, and nothing has ever read it. A "vote" room behaved exactly like a host_pick one: members could suggest and vote, the tally was recorded and broadcast, and then the host promoted whatever they liked regardless of it. In a vote room the host now starts the winner rather than choosing it. Promoting anything other than the leading suggestion is refused, because being able to overrule the tally makes the mode host_pick with extra steps and turns the vote counts on everyone else's screen into decoration. The winner is the head of the repository's existing ordering (vote_count DESC, created_at ASC): most votes, ties to whoever suggested first — deterministic, and re-suggesting a title cannot jump the queue. A room where nobody has voted has no winner and says so, rather than quietly promoting the oldest suggestion as though a vote had happened. host_pick rooms are untouched: the host still promotes freely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK * fix(watch-together): close the second door into a vote room's selection Gating PromoteSuggestion left SelectItem wide open: it is host-only but was not gated by selection mode, so the host of a vote room could set any title directly and bypass the vote entirely. Enforcing the tally on one path and not the other makes the vote counts on everyone else's screen decoration. A vote room now refuses a direct selection outright. The winner is the only way in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK * fix(watch-together): stop a dropped socket reading as the host leaving hostDisconnectTTL was 15 seconds, which treated any transient drop as a departure. An explicit leave and an explicit close already tear the room down immediately, so this timer only ever covers a host who has NOT said they are going — and at 15s a host who backgrounded the app, moved between screens, or hit a brief network blip lost the room for everyone with a "host_left" nobody could explain. Two minutes survives a reconnect or an app switch, and is short enough that a genuinely departed host does not leave a room open all evening. The janitor still reaps idle rooms independently. This matters for what the clients are growing into: a room you stay in while you browse for something to suggest. A client that drops its socket when the lobby leaves composition should cost you a reconnect, not the room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK * fix(watch-together): let a vote room actually start its winner The vote gate landed on both doors into a room's selection, but promoting the winner walks through SelectItem to commit — so the gate meant to stop the host bypassing the vote also stopped the vote itself. Vote rooms could not start playback by any route. Split the commit path: SelectItem keeps the gate for direct requests, and PromoteSuggestion goes through the internal path once it has confirmed the suggestion is the winner. Map ErrVoteRoomSelection in the promote handler too, so a future regression there reads as a conflict rather than a 500. Add service-level tests for both gates — the previous tests only covered the pure winnerFrom helper, which is why the suite stayed green while vote rooms were non-functional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
52 lines
1.7 KiB
Go
52 lines
1.7 KiB
Go
package watchtogether
|
|
|
|
import "testing"
|
|
|
|
// The winner is the head of the repository's ordering (vote_count DESC,
|
|
// created_at ASC). These pin the rule the ordering encodes, so a change to the
|
|
// query that breaks it fails here rather than silently starting the wrong film.
|
|
func TestVoteWinnerIsTheHeadOfTheOrdering(t *testing.T) {
|
|
ordered := []Suggestion{
|
|
{ID: "b", Title: "Heat", VoteCount: 3},
|
|
{ID: "a", Title: "Alien", VoteCount: 1},
|
|
{ID: "c", Title: "Ronin", VoteCount: 0},
|
|
}
|
|
|
|
winner, err := winnerFrom(ordered)
|
|
if err != nil {
|
|
t.Fatalf("no winner: %v", err)
|
|
}
|
|
if winner.ID != "b" {
|
|
t.Fatalf("winner = %q, want the most-voted", winner.ID)
|
|
}
|
|
}
|
|
|
|
// A vote nobody cast has no winner. Promoting the oldest suggestion there would
|
|
// let a host "start the winner" of a vote that never happened — precisely the
|
|
// confusion this mode exists to remove.
|
|
func TestVoteWinnerRefusesWhenNobodyHasVoted(t *testing.T) {
|
|
if _, err := winnerFrom(nil); err != ErrNoVotesCast {
|
|
t.Fatalf("empty room: err = %v, want ErrNoVotesCast", err)
|
|
}
|
|
unvoted := []Suggestion{{ID: "a", VoteCount: 0}, {ID: "b", VoteCount: 0}}
|
|
if _, err := winnerFrom(unvoted); err != ErrNoVotesCast {
|
|
t.Fatalf("no votes: err = %v, want ErrNoVotesCast", err)
|
|
}
|
|
}
|
|
|
|
// Ties resolve to whoever suggested first, which the ordering gives us for
|
|
// free — and which means re-suggesting the same title cannot jump the queue.
|
|
func TestVoteWinnerBreaksTiesByOrder(t *testing.T) {
|
|
tied := []Suggestion{
|
|
{ID: "first", VoteCount: 2},
|
|
{ID: "second", VoteCount: 2},
|
|
}
|
|
winner, err := winnerFrom(tied)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if winner.ID != "first" {
|
|
t.Fatalf("tie went to %q, want the earlier suggestion", winner.ID)
|
|
}
|
|
}
|