fix: what a live play-through found - stale search, hint cost, empty day (2.2.2)
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 8s
frontend-ci / lint (push) Successful in 23s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / fs-scan (push) Successful in 18s
frontend-ci / test (push) Successful in 37s
frontend-ci / build (push) Successful in 45s
frontend-ci / push (push) Skipped
frontend-ci / secrets-scan (pull_request) Successful in 3s
frontend-ci / sast (pull_request) Successful in 7s
frontend-ci / fs-scan (pull_request) Successful in 14s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 28s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped

Played end to end against a clean-install dev stack: Quick Play, a Custom
Game loop closed and scored server-side, a hint, an undo, every anonymous
route. Four things the 1,112 unit tests had not caught.

Picking an actor flips the search to movie mode while the 300ms debounce
still holds the actor's name, so a movie search for "Daniel Craig" ran
and opened a listbox under an empty input; an empty input now means
nothing to search. The hint button said -150 while the server charges
200. A fresh install's empty daily-challenge day (a 404 until the midnight
job) read as "Failed to load". And every abandoned request logged
"[API Error] canceled", two per page change.
This commit is contained in:
TehRiehlDeal committed 2026-09-12 02:42:42 -07:00
1 parent e1762e382d
commit 2b13d46254
11 files changed
+122 -11

No files matched your search

