generated from nhcarrigan/template
perf: virtual windowing, markdown memoisation, and search debounce
- Terminal: virtual windowing renders max 150 lines, loads 50 older on scroll-up with scroll position compensation; auto-advances window forward during auto-scroll so old DOM nodes are unloaded continuously - Markdown: two-stage derived rendering separates expensive parse step (marked + hljs + spoilers, runs on content change) from cheap search highlight step (runs on query change only) - Achievements: fix double Object.keys() call in derived store - Terminal: 150ms debounce on search query to reduce redundant updates - Tests: add Markdown.test.ts for processSpoilers and highlightSearchMatches; extend Terminal.test.ts with virtual windowing helper coverage
This commit is contained in:
@@ -108,15 +108,19 @@
|
|||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(text: string): string {
|
// Two-stage reactive rendering:
|
||||||
|
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
|
||||||
|
let parsedHtml = $derived.by(() => {
|
||||||
try {
|
try {
|
||||||
const html = marked.parse(text) as string;
|
const html = marked.parse(content) as string;
|
||||||
const withSpoilers = processSpoilers(html);
|
return processSpoilers(html);
|
||||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
|
||||||
} catch {
|
} catch {
|
||||||
return text;
|
return content;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
|
||||||
|
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
|
||||||
|
|
||||||
function handleSpoilerClick(event: Event) {
|
function handleSpoilerClick(event: Event) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
@@ -191,7 +195,7 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||||
{@html renderMarkdown(content)}
|
{@html renderedHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Markdown Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions extracted from the Markdown component:
|
||||||
|
* - processSpoilers: wraps ||text|| syntax in spoiler spans, leaving code blocks untouched
|
||||||
|
* - highlightSearchMatches: injects <mark> tags for search terms, skipping code blocks
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Code blocks render with syntax highlighting and a copy button
|
||||||
|
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
||||||
|
* - [ ] Search query highlights matching text in non-code content
|
||||||
|
* - [ ] Links open in the system browser via the Tauri opener
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror functions from Markdown.svelte for isolated testing
|
||||||
|
|
||||||
|
function processSpoilers(html: string): string {
|
||||||
|
const codeBlockPlaceholders: string[] = [];
|
||||||
|
|
||||||
|
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||||
|
codeBlockPlaceholders.push(match);
|
||||||
|
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
processed = processed.replace(
|
||||||
|
/\|\|(.+?)\|\|/g,
|
||||||
|
'<span class="spoiler" role="button" tabindex="0">$1</span>'
|
||||||
|
);
|
||||||
|
|
||||||
|
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||||
|
return codeBlockPlaceholders[parseInt(index)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSearchMatches(html: string, query: string): string {
|
||||||
|
if (!query) return html;
|
||||||
|
|
||||||
|
const codeBlockPlaceholders: string[] = [];
|
||||||
|
const tagPlaceholders: string[] = [];
|
||||||
|
|
||||||
|
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||||
|
codeBlockPlaceholders.push(match);
|
||||||
|
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||||
|
tagPlaceholders.push(match);
|
||||||
|
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||||
|
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||||
|
|
||||||
|
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||||
|
return tagPlaceholders[parseInt(index)];
|
||||||
|
});
|
||||||
|
|
||||||
|
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||||
|
return codeBlockPlaceholders[parseInt(index)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("processSpoilers", () => {
|
||||||
|
it("wraps ||text|| in a spoiler span", () => {
|
||||||
|
const result = processSpoilers("<p>||secret||</p>");
|
||||||
|
expect(result).toContain('<span class="spoiler"');
|
||||||
|
expect(result).toContain("secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role=button and tabindex to spoiler spans", () => {
|
||||||
|
const result = processSpoilers("<p>||hidden||</p>");
|
||||||
|
expect(result).toContain('role="button"');
|
||||||
|
expect(result).toContain('tabindex="0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves content without spoiler markers unchanged", () => {
|
||||||
|
const html = "<p>Normal text here</p>";
|
||||||
|
expect(processSpoilers(html)).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple spoilers in the same string", () => {
|
||||||
|
const result = processSpoilers("<p>||a|| and ||b||</p>");
|
||||||
|
const matches = result.match(/class="spoiler"/g);
|
||||||
|
expect(matches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply spoiler syntax inside code blocks", () => {
|
||||||
|
const html = "<pre><code>||not a spoiler||</code></pre>";
|
||||||
|
const result = processSpoilers(html);
|
||||||
|
expect(result).not.toContain('class="spoiler"');
|
||||||
|
expect(result).toContain("||not a spoiler||");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply spoiler syntax inside inline code", () => {
|
||||||
|
const html = "<p>Example: <code>||inline||</code></p>";
|
||||||
|
const result = processSpoilers(html);
|
||||||
|
expect(result).not.toContain('class="spoiler"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles spoilers adjacent to code blocks correctly", () => {
|
||||||
|
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
|
||||||
|
const result = processSpoilers(html);
|
||||||
|
expect(result).toContain('<span class="spoiler"');
|
||||||
|
expect(result).toContain("<pre><code>code</code></pre>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("highlightSearchMatches", () => {
|
||||||
|
it("returns unchanged html when query is empty string", () => {
|
||||||
|
const html = "<p>hello world</p>";
|
||||||
|
expect(highlightSearchMatches(html, "")).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps matched text in a mark element", () => {
|
||||||
|
const result = highlightSearchMatches("<p>hello world</p>", "hello");
|
||||||
|
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
|
||||||
|
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights all occurrences", () => {
|
||||||
|
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
|
||||||
|
const matches = result.match(/<mark class="search-highlight">/g);
|
||||||
|
expect(matches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight inside code blocks", () => {
|
||||||
|
const html = "<pre><code>hello inside code</code></pre>";
|
||||||
|
const result = highlightSearchMatches(html, "hello");
|
||||||
|
expect(result).not.toContain('<mark class="search-highlight">');
|
||||||
|
expect(result).toContain("hello inside code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not corrupt HTML tags", () => {
|
||||||
|
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
|
||||||
|
expect(result).toContain('<p class="foo">');
|
||||||
|
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes regex special characters in the query", () => {
|
||||||
|
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
|
||||||
|
expect(result).toContain('<mark class="search-highlight">$1</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights text outside code blocks whilst leaving code intact", () => {
|
||||||
|
const html = "<pre><code>match here</code></pre><p>match here too</p>";
|
||||||
|
const result = highlightSearchMatches(html, "match");
|
||||||
|
expect(result).toContain("<pre><code>match here</code></pre>");
|
||||||
|
expect(result).toContain('<mark class="search-highlight">match</mark>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,15 +12,26 @@
|
|||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||||
|
|
||||||
|
// Virtual windowing constants — keeps the DOM lean during long sessions
|
||||||
|
const WINDOW_SIZE = 150; // max lines rendered at once
|
||||||
|
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
||||||
|
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
||||||
|
|
||||||
let terminalElement: HTMLDivElement;
|
let terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
let lines: TerminalLine[] = [];
|
let lines: TerminalLine[] = [];
|
||||||
let currentSearchQuery = "";
|
let currentSearchQuery = "";
|
||||||
let currentConversationId: string | null = null;
|
let currentConversationId: string | null = null;
|
||||||
let isRestoringScroll = false;
|
let isRestoringScroll = false;
|
||||||
|
let windowStart = 0;
|
||||||
|
let isLoadingMore = false;
|
||||||
|
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
searchQuery.subscribe((value) => {
|
searchQuery.subscribe((value) => {
|
||||||
currentSearchQuery = value;
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
currentSearchQuery = value;
|
||||||
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
let hidePaths = false;
|
let hidePaths = false;
|
||||||
@@ -54,12 +65,14 @@
|
|||||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||||
isRestoringScroll = true;
|
isRestoringScroll = true;
|
||||||
if (savedPosition === -1) {
|
if (savedPosition === -1) {
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to bottom — window reactive statement will advance windowStart
|
||||||
shouldAutoScroll = true;
|
shouldAutoScroll = true;
|
||||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
// Restore to saved position
|
// Restore to saved position — show from the beginning of history
|
||||||
|
windowStart = 0;
|
||||||
shouldAutoScroll = false;
|
shouldAutoScroll = false;
|
||||||
|
await tick();
|
||||||
terminalElement.scrollTop = savedPosition;
|
terminalElement.scrollTop = savedPosition;
|
||||||
}
|
}
|
||||||
// Small delay to prevent the scroll handler from overriding our restore
|
// Small delay to prevent the scroll handler from overriding our restore
|
||||||
@@ -69,10 +82,28 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleScroll() {
|
async function handleScroll() {
|
||||||
if (!terminalElement || isRestoringScroll) return;
|
if (!terminalElement || isRestoringScroll) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||||
|
|
||||||
|
// Load older lines when the user scrolls near the top
|
||||||
|
if (scrollTop < 300 && windowStart > 0 && !isLoadingMore) {
|
||||||
|
isLoadingMore = true;
|
||||||
|
const prevScrollHeight = terminalElement.scrollHeight;
|
||||||
|
const prevScrollTop = terminalElement.scrollTop;
|
||||||
|
|
||||||
|
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (terminalElement) {
|
||||||
|
// Compensate for the new items pushing content down
|
||||||
|
terminalElement.scrollTop =
|
||||||
|
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
||||||
|
}
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
@@ -138,6 +169,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visible slice — only render lines within the current window
|
||||||
|
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
||||||
|
|
||||||
|
// Height of the invisible spacer above the visible window
|
||||||
|
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
||||||
|
|
||||||
|
// Advance the window forward when auto-scrolling and new lines overflow it
|
||||||
|
$: if (shouldAutoScroll && lines.length > windowStart + WINDOW_SIZE) {
|
||||||
|
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (currentSearchQuery && lines.length > 0) {
|
if (currentSearchQuery && lines.length > 0) {
|
||||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
@@ -179,6 +221,7 @@
|
|||||||
if (terminalElement) {
|
if (terminalElement) {
|
||||||
terminalElement.removeEventListener("copy", handleCopy);
|
terminalElement.removeEventListener("copy", handleCopy);
|
||||||
}
|
}
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy message content to clipboard
|
// Copy message content to clipboard
|
||||||
@@ -238,7 +281,13 @@
|
|||||||
Waiting for Claude... Type a message below to start!
|
Waiting for Claude... Type a message below to start!
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each lines as line (line.id)}
|
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
||||||
|
{#if windowStart > 0}
|
||||||
|
<div class="terminal-older-indicator">
|
||||||
|
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each visibleLines as line (line.id)}
|
||||||
{#if line.type === "thinking"}
|
{#if line.type === "thinking"}
|
||||||
{#if showThinking}
|
{#if showThinking}
|
||||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||||
@@ -428,6 +477,15 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-older-indicator {
|
||||||
|
color: var(--text-tertiary, #6b7280);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-header-text {
|
.terminal-header-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,27 @@ function truncateToolContent(content: string): string {
|
|||||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtual windowing helpers — mirror the logic in Terminal.svelte
|
||||||
|
|
||||||
|
const WINDOW_SIZE = 150;
|
||||||
|
const LOAD_CHUNK = 50;
|
||||||
|
const AVG_LINE_HEIGHT = 60;
|
||||||
|
|
||||||
|
/** Returns the windowStart index when auto-scrolling to the bottom. */
|
||||||
|
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
|
||||||
|
return Math.max(0, linesLength - windowSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
|
||||||
|
function olderWindowStart(currentStart: number, chunkSize: number): number {
|
||||||
|
return Math.max(0, currentStart - chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the height in pixels of the invisible top spacer. */
|
||||||
|
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
|
||||||
|
return windowStart * avgLineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
|
|
||||||
describe("getLineClass", () => {
|
describe("getLineClass", () => {
|
||||||
@@ -262,3 +283,86 @@ describe("truncateToolContent", () => {
|
|||||||
expect(result.endsWith("...")).toBe(false);
|
expect(result.endsWith("...")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("autoScrollWindowStart", () => {
|
||||||
|
it("returns 0 when lines fit within the window", () => {
|
||||||
|
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when lines exactly fill the window", () => {
|
||||||
|
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances when lines exceed the window size", () => {
|
||||||
|
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative value", () => {
|
||||||
|
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
|
||||||
|
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("olderWindowStart", () => {
|
||||||
|
it("subtracts the chunk size from the current start", () => {
|
||||||
|
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative value when chunk is larger than start", () => {
|
||||||
|
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when current start is 0", () => {
|
||||||
|
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when current start exactly equals the chunk size", () => {
|
||||||
|
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly loads a partial chunk near the beginning", () => {
|
||||||
|
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("topSpacerHeight", () => {
|
||||||
|
it("returns 0 when windowStart is 0", () => {
|
||||||
|
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiplies windowStart by avgLineHeight", () => {
|
||||||
|
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales linearly with windowStart", () => {
|
||||||
|
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
|
||||||
|
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
|
||||||
|
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
|
||||||
|
expect(topSpacerHeight(5, 100)).toBe(500);
|
||||||
|
expect(topSpacerHeight(5, 80)).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("virtual windowing constants", () => {
|
||||||
|
it("WINDOW_SIZE is 150", () => {
|
||||||
|
expect(WINDOW_SIZE).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("LOAD_CHUNK is 50", () => {
|
||||||
|
expect(LOAD_CHUNK).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
|
||||||
|
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AVG_LINE_HEIGHT is a positive number", () => {
|
||||||
|
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
|||||||
return byRarity;
|
return byRarity;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
export const achievementProgress = derived(achievementsStore, ($store) => {
|
||||||
unlocked: $store.totalUnlocked,
|
const total = Object.keys($store.achievements).length;
|
||||||
total: Object.keys($store.achievements).length,
|
return {
|
||||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
unlocked: $store.totalUnlocked,
|
||||||
}));
|
total,
|
||||||
|
percentage: Math.round(($store.totalUnlocked / total) * 100),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize achievement listener
|
// Initialize achievement listener
|
||||||
export async function initAchievementsListener() {
|
export async function initAchievementsListener() {
|
||||||
|
|||||||
Reference in New Issue
Block a user