test(js): add window exports + vitest coverage for 3 Tier-2 pure helpers (#4312)

Each source file gets a one-line `window.X = X;` export added at the
bottom, paired with a small vitest file. The pattern matches existing
exports in app.js (marked, io, Chart, etc.) and the LdrValueHelpers
pattern in utils — no lint friction, no global-namespace concerns.

The three helpers are pure and were verified worth-testing in a
multi-round review: substantive logic, clear input/output contracts,
real-world ROI.

Helpers exported and tested:

  pages/subscriptions.js — formatNextUpdate(dateString)  (5 cases)
    Date-formatter for the "Next refresh in …" label. Tests cover the
    invalid-input sentinel, the future / near-future paths, and the
    >5-min-past branch that triggers the timezone-offset correction
    (which has caused user-visible bugs before).

  followup.js — FollowUpResearch.getResearchIdFromPage()  (6 cases)
    Extracts the parent research ID from one of four fallback sources
    (URL path, query, [data-research-id], window.currentResearchId).
    Each fallback path is isolated in its own test; precedence between
    path and query is also asserted.

  collection_details.js — getProviderLabel(value)  (5 cases)
    Maps embedding-provider keys to friendly labels with a sensible
    fallback chain (known key → friendly name; unknown key → raw value;
    null/undefined/"" → "Not configured").

Each test stubs globalThis.fetch defensively in case the source file's
DOMContentLoaded listener fires (it usually doesn't — happy-dom has
already fired the event by the time the dynamic import settles).
This commit is contained in:
LearningCircuit
2026-05-25 13:35:20 +02:00
committed by GitHub
parent d621b4197c
commit 650cc60029
6 changed files with 216 additions and 0 deletions

View File

@@ -709,3 +709,6 @@ async function searchCollection(query) {
container.innerHTML = '<div class="ldr-empty-state"><i class="fas fa-exclamation-triangle fa-2x"></i><p>Search failed. Please try again.</p></div>';
}
}
// Exposed on window so vitest can exercise the pure provider-mapping helper.
window.getProviderLabel = getProviderLabel;

View File