+23
View File
@@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.2.2] - 2026-09-12
Found by playing the app end to end against a clean-install development stack
before the first push - a Quick Play, a Custom Game loop closed and scored, a
hint, an undo, and every anonymous route.
### Fixed
- **No more stale results after picking an actor or movie.** Selecting an
actor clears the input and flips the search to movie mode, but the 300ms
debounce still held the actor's name, so a *movie* search for "Daniel Craig"
ran and opened a listbox of results under an empty box. An empty input now
means nothing to search, whatever the debounce holds.
- **The hint button says what a hint costs.** It read "-150 pts"; the server
charges 200 (150 is the duplicate penalty).
- **A day with no daily challenges yet reads as what it is.** The API answers
404 before the midnight job has run - a fresh install, or a failed job - and
the page called that "Failed to load today's challenges", which reads as an
outage. It now says they have not been generated yet and when they will be.
- Requests the page abandoned on purpose (navigating away mid-fetch aborts
them) are no longer logged as `[API Error] canceled` - two per page change,
burying the real ones.
## [2.2.1] - 2026-09-12
The frontend test foundation (WP-19: the frontend half of L1), and the defects
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "movie-loop-frontend",
"version": "2.2.1",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "movie-loop-frontend",
"version": "2.2.1",
"version": "2.2.2",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.2.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "movie-loop-frontend",
"private": true,
"version": "2.2.1",
"version": "2.2.2",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
+14
View File
@@ -48,6 +48,8 @@ const createMock = vi.fn();
vi.mock('axios', () => ({
default: {
create: (...args: unknown[]) => createMock(...args),
isCancel: (err: unknown) =>
typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ERR_CANCELED',
},
}));
@@ -251,6 +253,18 @@ describe('response interceptor - non-401 / generic errors', () => {
quiet.mockRestore();
});
it('does not log an aborted request: the page cancelled it on purpose', async () => {
// Navigating away mid-fetch aborts the controller; axios rejects with a
// cancellation. Two of these per page change were showing up as errors.
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = { ...axiosError({ message: 'canceled' }), code: 'ERR_CANCELED' };
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).not.toHaveBeenCalled();
quiet.mockRestore();
});
it('falls back to the error message when there is no response', async () => {
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ message: 'Network Error' });
+5
View File
@@ -59,6 +59,11 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
// A request the page abandoned on purpose - navigating away mid-fetch
// aborts its controller - is not an error worth a console line; logging
// it as one buried the real ones.
if (axios.isCancel(error)) return Promise.reject(error);
const message =
error.response?.data?.message || error.message || 'An error occurred';
console.error('[API Error]', message);
+3 -1
View File
@@ -87,7 +87,9 @@ describe('HintButton', () => {
expect(
screen.getByRole('button', { name: /Hint \(3 left\)/ }),
).toBeInTheDocument();
expect(screen.getByText('-150 pts')).toBeInTheDocument();
// The server charges 200 per hint (score.service.ts); the label used to
// say 150, the duplicate penalty's figure. Seen live in WP-19's smoke run.
expect(screen.getByText('-200 pts')).toBeInTheDocument();
});
it('is disabled when a hint cannot be taken (no session)', () => {
+1 -1
View File
@@ -41,7 +41,7 @@ export default function HintButton() {
<Lightbulb className="mr-1 h-3.5 w-3.5" />
)}
Hint ({remaining} left)
<span className="ml-1 text-xs text-red-500">-150 pts</span>
<span className="ml-1 text-xs text-red-500">-200 pts</span>
</Button>
{hints.length > 0 && (
@@ -520,4 +520,41 @@ describe('SearchAutocomplete', () => {
expect(screen.queryByRole('listbox')).toBeNull();
expect(movieInput).toHaveFocus();
}, 10000);
it('does not search the new mode with the previous query after a selection flips the mode', async () => {
// Seen live in WP-19's smoke run: pick an actor, the mode flips to movie,
// and 300ms of debounce still hold the actor's name - so a movie search
// for "Daniel Craig" ran and opened a listbox under an empty input.
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Daniel Craig')],
total_pages: 1,
total_results: 1,
});
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Daniel Craig - Permis de jouer')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const onSelectActor = vi.fn();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'Daniel Craig');
await screen.findByText('Daniel Craig');
await user.click(screen.getByRole('button', { name: /Daniel Craig/ }));
expect(onSelectActor).toHaveBeenCalledTimes(1);
// The parent reacts to the selection by switching the mode, as the game does.
rerender(
<SearchAutocomplete mode="movie" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await pastDebounce();
expect(searchMoviesApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
expect(screen.getByRole('combobox', { name: 'Search for a movie' })).toHaveValue('');
}, 10000);
});
+11 -5
View File
@@ -31,7 +31,13 @@ export default function SearchAutocomplete({
const [movieResults, setMovieResults] = useState<TmdbMovieResult[]>([]);
const [personResults, setPersonResults] = useState<TmdbPersonResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
// The debounced value lags the live one by 300ms. When a selection clears
// the input and flips the mode, the search effect re-ran for the new mode
// with the *old* debounced query still in hand, and opened a listbox of
// movie results for the actor's name under an empty input. An empty live
// query therefore means "nothing to search", whatever the debounce holds.
const debouncedQuery = useDebounce(query, 300);
const searchQuery = query.trim().length === 0 ? '' : debouncedQuery;
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listId = `search-listbox-${mode}`;
@@ -39,7 +45,7 @@ export default function SearchAutocomplete({
// A query too short to search is a derived condition. Clearing results in an
// effect meant an extra committed render on every keystroke below the
// threshold, and left the stored results and the displayed ones out of step.
const isSearchable = debouncedQuery.trim().length >= 2;
const isSearchable = searchQuery.trim().length >= 2;
const results = isSearchable
? mode === 'actor'
? personResults
@@ -48,7 +54,7 @@ export default function SearchAutocomplete({
// Loading and error are derived from which search has settled, so neither is
// set synchronously inside the effect.
const searchKey = `${mode}:${debouncedQuery}`;
const searchKey = `${mode}:${searchQuery}`;
const [settled, setSettled] = useState<{
key: string;
error: string | null;
@@ -65,7 +71,7 @@ export default function SearchAutocomplete({
let error: string | null = null;
try {
if (mode === 'actor') {
const data = await searchPersons(debouncedQuery);
const data = await searchPersons(searchQuery);
if (cancelled) return;
setPersonResults(
data.results.filter((p) => p.known_for_department === 'Acting'),
@@ -74,7 +80,7 @@ export default function SearchAutocomplete({
setIsOpen(true);
setActiveIndex(-1);
} else {
const data = await searchMovies(debouncedQuery);
const data = await searchMovies(searchQuery);
if (cancelled) return;
setMovieResults(data.results);
setPersonResults([]);
@@ -93,7 +99,7 @@ export default function SearchAutocomplete({
return () => {
cancelled = true;
};
}, [debouncedQuery, mode, isSearchable, searchKey]);
}, [searchQuery, mode, isSearchable, searchKey]);
// Reset when the mode flips between actor and movie. Adjusting state during
// render is React's documented pattern for reacting to a changed prop; doing
+13
View File
@@ -137,6 +137,19 @@ describe('loading and listing', () => {
).toBeInTheDocument();
});
it('explains a day with nothing generated yet instead of calling it a failure', async () => {
// Seen on a clean install in WP-19's smoke run: the API's 404 for an
// empty day rendered as "Failed to load", which reads as an outage.
challengesSpy.mockRejectedValue({
response: { status: 404, data: { message: 'No daily challenges available for today' } },
});
renderPage();
expect(
await screen.findByText(/haven't been generated yet/),
).toBeInTheDocument();
expect(screen.queryByText(/Failed to load/)).toBeNull();
});
it('does not check server completion for an anonymous visitor', async () => {
renderPage();
await screen.findByText('easy');
+12 -1
View File
@@ -10,6 +10,7 @@ import { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderbo
import { useAuthStore } from '@/stores/auth-store';
import { Loader2, Calendar, Trophy, Clock } from 'lucide-react';
import { storage } from '@/lib/storage';
import { getErrorStatus } from '@/lib/error';
import { releaseYear } from '@/lib/tmdb';
type Difficulty = 'easy' | 'medium' | 'hard';
@@ -59,7 +60,17 @@ export default function DailyChallenge() {
});
}
})
.catch(() => setError('Failed to load today\'s challenges.'))
.catch((err: unknown) =>
// The API answers 404 when nothing has been generated for the day -
// a fresh install before the first midnight job, or a job that
// failed. That is an ordinary state, not an outage, and it used to
// read as one.
setError(
getErrorStatus(err) === 404
? "Today's challenges haven't been generated yet. They are created at 00:00 UTC - check back then."
: 'Failed to load today\'s challenges.',
),
)
.finally(() => setLoading(false));
}, [userId]);