fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s

CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.

Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".

That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.

The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n                2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
This commit is contained in:
2026-08-17 00:25:40 -04:00
parent 414dfb23b6
commit 6d729d1512
3 changed files with 37 additions and 16 deletions
@@ -66,6 +66,13 @@ const response: AdminMissingResponse = {
afterEach(() => vi.clearAllMocks());
// Whitespace-normalised text of a testid'd element. Svelte templates wrap
// mid-sentence, so raw textContent has newlines where the rendered page has
// single spaces — asserting on it directly makes tests fail on reflow.
function text(testId: string): string {
return (screen.getByTestId(testId).textContent ?? '').replace(/\s+/g, ' ').trim();
}
function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
vi.mocked(createMissingFilesQuery).mockReturnValue(
mockQuery({ data, ...extra }) as ReturnType<typeof createMissingFilesQuery>
@@ -134,12 +141,14 @@ describe('admin missing files', () => {
gave_up_at: null
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/asked lidarr twice/i);
expect(line.textContent).toMatch(/last 2d ago/i);
// Collapsed: the markup wraps mid-sentence, so textContent carries the
// template's newlines and indentation between the words.
const line = text('reacquisition-state');
expect(line).toMatch(/asked lidarr twice/i);
expect(line).toMatch(/last 2d ago/i);
// Forward-looking, not relativeTime — which would say "just now" for a
// future timestamp and read as nonsense.
expect(line.textContent).toMatch(/next in 4h/i);
expect(line).toMatch(/next in 4h/i);
});
test('a given-up album says so and says it can come back', () => {
@@ -151,10 +160,10 @@ describe('admin missing files', () => {
gave_up_at: new Date(Date.now() - 5 * DAY).toISOString()
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/gave up/i);
expect(line.textContent).toMatch(/after 3 times/i);
expect(line.textContent).toMatch(/tried again/i);
const line = text('reacquisition-state');
expect(line).toMatch(/gave up/i);
expect(line).toMatch(/after 3 times/i);
expect(line).toMatch(/tried again/i);
});
// Paging only appears when it can do something: a single page of results