@@ -351,3 +351,7 @@ function toggleAdvancedOptions(event) {
const panel = document.getElementById('advancedOptionsPanel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
// Exposed on window so vitest can exercise getResearchIdFromPage
// (and other future helpers) without spinning up the full followup modal.
window.FollowUpResearch = FollowUpResearch;

View File

@@ -747,3 +747,7 @@ function showSchedulerInfo() {
<p>The scheduler will continue running subscriptions for 48 hours after your last login. Simply log in periodically to keep it active.</p>
`, 'info');
}
// Exposed on window so vitest can exercise the pure formatting helper
// without standing up the full subscriptions page DOM.
window.formatNextUpdate = formatNextUpdate;

View File

@@ -0,0 +1,51 @@
/**
* Tests for collection_details.js — getProviderLabel.
*
* Tiny pure helper that maps an embedding-provider value to a
* user-facing label, falling back to the raw value, then to a
* default sentinel. Used in the collection-details page header.
*/
let getProviderLabel;
beforeAll(async () => {
// collection_details.js has a DOMContentLoaded listener that
// queries DOM elements that don't exist in the test env. The event
// has typically already fired in happy-dom by import time, so the
// listener never runs. Stub fetch defensively just in case.
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
);
await import('@js/collection_details.js');
getProviderLabel = window.getProviderLabel;
});
describe('getProviderLabel', () => {
it('maps known provider keys to their friendly labels', () => {
expect(getProviderLabel('sentence_transformers')).toBe('Sentence Transformers');
expect(getProviderLabel('ollama')).toBe('Ollama');
expect(getProviderLabel('openai')).toBe('OpenAI');
expect(getProviderLabel('anthropic')).toBe('Anthropic');
expect(getProviderLabel('cohere')).toBe('Cohere');
});
it('returns the input verbatim for unknown keys (so the UI shows the raw value)', () => {
expect(getProviderLabel('huggingface')).toBe('huggingface');
expect(getProviderLabel('local-custom-provider')).toBe('local-custom-provider');
});
it('falls back to "Not configured" for null', () => {
expect(getProviderLabel(null)).toBe('Not configured');
});
it('falls back to "Not configured" for undefined', () => {
expect(getProviderLabel(undefined)).toBe('Not configured');
});
it('falls back to "Not configured" for the empty string', () => {
// '' is falsy and not in the map, so both `providerMap[v]` and the
// `providerValue` fallback fail, landing on the sentinel.
expect(getProviderLabel('')).toBe('Not configured');
});
});

86
tests/js/followup.test.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* Tests for followup.js — FollowUpResearch.getResearchIdFromPage.
*
* Extracts the parent research ID from one of four fallback sources:
* 1. URL path segment (/results/<id>)
* 2. URL query param (?research_id=<id>)
* 3. DOM data-research-id attribute
* 4. window.currentResearchId
*
* Each test isolates exactly one source so the precedence order doesn't
* have to be re-derived from the test setup.
*/
let FollowUpResearch;
beforeAll(async () => {
// followup.js auto-constructs an instance and binds a DOMContentLoaded
// listener that fetches /static/templates/followup_modal.html. The
// DOMContentLoaded event has already fired in happy-dom by the time
// import settles, so the listener never runs — but stub fetch
// defensively in case ordering changes.
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('') })
);
await import('@js/followup.js');
FollowUpResearch = window.FollowUpResearch;
});
function setLocation(pathname, search = '') {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { pathname, search, hash: '', host: 'localhost', protocol: 'http:' },
});
}
describe('FollowUpResearch.getResearchIdFromPage', () => {
afterEach(() => {
document.body.innerHTML = '';
delete window.currentResearchId;
});
it('extracts the id from /results/<id> in the URL path', () => {
setLocation('/results/abc-123-def');
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBe('abc-123-def');
});
it('falls through to the query string when the path does not match', () => {
setLocation('/somewhere-else', '?research_id=xyz-789');
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBe('xyz-789');
});
it('falls through to a [data-research-id] DOM attribute', () => {
setLocation('/somewhere-else', '');
const el = document.createElement('div');
el.dataset.researchId = 'data-id-456';
document.body.appendChild(el);
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBe('data-id-456');
});
it('falls through to window.currentResearchId as the last resort', () => {
setLocation('/somewhere-else', '');
window.currentResearchId = 'window-id-999';
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBe('window-id-999');
});
it('returns null when none of the four sources have a value', () => {
setLocation('/somewhere-else', '');
// No DOM element, no window.currentResearchId.
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBeNull();
});
it('prefers the URL path over the query string (precedence smoke test)', () => {
setLocation('/results/from-path', '?research_id=from-query');
const fr = new FollowUpResearch();
expect(fr.getResearchIdFromPage()).toBe('from-path');
});
});

View File

@@ -0,0 +1,68 @@
/**
* Tests for pages/subscriptions.js — formatNextUpdate.
*
* Pure date-formatting helper used by the subscriptions page to label
* "Next refresh in …". The 5-minute past threshold drives a timezone
* correction branch that has caused user-visible bugs before; lock the
* branch boundaries down here.
*/
let formatNextUpdate;
beforeAll(async () => {
// subscriptions.js has a DOMContentLoaded listener that calls fetch().
// The event has typically already fired in happy-dom by the time we
// import, so the listener never runs — but stub fetch defensively in
// case the test ordering changes.
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
);
await import('@js/pages/subscriptions.js');
formatNextUpdate = window.formatNextUpdate;
});
describe('formatNextUpdate', () => {
it('returns "Invalid date" for non-parsable input', () => {
expect(formatNextUpdate('not-a-date')).toBe('Invalid date');
expect(formatNextUpdate('')).toBe('Invalid date');
expect(formatNextUpdate(undefined)).toBe('Invalid date');
});
it('returns a locale string for a clearly-future date (no past-branch fixup)', () => {
const tenYearsLater = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
const result = formatNextUpdate(tenYearsLater.toISOString());
// Should be a non-empty formatted string, not the invalid sentinel.
expect(result).not.toBe('Invalid date');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('returns a locale string for "near future" (within 5 min, no fixup)', () => {
const twoMinutesFromNow = new Date(Date.now() + 2 * 60 * 1000);
const result = formatNextUpdate(twoMinutesFromNow.toISOString());
expect(result).not.toBe('Invalid date');
expect(typeof result).toBe('string');
});
it('applies the timezone-offset correction when the date is >5 min in the past', () => {
// The past-branch subtracts getTimezoneOffset()*60000 ms from the
// parsed date, then formats. For timezones with non-zero offsets
// the formatted output will differ from the un-corrected string;
// for UTC environments both branches produce the same output, so
// we just assert the value is not "Invalid date" and the function
// executed the past branch without throwing.
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
const result = formatNextUpdate(oneHourAgo.toISOString());
expect(result).not.toBe('Invalid date');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('treats a date exactly at the 5-minute past boundary as past-correctable', () => {
// sixMinutesAgo is unambiguously past the threshold (>5min ago).
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
const result = formatNextUpdate(sixMinutesAgo.toISOString());
expect(result).not.toBe('Invalid date');
});
});