From 8e82fe8ecfa6fd5dcb504a8c77e8f4f3c5345fd3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 16 Jan 2026 14:06:05 +0100 Subject: [PATCH 01/42] Do morph type filtering server-side --- .../LexEntryFilterMapProvider.cs | 1 + .../FwLite/LcmCrdt/EntryFilterMapProvider.cs | 1 + .../MiniLcm.Tests/QueryEntryTestsBase.cs | 18 ++++++++++++ .../FwLite/MiniLcm/Filtering/EntryFilter.cs | 1 + .../Filtering/EntryFilterMapProvider.cs | 1 + .../src/project/browse/EntriesList.svelte | 29 ++----------------- .../src/project/browse/SearchFilter.svelte | 19 +++++++++++- 7 files changed, 42 insertions(+), 28 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs index 019208f154..8abd0d754a 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs @@ -32,6 +32,7 @@ public class LexEntryFilterMapProvider : EntryFilterMapProvider public override Expression> EntryCitationForm => (entry, ws) => entry.PickText(entry.CitationForm, ws); public override Expression> EntryLiteralMeaning => (entry, ws) => entry.PickText(entry.LiteralMeaning, ws); + public override Expression> EntryMorphType => e => LcmHelpers.FromLcmMorphType(e.PrimaryMorphType); public override Expression> EntryComplexFormTypes => e => EmptyToNull(e.ComplexFormEntryRefs.SelectMany(r => r.ComplexEntryTypesRS)); public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToNull; } diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 5aa3a06b3f..a62d909c9c 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -27,6 +27,7 @@ public class EntryFilterMapProvider : EntryFilterMapProvider public override Expression> EntryLexemeForm => (entry, ws) => Json.Value(entry.LexemeForm, ms => ms[ws])!; public override Expression> EntryCitationForm => (entry, ws) => Json.Value(entry.CitationForm, ms => ms[ws])!; public override Expression> EntryLiteralMeaning => (entry, ws) => Json.Value(entry.LiteralMeaning, ms => ms[ws])!.GetPlainText(); + public override Expression> EntryMorphType => e => e.MorphType; public override Expression> EntryComplexFormTypes => e => e.ComplexFormTypes; public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList; } diff --git a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs index 117ab99970..224002899e 100644 --- a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs @@ -226,6 +226,24 @@ public async Task CanFilterToMissingComplexFormTypesWithEmptyArray() results.Select(e => e.LexemeForm["en"]).Distinct().Should().BeEquivalentTo(Apple, Banana, Kiwi, Null_LexemeForm); } + [Fact] + public async Task CanFilterByMorphTypeSingleType() + { + // This test ensures we can filter by a single morph type + var results = await Api.GetEntries(new(Filter: new() { GridifyFilter = "MorphType=Root" })).ToArrayAsync(); + // Root is not set on any of our test entries (they all default to Stem), so should be empty + results.Should().BeEmpty(); + } + + [Fact] + public async Task CanFilterByMorphTypeTwoTypes() + { + // This test ensures we can filter by multiple morph types using OR syntax + var results = await Api.GetEntries(new(Filter: new() { GridifyFilter = "MorphType=Root|MorphType=Stem" })).ToArrayAsync(); + // All our test entries default to Stem, so we should get all of them + results.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(Apple, Peach, Banana, Kiwi, Null_LexemeForm); + } + [Fact] public async Task CanFilterLexemeForm() { diff --git a/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs b/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs index f1f332961b..a1688e3911 100644 --- a/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs +++ b/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs @@ -26,6 +26,7 @@ public static GridifyMapper NewMapper(EntryFilterMapProvider provider) mapper.AddMap(nameof(Entry.LexemeForm), provider.EntryLexemeForm!); mapper.AddMap(nameof(Entry.CitationForm), provider.EntryCitationForm!); mapper.AddMap(nameof(Entry.LiteralMeaning), provider.EntryLiteralMeaning!); + mapper.AddMap(nameof(Entry.MorphType), provider.EntryMorphType); mapper.AddMap(nameof(Entry.ComplexFormTypes), provider.EntryComplexFormTypes, provider.EntryComplexFormTypesConverter); return mapper; } diff --git a/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs b/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs index 00be759cde..58967af5f3 100644 --- a/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs +++ b/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs @@ -19,6 +19,7 @@ public abstract class EntryFilterMapProvider public abstract Expression> EntryLexemeForm { get; } public abstract Expression> EntryCitationForm { get; } public abstract Expression> EntryLiteralMeaning { get; } + public abstract Expression> EntryMorphType { get; } public abstract Expression> EntryComplexFormTypes { get; } public virtual Func? EntryComplexFormTypesConverter { get; } = null; } diff --git a/frontend/viewer/src/project/browse/EntriesList.svelte b/frontend/viewer/src/project/browse/EntriesList.svelte index 43358a012a..80e903dd07 100644 --- a/frontend/viewer/src/project/browse/EntriesList.svelte +++ b/frontend/viewer/src/project/browse/EntriesList.svelte @@ -1,5 +1,5 @@ + +{@render children(state)} diff --git a/frontend/viewer/src/stories/primitives/delayed.stories.svelte b/frontend/viewer/src/stories/primitives/delayed.stories.svelte new file mode 100644 index 0000000000..284cc10326 --- /dev/null +++ b/frontend/viewer/src/stories/primitives/delayed.stories.svelte @@ -0,0 +1,140 @@ + + + + {#snippet template(args)} + {@const simulatedLoadTime = 500} +
+ { + await delay(simulatedLoadTime); + return '😎🎈'; + }} + delay={args.delay} + > + {#snippet children(state)} + {@const start = Date.now()} + {#if state.loading} +
+ + Waiting {args.delay}ms before loading and {simulatedLoadTime}ms to simulate load... +
+ {:else if state.error} +
Error: {state.error}
+ {:else} + {@const end = Date.now()} +
Loaded: {state.current} (in {end - start}ms)
+ {/if} + {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template(args)} +
+ Promise.resolve('Should not be called')} + getCached={() => 'Instant Cached Data'} + delay={args.delay} + > + {#snippet children(state)} + {#if state.loading} +
If you see this, cache didn't work...
+ {:else} +
From Cache: {state.current}
+ {/if} + {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template(args)} +
+ { + await delay(500); + throw new Error('Failed to fetch data!'); + }} + delay={args.delay} + > + {#snippet children(state)} + {#if state.loading} + + {:else if state.error} +
+
+ {(state.error as Error).message} +
+ {:else} +
{state.current}
+ {/if} + {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template(args)} +
+
+ Scroll quickly to see placeholders. Items are cached after loading. +
+ + {#snippet children(id: number)} +
+ loadItem(id)} + getCached={() => cache.get(id)} + delay={args.delay} + > + {#snippet children(state)} + {#if state.loading} +
+ + Loading #{id}... +
+ {:else if state.error} + Error loading #{id} + {:else} + {state.current} + {/if} + {/snippet} +
+
+ {/snippet} +
+
+ {/snippet} +
From 8ea5a966428451a5a1afd4e7db41c6c19d025dad Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 16 Jan 2026 17:05:57 +0100 Subject: [PATCH 04/42] Move SortMenu and extract options --- frontend/viewer/src/locales/en.po | 6 +++--- frontend/viewer/src/locales/es.po | 6 +++--- frontend/viewer/src/locales/fr.po | 6 +++--- frontend/viewer/src/locales/id.po | 6 +++--- frontend/viewer/src/locales/ko.po | 6 +++--- frontend/viewer/src/locales/ms.po | 6 +++--- frontend/viewer/src/locales/sw.po | 6 +++--- frontend/viewer/src/locales/vi.po | 6 +++--- frontend/viewer/src/project/browse/BrowseView.svelte | 3 ++- .../viewer/src/project/browse/EntriesList.svelte | 2 +- .../src/project/browse/{ => sort}/SortMenu.svelte | 12 +++--------- frontend/viewer/src/project/browse/sort/options.ts | 9 +++++++++ 12 files changed, 39 insertions(+), 35 deletions(-) rename frontend/viewer/src/project/browse/{ => sort}/SortMenu.svelte (90%) create mode 100644 frontend/viewer/src/project/browse/sort/options.ts diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 9d9a6587dd..b377651eee 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -245,7 +245,7 @@ msgid "Author:" msgstr "Author:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Auto" @@ -255,7 +255,7 @@ msgid "Auto syncing" msgstr "Auto syncing" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Best match" @@ -886,7 +886,7 @@ msgstr "green" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Headword" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 89450f8156..16578f5cc0 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Autor:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Auto" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Sincronización automática" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Mejor partido" @@ -891,7 +891,7 @@ msgstr "" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Palabra clave" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 03f1e0d25d..e5a7fa7a9c 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Auteur :" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Auto" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Synchronisation automatique" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Meilleure correspondance" @@ -891,7 +891,7 @@ msgstr "" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Entrée de dictionnaire" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index db85dd2c8c..6cf0fe0732 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Penulis:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Otomatis" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Sinkronisasi otomatis" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Pertandingan terbaik" @@ -891,7 +891,7 @@ msgstr "" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Lema" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index a68b78f0dd..5ed25b186a 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "작성자:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "자동" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "자동 동기화" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "베스트 매치" @@ -891,7 +891,7 @@ msgstr "" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "헤드워드" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 7305805a39..151e7b8155 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Pengarang:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Auto" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Segerakkan automatik" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Padanan terbaik" @@ -891,7 +891,7 @@ msgstr "hijau" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Kata Utama" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 13cca30194..cda185daeb 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Mwandishi:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Otomatiki" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Kuoanisha otomatiki" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Ujumbe bora" @@ -891,7 +891,7 @@ msgstr "kijani" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Neno la kichwa" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index f7a6e92b35..2b8a48ad0f 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -250,7 +250,7 @@ msgid "Author:" msgstr "Tác giả:" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Auto" msgstr "Tự động" @@ -260,7 +260,7 @@ msgid "Auto syncing" msgstr "Đang đồng bộ tự động" #. Sort option -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Best match" msgstr "Khớp tốt nhất" @@ -891,7 +891,7 @@ msgstr "" #. Sort option #. Sort results by headword/main entry form (alphabetically) #. Related: "Auto" (default smart sort), "Best match" (relevance) -#: src/project/browse/SortMenu.svelte +#: src/project/browse/sort/SortMenu.svelte msgid "Headword" msgstr "Từ đầu mục" diff --git a/frontend/viewer/src/project/browse/BrowseView.svelte b/frontend/viewer/src/project/browse/BrowseView.svelte index 1b1abb678d..10f51ee729 100644 --- a/frontend/viewer/src/project/browse/BrowseView.svelte +++ b/frontend/viewer/src/project/browse/BrowseView.svelte @@ -13,7 +13,8 @@ import {useCurrentView} from '$lib/views/view-service'; import IfOnce from '$lib/components/if-once/if-once.svelte'; import {SortField, type IPartOfSpeech, type ISemanticDomain} from '$lib/dotnet-types'; - import SortMenu, {type SortConfig} from './SortMenu.svelte'; + import SortMenu from './sort/SortMenu.svelte'; + import type {SortConfig} from './sort/options'; import {useProjectContext} from '$project/project-context.svelte'; import type {EntryListViewMode} from './EntryListViewOptions.svelte'; import EntryListViewOptions from './EntryListViewOptions.svelte'; diff --git a/frontend/viewer/src/project/browse/EntriesList.svelte b/frontend/viewer/src/project/browse/EntriesList.svelte index 80e903dd07..a934e19212 100644 --- a/frontend/viewer/src/project/browse/EntriesList.svelte +++ b/frontend/viewer/src/project/browse/EntriesList.svelte @@ -14,7 +14,7 @@ import EntryMenu from './EntryMenu.svelte'; import FabContainer from '$lib/components/fab/fab-container.svelte'; import {VList, type VListHandle} from 'virtua/svelte'; - import type {SortConfig} from './SortMenu.svelte'; + import type {SortConfig} from './sort/options'; import {AppNotification} from '$lib/notifications/notifications'; import {Icon} from '$lib/components/ui/icon'; import {useProjectContext} from '$project/project-context.svelte'; diff --git a/frontend/viewer/src/project/browse/SortMenu.svelte b/frontend/viewer/src/project/browse/sort/SortMenu.svelte similarity index 90% rename from frontend/viewer/src/project/browse/SortMenu.svelte rename to frontend/viewer/src/project/browse/sort/SortMenu.svelte index d7851975e9..81a7585342 100644 --- a/frontend/viewer/src/project/browse/SortMenu.svelte +++ b/frontend/viewer/src/project/browse/sort/SortMenu.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts index 16cd8edebf..e25fdcf935 100644 --- a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts @@ -282,7 +282,7 @@ export class EntryLoaderService { const maxBatch = Math.floor(maxIndex / this.batchSize); for (let batch = 0; batch <= maxBatch; batch++) { const startIndex = batch * this.batchSize; - const endIndex = Math.min(startIndex + this.batchSize, (this.totalCount ?? 0)); + const endIndex = Math.min(startIndex + this.batchSize, (this.totalCount ?? startIndex + this.batchSize)); let allPresent = true; for (let i = startIndex; i < endIndex; i++) { From ca3f5824c3f08b10f0a53af19154fb1d0c5304bb Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 19 Jan 2026 13:53:30 +0100 Subject: [PATCH 11/42] Remove old service --- .../lib/services/chunk-cache-service.test.ts | 476 ------------------ .../src/lib/services/chunk-cache-service.ts | 419 --------------- 2 files changed, 895 deletions(-) delete mode 100644 frontend/viewer/src/lib/services/chunk-cache-service.test.ts delete mode 100644 frontend/viewer/src/lib/services/chunk-cache-service.ts diff --git a/frontend/viewer/src/lib/services/chunk-cache-service.test.ts b/frontend/viewer/src/lib/services/chunk-cache-service.test.ts deleted file mode 100644 index a4ead04c6e..0000000000 --- a/frontend/viewer/src/lib/services/chunk-cache-service.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import {describe, it, expect, beforeEach, vi} from 'vitest'; -import {ChunkCacheService, type EntryWindowProvider} from './chunk-cache-service'; -import type {IEntry} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IEntry'; - -/** - * Helper to create mock entries - */ -function createEntry(id: string, lexeme: string): IEntry { - return { - id, - lexemeForm: { en: lexeme }, - citationForm: {}, - literalMeaning: { spans: [] }, - note: { spans: [] }, - morphType: 0, - senses: [], - components: [], - complexForms: [], - complexFormTypes: [], - publishIn: [], - deletedAt: undefined - }; -} - -/** - * Mock backend provider - */ -class MockProvider implements EntryWindowProvider { - private entries: IEntry[] = []; - private failOnceStart: number | null = null; - - setEntries(entries: IEntry[]): void { - this.entries = entries; - } - - failNextWindowForStart(start: number): void { - this.failOnceStart = start; - } - - // eslint-disable-next-line @typescript-eslint/require-await - async fetchWindow(start: number, size: number): Promise<{ entries: IEntry[]; firstIndex: number }> { - if (this.failOnceStart === start) { - this.failOnceStart = null; - throw new Error('Transient fetch failure'); - } - const window = this.entries.slice(start, start + size); - return { entries: window, firstIndex: start }; - } - - // eslint-disable-next-line @typescript-eslint/require-await - async fetchRowIndex(entryId: string): Promise<{ rowIndex: number; entry: IEntry }> { - const rowIndex = this.entries.findIndex(e => e.id === entryId); - if (rowIndex === -1) throw new Error(`Entry ${entryId} not found`); - return { rowIndex, entry: this.entries[rowIndex] }; - } -} - -describe('ChunkCacheService', () => { - let service: ChunkCacheService; - let provider: MockProvider; - const chunkSize = 10; - - beforeEach(() => { - provider = new MockProvider(); - // Create 35 mock entries (3.5 chunks worth) - const entries = Array.from({ length: 35 }, (_, i) => - createEntry(`entry-${i}`, `Item ${i}`) - ); - provider.setEntries(entries); - service = new ChunkCacheService(provider, chunkSize); - }); - - describe('ensureWindow', () => { - it('should fetch and cache first chunk', async () => { - const result = await service.ensureWindow(0, 5); - - expect(result).toHaveLength(5); - expect(result[0].id).toBe('entry-0'); - expect(result[4].id).toBe('entry-4'); - }); - - it('should fetch multiple chunks for large window', async () => { - const result = await service.ensureWindow(0, 25); - - expect(result).toHaveLength(25); - expect(result[0].id).toBe('entry-0'); - expect(result[24].id).toBe('entry-24'); - }); - - it('should cache and reuse chunks on second call', async () => { - const fetchSpy = vi.spyOn(provider, 'fetchWindow'); - - // First call - await service.ensureWindow(0, 10); - expect(fetchSpy).toHaveBeenCalled(); - const callCount1 = fetchSpy.mock.calls.length; - - // Second call to same window - const result = await service.ensureWindow(0, 10); - expect(result).toHaveLength(10); - expect(fetchSpy.mock.calls.length).toBe(callCount1); // No additional calls - }); - - it('should emit onWindowReady event', async () => { - const onWindowReady = vi.fn(); - service.subscribe({ onWindowReady }); - - await service.ensureWindow(0, 5); - - expect(onWindowReady).toHaveBeenCalled(); - }); - - it('should handle offset windows', async () => { - const result = await service.ensureWindow(15, 5); - - expect(result).toHaveLength(5); - expect(result[0].id).toBe('entry-15'); - expect(result[4].id).toBe('entry-19'); - }); - - it('should handle partial last chunk', async () => { - const result = await service.ensureWindow(30, 10); - - expect(result).toHaveLength(5); // Only 5 entries left - expect(result[0].id).toBe('entry-30'); - expect(result[4].id).toBe('entry-34'); - }); - }); - - describe('applyEntryUpdated', () => { - it('should update entry in cached chunk', async () => { - await service.ensureWindow(0, 10); - - const updatedEntry = createEntry('entry-3', 'Updated Item 3'); - service.applyEntryUpdated(updatedEntry); - - const result = await service.ensureWindow(0, 10); - expect(result[3].lexemeForm.en).toBe('Updated Item 3'); - }); - - it('should emit onChunkChanged event', async () => { - await service.ensureWindow(0, 10); - - const onChunkChanged = vi.fn(); - service.subscribe({ onChunkChanged }); - - const updatedEntry = createEntry('entry-5', 'Updated'); - service.applyEntryUpdated(updatedEntry); - - expect(onChunkChanged).toHaveBeenCalled(); - }); - - it('should handle update on non-cached entry', () => { - const onError = vi.fn(); - service.subscribe({ onError }); - - // This should silently do nothing, not error - const updatedEntry = createEntry('entry-100', 'Updated'); - service.applyEntryUpdated(updatedEntry); - - expect(onError).not.toHaveBeenCalled(); - }); - }); - - describe('applyEntryDeleted', () => { - it('should delete entry from cached chunk', async () => { - await service.ensureWindow(0, 15); - - service.applyEntryDeleted('entry-5'); - - const result = await service.ensureWindow(0, 15); - expect(result).not.toContainEqual(expect.objectContaining({ id: 'entry-5' })); - }); - - it('should cascade delete from next chunk', async () => { - await service.ensureWindow(0, 25); - - service.applyEntryDeleted('entry-5'); - - const result = await service.ensureWindow(0, 25); - // After delete of entry-5, entry-10 should move up - expect(result[10].id).toBe('entry-11'); - }); - - it('should emit onChunkChanged on cascade', async () => { - await service.ensureWindow(0, 25); - - const onChunkChanged = vi.fn(); - service.subscribe({ onChunkChanged }); - - service.applyEntryDeleted('entry-5'); - - // Should be called at least once for the deletion - expect(onChunkChanged).toHaveBeenCalled(); - }); - }); - - describe('applyEntryInserted', () => { - it('should insert entry at absolute index', async () => { - await service.ensureWindow(0, 15); - - const newEntry = createEntry('new-entry', 'New Item'); - service.applyEntryInserted(newEntry, 5); - - const result = await service.ensureWindow(0, 20); - expect(result[5]).toEqual(expect.objectContaining({ id: 'new-entry' })); - }); - - it('should cascade insert to next chunk', async () => { - await service.ensureWindow(0, 25); - - const newEntry = createEntry('new-entry', 'New Item'); - service.applyEntryInserted(newEntry, 8); - - const result = await service.ensureWindow(0, 25); - expect(result[8].id).toBe('new-entry'); - // entry-34 should be pushed out (was at index 34, now past end or in overflow) - }); - - it('should emit onChunkChanged on insert', async () => { - await service.ensureWindow(0, 15); - - const onChunkChanged = vi.fn(); - service.subscribe({ onChunkChanged }); - - const newEntry = createEntry('new-entry', 'New Item'); - service.applyEntryInserted(newEntry, 5); - - expect(onChunkChanged).toHaveBeenCalled(); - }); - }); - - describe('delete and insert cascade patterns', () => { - it('should handle multiple deletes', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-2'); - service.applyEntryDeleted('entry-5'); - service.applyEntryDeleted('entry-1'); - - const result = await service.ensureWindow(0, 20); - const ids = result.map(e => e.id); - expect(ids).not.toContain('entry-1'); - expect(ids).not.toContain('entry-2'); - expect(ids).not.toContain('entry-5'); - }); - - it('should handle insert followed by delete', async () => { - await service.ensureWindow(0, 15); - - const newEntry = createEntry('new-entry', 'New'); - service.applyEntryInserted(newEntry, 5); - - service.applyEntryDeleted('new-entry'); - - const result = await service.ensureWindow(0, 15); - expect(result).not.toContainEqual(expect.objectContaining({ id: 'new-entry' })); - }); - - it('should maintain consistency after mixed operations', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-3'); - const newEntry = createEntry('inserted', 'Inserted'); - service.applyEntryInserted(newEntry, 10); - const updated = createEntry('entry-8', 'Updated'); - service.applyEntryUpdated(updated); - - const result = await service.ensureWindow(0, 20); - expect(result).not.toContainEqual(expect.objectContaining({ id: 'entry-3' })); - expect(result).toContainEqual(expect.objectContaining({ id: 'inserted' })); - // After deleting entry-3, entry-8 shifts from position 8 to 7 - expect(result[7].lexemeForm.en).toBe('Updated'); - }); - }); - - describe('observer subscriptions', () => { - it('should allow multiple observers', async () => { - const obs1 = { onChunkChanged: vi.fn() }; - const obs2 = { onChunkChanged: vi.fn() }; - - service.subscribe(obs1); - service.subscribe(obs2); - - await service.ensureWindow(0, 10); - service.applyEntryUpdated(createEntry('entry-0', 'Updated')); - - expect(obs1.onChunkChanged).toHaveBeenCalled(); - expect(obs2.onChunkChanged).toHaveBeenCalled(); - }); - - it('should allow unsubscribe', async () => { - const onChunkChanged = vi.fn(); - const unsubscribe = service.subscribe({ onChunkChanged }); - - await service.ensureWindow(0, 10); - service.applyEntryUpdated(createEntry('entry-0', 'Updated')); - - expect(onChunkChanged).toHaveBeenCalledTimes(1); - - unsubscribe(); - - service.applyEntryUpdated(createEntry('entry-1', 'Updated')); - expect(onChunkChanged).toHaveBeenCalledTimes(1); // No additional call - }); - }); - - describe('clear', () => { - it('should clear all cached chunks', async () => { - await service.ensureWindow(0, 20); - - service.clear(); - - const onWindowReady = vi.fn(); - service.subscribe({ onWindowReady }); - - // After clear, should fetch again - const fetchSpy = vi.spyOn(provider, 'fetchWindow'); - await service.ensureWindow(0, 10); - - expect(fetchSpy).toHaveBeenCalled(); - }); - }); - - describe('error handling', () => { - it('should emit error on fetch failure', async () => { - const failProvider = new (class implements EntryWindowProvider { - // eslint-disable-next-line @typescript-eslint/require-await - async fetchWindow(): Promise<{ entries: IEntry[]; firstIndex: number }> { - throw new Error('Network error'); - } - - // eslint-disable-next-line @typescript-eslint/require-await - async fetchRowIndex(): Promise<{ rowIndex: number; entry: IEntry }> { - throw new Error('Network error'); - } - })(); - - const failService = new ChunkCacheService(failProvider, chunkSize); - const onError = vi.fn(); - failService.subscribe({ onError }); - - try { - await failService.ensureWindow(0, 10); - } catch { - // Expected - } - - expect(onError).toHaveBeenCalled(); - }); - }); - - describe('window bounds and tail refill', () => { - it('should preserve window length after delete by not cascading beyond visible range', async () => { - await service.ensureWindow(0, 15); - - // Delete from within the window - service.applyEntryDeleted('entry-5'); - - const result = await service.ensureWindow(0, 15); - // Window should still be 15 entries (refilled or pulled from cascade) - expect(result.length).toBeGreaterThanOrEqual(14); - expect(result).not.toContainEqual(expect.objectContaining({ id: 'entry-5' })); - }); - - it('should discard overflow outside requestedWindow on insert', async () => { - await service.ensureWindow(0, 15); - - // Insert near end of visible window - const newEntry = createEntry('new', 'New'); - service.applyEntryInserted(newEntry, 14); - - const result = await service.ensureWindow(0, 15); - // Should have inserted entry and not accumulated garbage beyond window - expect(result).toContainEqual(expect.objectContaining({ id: 'new' })); - expect(result.length).toBeLessThanOrEqual(15); - }); - - it('should update chunk bounds when entries cascade', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-5'); - - // After delete, window should shrink by 1 but entries should still be valid - const result = await service.ensureWindow(0, 20); - expect(result).toHaveLength(19); - - // Verify chunk boundaries were updated (no duplicates or gaps) - const ids = new Set(result.map(e => e.id)); - expect(ids.size).toBe(result.length); - }); - - it('should maintain correct order during cascades', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-8'); - const newEntry = createEntry('inserted-x', 'X'); - service.applyEntryInserted(newEntry, 12); - - const result = await service.ensureWindow(0, 20); - const ids = result.map(e => e.id); - - // Verify no duplicates - const uniqueIds = new Set(ids); - expect(uniqueIds.size).toBe(ids.length); - - // Verify order is still valid (monotonically increasing except for inserted) - expect(ids).toContainEqual('inserted-x'); - }); - - it('should cascade delete at chunk boundary and adjust next chunk bounds', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-9'); // last slot of chunk 0 - - const result = await service.ensureWindow(0, 20); - expect(result[9].id).toBe('entry-10'); - expect(result[10].id).toBe('entry-11'); - }); - - it('should cascade insert at chunk boundary across multiple chunks', async () => { - await service.ensureWindow(0, 30); - - const newEntry = createEntry('insert-boundary', 'Boundary'); - service.applyEntryInserted(newEntry, 10); // first slot of chunk 1 - - const result = await service.ensureWindow(0, 30); - expect(result[10].id).toBe('insert-boundary'); - // ensure original entries shifted but still ordered (entry-19 pushed toward chunk 2) - expect(result).toContainEqual(expect.objectContaining({ id: 'entry-19' })); - }); - - it('should handle insert then delete in adjacent chunks without duplicates', async () => { - await service.ensureWindow(0, 25); - - const inserted = createEntry('insert-adjacent', 'Adj'); - service.applyEntryInserted(inserted, 10); // at boundary - service.applyEntryDeleted('entry-15'); // next chunk - - const result = await service.ensureWindow(0, 25); - const ids = result.map(e => e.id); - expect(new Set(ids).size).toBe(ids.length); - expect(ids).toContain('insert-adjacent'); - }); - - it('should recover after transient fetch failure', async () => { - provider.failNextWindowForStart(0); - await expect(service.ensureWindow(0, 10)).rejects.toThrow('Transient fetch failure'); - - const result = await service.ensureWindow(0, 10); - expect(result).toHaveLength(10); - }); - - it('should handle window shift without stale chunk interference', async () => { - await service.ensureWindow(0, 15); - const firstWindow = await service.ensureWindow(0, 15); - expect(firstWindow[0].id).toBe('entry-0'); - - const shifted = await service.ensureWindow(20, 5); - expect(shifted[0].id).toBe('entry-20'); - expect(shifted).not.toContainEqual(expect.objectContaining({ id: 'entry-0' })); - }); - - it('should handle delete then insert at same absolute index', async () => { - await service.ensureWindow(0, 20); - - service.applyEntryDeleted('entry-7'); - const replacement = createEntry('replacement-7', 'Repl'); - service.applyEntryInserted(replacement, 7); - - const result = await service.ensureWindow(0, 20); - expect(result[7].id).toBe('replacement-7'); - expect(result).not.toContainEqual(expect.objectContaining({ id: 'entry-7' })); - }); - }); -}); diff --git a/frontend/viewer/src/lib/services/chunk-cache-service.ts b/frontend/viewer/src/lib/services/chunk-cache-service.ts deleted file mode 100644 index 896045cb0e..0000000000 --- a/frontend/viewer/src/lib/services/chunk-cache-service.ts +++ /dev/null @@ -1,419 +0,0 @@ -import type {IEntry} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IEntry'; - -/** - * Represents a fixed-size chunk of entries in the cache. - * Each chunk stores entries for a contiguous range of indices. - */ -interface Chunk { - /** Starting index of this chunk (0-based) */ - start: number; - /** Ending index (exclusive) of this chunk */ - endExclusive: number; - /** Cached entries in this chunk */ - entries: IEntry[]; - /** Current status: 'ready', 'loading', or 'stale' */ - status: 'ready' | 'loading' | 'stale'; -} - -/** - * Callback for observing chunk cache changes. - */ -export interface ChunkCacheObserver { - /** - * Called when a chunk is updated (entry inserted/updated/deleted). - * index: the chunk index in the cache map - */ - onChunkChanged?: (chunkIndex: number) => void; - /** - * Called when a new window of entries is ready (e.g., after ensureWindow completes). - */ - onWindowReady?: () => void; - /** - * Called when an error occurs. - */ - onError?: (error: Error) => void; -} - -/** - * Interface for the backend API that provides entry windows and row indices. - * This is typically implemented by an HTTP client talking to MiniLcm endpoints. - */ -export interface EntryWindowProvider { - /** - * Fetch a window of entries. - * start: 0-based offset - * size: number of entries to fetch - * Returns: { entries, firstIndex } where firstIndex is the 0-based start - */ - fetchWindow(start: number, size: number): Promise<{ entries: IEntry[]; firstIndex: number }>; - - /** - * Get the 0-based row index of a specific entry. - * Typically called during jump-to-entry flow. - */ - fetchRowIndex(entryId: string): Promise<{ rowIndex: number; entry: IEntry }>; -} - -/** - * ChunkCacheService manages chunk-based virtual scrolling. - * - * Responsibilities: - * - Maintain a map of chunks keyed by chunk index - * - Support ensureWindow(start, size) to fetch and cache entry windows - * - Apply entry updates/deletes/inserts with cascade rebalancing - * - Emit observable events for Svelte reactivity - * - * Chunk math: - * - Chunks are fixed-size (except the tail) - * - Chunk index = Math.floor(startIndex / chunkSize) - * - When deleting/inserting, cascade between chunks to maintain alignment - */ -export class ChunkCacheService { - private chunks = new Map(); - private requestedWindow: { start: number; size: number } | null = null; - private pendingWindows = new Map>(); - private observers: ChunkCacheObserver[] = []; - private isLoading = false; - - constructor( - private provider: EntryWindowProvider, - private chunkSize: number = 50 - ) {} - - /** - * Subscribe to cache change events. - * Returns an unsubscribe function. - */ - subscribe(observer: ChunkCacheObserver): () => void { - this.observers.push(observer); - return () => { - const idx = this.observers.indexOf(observer); - if (idx >= 0) this.observers.splice(idx, 1); - }; - } - - /** - * Ensure that a window of entries is loaded and ready. - * - If chunks are already cached, returns immediately - * - Otherwise, fetches from backend and fills chunks - * - Emits onWindowReady when complete - */ - async ensureWindow(start: number, size: number): Promise { - this.requestedWindow = { start, size }; - - const chunkIndices = this.getChunkIndicesForWindow(start, size); - const missingChunks = chunkIndices.filter(idx => !this.chunks.has(idx)); - - if (missingChunks.length === 0) { - // All chunks already cached, return immediately - return this.extractWindow(start, size); - } - - // Fetch missing chunks - for (const chunkIdx of missingChunks) { - // Avoid duplicate fetches by using a promise cache - if (!this.pendingWindows.has(chunkIdx)) { - const promise = this.fetchAndCacheChunk(chunkIdx); - this.pendingWindows.set(chunkIdx, promise); - void promise - .then(() => { - this.pendingWindows.delete(chunkIdx); - }) - .catch((err: unknown) => { - this.pendingWindows.delete(chunkIdx); - this.notifyError(err instanceof Error ? err : new Error(String(err))); - }); - } - - // Wait for this chunk to be fetched - await this.pendingWindows.get(chunkIdx)!; - } - - // All chunks are now ready - this.notifyWindowReady(); - return this.extractWindow(start, size); - } - - /** - * Apply an entry update (modification to existing entry). - * If the entry is cached, updates it in-place and emits onChunkChanged. - */ - applyEntryUpdated(entry: IEntry): void { - for (const [chunkIdx, chunk] of this.chunks) { - const idx = chunk.entries.findIndex(e => e.id === entry.id); - if (idx >= 0) { - chunk.entries[idx] = entry; - this.notifyChunkChanged(chunkIdx); - return; - } - } - } - - /** - * Apply an entry deletion. - * - Removes entry from cache - * - Cascades deletions to subsequent chunks (squeezes left) - * - If cascade reaches a chunk outside the requested window, marks it stale - * - May trigger a tail refill if visible chunk becomes short - */ - applyEntryDeleted(entryId: string): void { - for (const [chunkIdx, chunk] of this.chunks) { - const idx = chunk.entries.findIndex(e => e.id === entryId); - if (idx >= 0) { - // Found the entry, remove it - chunk.entries.splice(idx, 1); - this.notifyChunkChanged(chunkIdx); - - // Cascade: pull entries from subsequent chunks - this.cascadeDelete(chunkIdx); - return; - } - } - } - - /** - * Apply an entry insertion at an absolute index. - * - Inserts entry into the owning chunk - * - Cascades the last element to the next chunk - * - Continues cascade until a chunk outside requested window, then discards - */ - applyEntryInserted(entry: IEntry, absoluteIndex: number): void { - const chunkIdx = Math.floor(absoluteIndex / this.chunkSize); - const offsetInChunk = absoluteIndex % this.chunkSize; - - // Ensure the chunk exists - if (!this.chunks.has(chunkIdx)) { - this.chunks.set(chunkIdx, { - start: chunkIdx * this.chunkSize, - endExclusive: (chunkIdx + 1) * this.chunkSize, - entries: [], - status: 'ready' - }); - } - - const chunk = this.chunks.get(chunkIdx)!; - chunk.entries.splice(offsetInChunk, 0, entry); - this.notifyChunkChanged(chunkIdx); - - // Cascade: overflow pushes to next chunk - this.cascadeInsert(chunkIdx); - } - - /** - * Clear all cached chunks and reset state. - */ - clear(): void { - this.chunks.clear(); - this.requestedWindow = null; - this.pendingWindows.clear(); - this.isLoading = false; - } - - // ===== PRIVATE HELPERS ===== - - /** - * Get the chunk indices that cover the requested window. - */ - private getChunkIndicesForWindow(start: number, size: number): number[] { - const end = start + size; - const startChunk = Math.floor(start / this.chunkSize); - const endChunk = Math.floor((end - 1) / this.chunkSize) + 1; - - const indices: number[] = []; - for (let i = startChunk; i < endChunk; i++) { - indices.push(i); - } - return indices; - } - - /** - * Fetch a single chunk from the backend and cache it. - */ - private async fetchAndCacheChunk(chunkIdx: number, chunkStartOverride?: number): Promise { - const chunkStart = chunkStartOverride ?? chunkIdx * this.chunkSize; - - // Mark as loading - const chunk: Chunk = { - start: chunkStart, - endExclusive: chunkStart + this.chunkSize, - entries: [], - status: 'loading' - }; - this.chunks.set(chunkIdx, chunk); - - try { - const { entries, firstIndex } = await this.provider.fetchWindow(chunkStart, this.chunkSize); - chunk.entries = entries; - chunk.status = 'ready'; - // Align chunk boundaries based on actual firstIndex - chunk.start = firstIndex; - chunk.endExclusive = firstIndex + entries.length; - // Don't notify on initial fetch - only notify when data is explicitly modified - } catch (err) { - // Drop errored chunk so callers treat this range as uncached and retry later - this.chunks.delete(chunkIdx); - throw err; - } - } - - /** - * Extract a window from cached chunks. - * Concatenates entries from relevant chunks in deterministic chunk-index order, - * accounting for chunk start positions. - */ - private extractWindow(start: number, size: number): IEntry[] { - const result: IEntry[] = []; - const endIndex = start + size; - - // Get chunk indices that should cover this window - const chunkIndices = this.getChunkIndicesForWindow(start, size); - - // Iterate in deterministic chunk-index order to ensure consistent output - for (const chunkIdx of chunkIndices) { - const chunk = this.chunks.get(chunkIdx); - if (!chunk) { - // Gap in chunks - window is incomplete - break; - } - - // Skip chunks that are completely before or after the window - if (chunk.endExclusive <= start || chunk.start >= endIndex) { - continue; - } - - // Calculate the slice range within this chunk - const chunkStart = Math.max(0, start - chunk.start); - const chunkEnd = Math.min(chunk.entries.length, endIndex - chunk.start); - - for (let i = chunkStart; i < chunkEnd; i++) { - result.push(chunk.entries[i]); - } - } - - return result; - } - - /** - * Cascade deletions: when an entry is deleted, the first entry from the next chunk - * moves into this chunk's empty slot. Updates chunk bounds and marks tail stale if needed. - */ - private cascadeDelete(chunkIdx: number): void { - const nextChunkIdx = chunkIdx + 1; - const nextChunk = this.chunks.get(nextChunkIdx); - - if (!nextChunk || nextChunk.entries.length === 0) { - // No more entries to pull from next chunk - // Check if this chunk is the tail and is inside the visible window - const chunk = this.chunks.get(chunkIdx)!; - if (this.isChunkVisible(chunk) && chunk.entries.length < this.chunkSize && nextChunk) { - // Visible tail chunk is now short - mark stale and refill - chunk.status = 'stale'; - this.refillVisibleTail(chunkIdx); - } - return; - } - - // Pull the first entry from the next chunk into this chunk - const entryFromNext = nextChunk.entries.shift()!; - const chunk = this.chunks.get(chunkIdx)!; - chunk.entries.push(entryFromNext); - - // Update next chunk's start position (entries shifted left) - nextChunk.start++; - nextChunk.endExclusive = nextChunk.start + nextChunk.entries.length; - - this.notifyChunkChanged(nextChunkIdx); - - // Continue cascading - this.cascadeDelete(nextChunkIdx); - } - - /** - * Cascade inserts: when an entry is inserted, the last entry overflows to the next chunk. - * Discards overflow outside the requestedWindow to avoid accumulating stale data. - * Updates chunk bounds for entries that moved. - */ - private cascadeInsert(chunkIdx: number): void { - const chunk = this.chunks.get(chunkIdx)!; - - if (chunk.entries.length <= this.chunkSize) { - // Chunk is not oversized, no cascade needed - return; - } - - // Pop the last entry and push to next chunk - const overflowEntry = chunk.entries.pop()!; - - const nextChunkIdx = chunkIdx + 1; - let nextChunk = this.chunks.get(nextChunkIdx); - - if (!nextChunk) { - // Check if next chunk is outside the visible window - if (this.requestedWindow && (nextChunkIdx * this.chunkSize) >= this.requestedWindow.start + this.requestedWindow.size) { - // Overflow outside window - discard it - return; - } - // Create next chunk if it doesn't exist and is inside window - nextChunk = { - start: nextChunkIdx * this.chunkSize, - endExclusive: (nextChunkIdx + 1) * this.chunkSize, - entries: [], - status: 'ready' - }; - this.chunks.set(nextChunkIdx, nextChunk); - } - - nextChunk.entries.unshift(overflowEntry); - // Update next chunk's start position (entries shifted right) - nextChunk.start = nextChunkIdx * this.chunkSize; - nextChunk.endExclusive = nextChunk.start + nextChunk.entries.length; - - this.notifyChunkChanged(nextChunkIdx); - - // Recursively cascade if next chunk is also oversized - this.cascadeInsert(nextChunkIdx); - } - - // ===== TAIL REFILL ===== - - /** - * Check if a chunk overlaps with the currently requested window. - */ - private isChunkVisible(chunk: Chunk): boolean { - if (!this.requestedWindow) return false; - const windowEnd = this.requestedWindow.start + this.requestedWindow.size; - return !(chunk.endExclusive <= this.requestedWindow.start || chunk.start >= windowEnd); - } - - /** - * When a visible tail chunk becomes short after a delete, refill it from the backend. - */ - private refillVisibleTail(chunkIdx: number): void { - const chunk = this.chunks.get(chunkIdx); - if (!chunk) return; - - // Fire off a refill fetch for this chunk - const promise = this.fetchAndCacheChunk(chunkIdx, chunk.start) - .catch((err: unknown) => { - this.notifyError(err instanceof Error ? err : new Error(String(err))); - }); - this.pendingWindows.set(chunkIdx, promise); - void promise.finally(() => { - this.pendingWindows.delete(chunkIdx); - }); - } - - // ===== NOTIFICATIONS ===== - - private notifyChunkChanged(chunkIdx: number): void { - this.observers.forEach(obs => obs.onChunkChanged?.(chunkIdx)); - } - - private notifyWindowReady(): void { - this.observers.forEach(obs => obs.onWindowReady?.()); - } - - private notifyError(error: Error): void { - this.observers.forEach(obs => obs.onError?.(error)); - } -} From 85b76c5c445ab686f43b314241b5b45567f97277 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 19 Jan 2026 14:03:26 +0100 Subject: [PATCH 12/42] Add entry-loader-service tests --- .../entry-loader-service.svelte.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts new file mode 100644 index 0000000000..77f35539c0 --- /dev/null +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts @@ -0,0 +1,108 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import {EntryLoaderService} from '$lib/services/entry-loader-service.svelte'; +import type {IEntry} from '$lib/dotnet-types'; +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; +import {defaultEntry} from '$lib/utils'; + +function makeEntry(id: string): IEntry { + return { + ...defaultEntry(), + id, + }; +} + +type MiniLcmApiMock = { + countEntries: ReturnType; + getEntries: ReturnType; + searchEntries: ReturnType; +}; + +function createService(entries: IEntry[], totalCount = entries.length) { + const api: MiniLcmApiMock = { + countEntries: vi.fn().mockResolvedValue(totalCount), + getEntries: vi.fn().mockResolvedValue(entries), + searchEntries: vi.fn().mockResolvedValue(entries), + }; + + let service!: EntryLoaderService; + const cleanup = $effect.root(() => { + service = new EntryLoaderService({ + miniLcmApi: () => api as unknown as IMiniLcmJsInvokable, + search: () => '', + sort: () => undefined, + gridifyFilter: () => undefined, + }); + }); + + return {api, service, cleanup}; +} + +describe('EntryLoaderService', () => { + let cleanups: (() => void)[] = []; + + afterEach(() => { + for (const cleanup of cleanups) cleanup(); + cleanups = []; + }); + + it('loads a batch and caches entries', async () => { + const entry0 = makeEntry('e0'); + const entry1 = makeEntry('e1'); + const {api, service, cleanup} = createService([entry0, entry1]); + cleanups.push(cleanup); + + await service.loadEntryByIndex(1); + + expect(api.getEntries).toHaveBeenCalledTimes(1); + expect(service.getEntryByIndex(0)?.id).toBe('e0'); + expect(service.getIndexById('e1')).toBe(1); + }); + + it('returns cached entries without refetching', async () => { + const entry0 = makeEntry('e0'); + const entry1 = makeEntry('e1'); + const {api, service, cleanup} = createService([entry0, entry1]); + cleanups.push(cleanup); + + await service.loadEntryByIndex(0); + await service.loadEntryByIndex(0); + + expect(api.getEntries).toHaveBeenCalledTimes(1); + }); + + it('removes entries and shifts indices', async () => { + const entry0 = makeEntry('e0'); + const entry1 = makeEntry('e1'); + const entry2 = makeEntry('e2'); + const {service, cleanup} = createService([entry0, entry1, entry2], 3); + cleanups.push(cleanup); + + await service.loadCount(); + await service.loadEntryByIndex(2); + + service.removeEntryById('e1'); + + expect(service.getIndexById('e2')).toBe(1); + expect(service.getEntryByIndex(1)?.id).toBe('e2'); + expect(service.totalCount).toBe(2); + }); + + it('recalculates loaded batches when totalCount is undefined', async () => { + const entry0 = makeEntry('e0'); + const entry1 = makeEntry('e1'); + const entry2 = makeEntry('e2'); + const {api, service, cleanup} = createService([entry0, entry1, entry2]); + cleanups.push(cleanup); + + await service.loadEntryByIndex(0); + expect(api.getEntries).toHaveBeenCalledTimes(1); + + service.totalCount = undefined; + service.removeEntryById('e0'); + + await service.loadEntryByIndex(2); + + expect(api.getEntries).toHaveBeenCalledTimes(2); + }); +}); From 7fc95159f2487339ac67c803470ccb20699eef54 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 20 Jan 2026 11:48:38 +0100 Subject: [PATCH 13/42] Prepare backend API for querying entry index --- .../Api/FwDataMiniLcmApi.cs | 7 ++-- .../FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs | 6 +-- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 5 +-- .../FwLite/LcmCrdt/Data/MiniLcmRepository.cs | 8 ++-- .../MiniLcm.Tests/EntryWindowTestsBase.cs | 39 ++++++++----------- backend/FwLite/MiniLcm/IMiniLcmReadApi.cs | 12 +++--- backend/LfClassicData/LfClassicMiniLcmApi.cs | 2 +- 7 files changed, 35 insertions(+), 44 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 511eb00dd5..524cf67ac0 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -977,7 +977,7 @@ public async Task GetEntriesWindow(int start, int size, str return new EntryWindowResponse(entries, start); } - public Task GetEntryRowIndex(Guid entryId, string? query = null, QueryOptions? options = null) + public Task GetEntryIndex(Guid entryId, string? query = null, QueryOptions? options = null) { options ??= QueryOptions.Default; var predicate = EntrySearchPredicate(query); @@ -989,13 +989,12 @@ public Task GetEntryRowIndex(Guid entryId, string? query { if (entry.Guid == entryId) { - var result = FromLexEntry(entry); - return Task.FromResult(new EntryRowIndexResponse(rowIndex, result)); + return Task.FromResult(rowIndex); } rowIndex++; } - throw NotFoundException.ForType(entryId); + return Task.FromResult(-1); } public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) diff --git a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs index 4afed12137..faaeb3a9a4 100644 --- a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs @@ -100,7 +100,7 @@ await projectProvider.OpenProject(project, context.HttpContext.RequestServices) api.MapGet("/entries/window", MiniLcm.GetEntriesWindow); api.MapGet("/entries/{search}", MiniLcm.SearchEntries); api.MapGet("/entry/{id:Guid}", MiniLcm.GetEntry); - api.MapGet("/entry/{id:Guid}/row-index", MiniLcm.GetEntryRowIndex); + api.MapGet("/entry/{id:Guid}/index", MiniLcm.GetEntryIndex); api.MapGet("/parts-of-speech", MiniLcm.GetPartsOfSpeech); api.MapGet("/semantic-domains", MiniLcm.GetSemanticDomains); api.MapGet("/publications", MiniLcm.GetPublications); @@ -149,13 +149,13 @@ public static Task GetEntriesWindow( return api.GetEntriesWindow(start, size, null, options.ToQueryOptions()); } - public static Task GetEntryRowIndex( + public static Task GetEntryIndex( Guid id, [AsParameters] MiniLcmQueryOptions options, [FromServices] MiniLcmHolder holder) { var api = holder.MiniLcmApi; - return api.GetEntryRowIndex(id, null, options.ToQueryOptions()); + return api.GetEntryIndex(id, null, options.ToQueryOptions()); } public static IAsyncEnumerable GetPartsOfSpeech([FromServices] MiniLcmHolder holder) diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 13c95b8e8e..24914b7f32 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -423,11 +423,10 @@ public async Task GetEntriesWindow(int start, int size, str return new EntryWindowResponse(entries, start); } - public async Task GetEntryRowIndex(Guid entryId, string? query = null, QueryOptions? options = null) + public async Task GetEntryIndex(Guid entryId, string? query = null, QueryOptions? options = null) { await using var repo = await repoFactory.CreateRepoAsync(); - var (rowIndex, entry) = await repo.GetEntryRowIndex(entryId, query, options); - return new EntryRowIndexResponse(rowIndex, entry); + return await repo.GetEntryIndex(entryId, query, options); } public async Task BulkCreateEntries(IAsyncEnumerable entries) diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index 3bf6cf8dda..267d54ae29 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -260,24 +260,24 @@ private ValueTask> ApplySorting(IQueryable queryable, Q return exampleSentence; } - public async Task<(int RowIndex, Entry Entry)> GetEntryRowIndex(Guid entryId, string? query = null, QueryOptions? options = null) + public async Task GetEntryIndex(Guid entryId, string? query = null, QueryOptions? options = null) { // This is a fallback implementation that's not optimal for large datasets, // but it works correctly. Ideally, we'd use ROW_NUMBER() window function with linq2db // for better performance on large entry lists. For now, we enumerate through sorted entries // and count until we find the target entry. - + var rowIndex = 0; await foreach (var entry in GetEntries(query, options)) { if (entry.Id == entryId) { - return (rowIndex, entry); + return rowIndex; } rowIndex++; } - throw NotFoundException.ForType(entryId); + return -1; } public async Task GetPublication(Guid publicationId) diff --git a/backend/FwLite/MiniLcm.Tests/EntryWindowTestsBase.cs b/backend/FwLite/MiniLcm.Tests/EntryWindowTestsBase.cs index 59c2cc66b9..3f463c23a3 100644 --- a/backend/FwLite/MiniLcm.Tests/EntryWindowTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/EntryWindowTestsBase.cs @@ -3,7 +3,7 @@ namespace MiniLcm.Tests; /// -/// Tests for GetEntriesWindow and GetEntryRowIndex APIs. +/// Tests for GetEntriesWindow and GetEntryIndex APIs. /// These APIs are critical for chunk-based virtual scrolling. /// public abstract class EntryWindowTestsBase : MiniLcmTestBase @@ -96,43 +96,37 @@ public async Task GetEntriesWindow_LargeWindow_ReturnsBiggerChunk() } [Fact] - public async Task GetEntryRowIndex_FirstEntry_ReturnsZero() + public async Task GetEntryIndex_FirstEntry_ReturnsZero() { - var result = await Api.GetEntryRowIndex(appleId); + var result = await Api.GetEntryIndex(appleId); - result.RowIndex.Should().Be(0); - result.Entry.Id.Should().Be(appleId); - result.Entry.LexemeForm["en"].Should().Be(Apple); + result.Should().Be(0); } [Fact] - public async Task GetEntryRowIndex_MiddleEntry_ReturnsCorrectIndex() + public async Task GetEntryIndex_MiddleEntry_ReturnsCorrectIndex() { - var result = await Api.GetEntryRowIndex(kiwiId); + var result = await Api.GetEntryIndex(kiwiId); - result.RowIndex.Should().Be(2); - result.Entry.Id.Should().Be(kiwiId); - result.Entry.LexemeForm["en"].Should().Be(Kiwi); + result.Should().Be(2); } [Fact] - public async Task GetEntryRowIndex_LastEntry_ReturnsLastIndex() + public async Task GetEntryIndex_LastEntry_ReturnsLastIndex() { - var result = await Api.GetEntryRowIndex(peachId); + var result = await Api.GetEntryIndex(peachId); - result.RowIndex.Should().Be(4); - result.Entry.Id.Should().Be(peachId); - result.Entry.LexemeForm["en"].Should().Be(Peach); + result.Should().Be(4); } [Fact] - public async Task GetEntryRowIndex_NonExistentEntry_Throws() + public async Task GetEntryIndex_NonExistentEntry_ReturnsNegativeOne() { var nonExistentId = Guid.NewGuid(); - var action = () => Api.GetEntryRowIndex(nonExistentId); + var result = await Api.GetEntryIndex(nonExistentId); - await action.Should().ThrowAsync(); + result.Should().Be(-1); } [Fact] @@ -175,11 +169,10 @@ public async Task GetEntriesWindow_MultipleCallsCoverAllEntries() } [Fact] - public async Task GetEntryRowIndex_CanPositionWindow() + public async Task GetEntryIndex_CanPositionWindow() { - // Get row index for an entry - var indexResult = await Api.GetEntryRowIndex(kiwiId); - var rowIndex = indexResult.RowIndex; + // Get index for an entry + var rowIndex = await Api.GetEntryIndex(kiwiId); // Use it to fetch a window around that entry var windowResult = await Api.GetEntriesWindow(rowIndex, 2); diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 26721e3c1c..23c87b0d84 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -28,7 +28,11 @@ public interface IMiniLcmReadApi Task GetSemanticDomain(Guid id); Task GetExampleSentence(Guid entryId, Guid senseId, Guid id); Task GetEntriesWindow(int start, int size, string? query = null, QueryOptions? options = null); - Task GetEntryRowIndex(Guid entryId, string? query = null, QueryOptions? options = null); + /// + /// Get the index of an entry within the sorted/filtered entry list. + /// Returns -1 if the entry is not found. + /// + Task GetEntryIndex(Guid entryId, string? query = null, QueryOptions? options = null); Task GetFileStream(MediaUri mediaUri) { @@ -42,11 +46,7 @@ public record EntryWindowResponse( { } -public record EntryRowIndexResponse( - int RowIndex, - Entry Entry) -{ -} + public record FilterQueryOptions( ExemplarOptions? Exemplar = null, diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index c335d76d86..59427c5889 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -430,7 +430,7 @@ public async Task GetEntriesWindow(int start, int size, str throw new NotImplementedException(); } - public async Task GetEntryRowIndex(Guid entryId, string? query = null, QueryOptions? options = null) + public Task GetEntryIndex(Guid entryId, string? query = null, QueryOptions? options = null) { throw new NotImplementedException(); } From 8e27a540c9ab714467655f1f445eae73f0d6d89b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 21 Jan 2026 13:49:26 +0100 Subject: [PATCH 14/42] v2 test improvements with added event handling --- frontend/viewer/playwright.config.ts | 3 +- .../src/lib/services/ENTRY_LOADER_PLAN.md | 149 +++++++++- frontend/viewer/src/lib/services/event-bus.ts | 4 + .../src/project/demo/in-memory-demo-api.ts | 96 +++---- frontend/viewer/tests/entries-list-v2.test.ts | 259 ++++++++++++++++++ frontend/viewer/tests/test.d.ts | 10 + 6 files changed, 447 insertions(+), 74 deletions(-) create mode 100644 frontend/viewer/tests/entries-list-v2.test.ts create mode 100644 frontend/viewer/tests/test.d.ts diff --git a/frontend/viewer/playwright.config.ts b/frontend/viewer/playwright.config.ts index 094f0c9d5e..99f0384271 100644 --- a/frontend/viewer/playwright.config.ts +++ b/frontend/viewer/playwright.config.ts @@ -1,5 +1,6 @@ -import { defineConfig, devices, type ReporterDescription } from '@playwright/test'; +import {defineConfig, devices, type ReporterDescription} from '@playwright/test'; import * as testEnv from '../tests/envVars'; + const vitePort = '5173'; const dotnetPort = '5137'; const autoStartServer = process.env.AUTO_START_SERVER ? Boolean(process.env.AUTO_START_SERVER) : false; diff --git a/frontend/viewer/src/lib/services/ENTRY_LOADER_PLAN.md b/frontend/viewer/src/lib/services/ENTRY_LOADER_PLAN.md index 6348492527..688d96ad48 100644 --- a/frontend/viewer/src/lib/services/ENTRY_LOADER_PLAN.md +++ b/frontend/viewer/src/lib/services/ENTRY_LOADER_PLAN.md @@ -25,10 +25,56 @@ Refactor `EntriesList.svelte` to use virtual scrolling with on-demand entry load ### V2 (Future Enhancement) -Add `getEntryIndex(entryId, queryOptions)` API endpoint to enable: -- Jump-to-entry by ID +✅ **Backend API complete**: `GetEntryIndex(entryId, query?, options?) → int` (returns -1 if not found) + +Use `getEntryIndex` to enable: +- Jump-to-entry by ID (e.g., reload with `?entryId=X`, clearing filter with entry selected) - Smart insert for new entries (know exact position) -- Avoid full reset on events for entries not in cache +- Quiet reset for events affecting entries not in cache + +#### UI Reactivity for Cache Updates + +**Problem**: `Delayed.svelte` watches function references `[load, getCached]`. When `EntryLoaderService` updates the cache, these references don't change, so the component doesn't re-render. + +**Solution**: Version-based VList keys. + +```typescript +// EntryLoaderService +#entryVersions = new SvelteMap(); + +getVersion(index: number): number { + return this.#entryVersions.get(index) ?? 0; +} + +// In updateEntry(): +this.#entryVersions.set(index, (this.#entryVersions.get(index) ?? 0) + 1); +``` + +```svelte + + entryLoader.getEntryByIndex(index)?.id ?? `skeleton-${index}`} +> + {#snippet children(index)} + {#key entryLoader.getVersion(index)} + entryLoader.getEntryByIndex(index)} + load={() => entryLoader.loadEntryByIndex(index)} + delay={250} + > + + + {/key} + {/snippet} + +``` + +**Two-layer reactivity:** +1. **`getKey` (entry ID)**: Ensures VList tracks items by identity, not position. On insert/delete, items keep their correct keys. Unloaded items use `skeleton-{index}` until loaded. +2. **`{#key}` (version)**: Forces re-render when an entry is updated in-place. When version increments, `{#key}` destroys and recreates `Delayed`, which re-queries `getCached()`. + +> **Note**: Key changes from `skeleton-X` to entry ID on load. This is fine — the item was a skeleton anyway, and re-mounting a freshly loaded item is expected. ## Data Structures @@ -75,11 +121,26 @@ Given index N: ## Future: Event Handling (V2) -| Event | Action | -|-------|--------| -| Delete in cache | Remove, shift indices, decrement count | -| Delete not in cache | Full reset (can't query deleted entry's position) | -| Add/Update | Call `getEntryIndex()` → insert at position or ignore if outside loaded batches | +| Event | Condition | Action | +|-------|-----------|--------| +| Delete | In cache | Remove, shift indices, decrement count | +| Delete | Not in cache | Full reset (can't query deleted entry's position) | +| Update | In cache | Update cache, increment version | +| Update | Not in cache | Quiet reset (see below) | +| Add | In loaded batch | Insert at position, shift subsequent, increment count | +| Add | After all loaded | Increment count only (entry will load on scroll) | +| Add | Before loaded batch | Quiet reset | + +### Quiet Reset + +When an event affects entries outside the cache but we want to preserve UX: + +1. Clear cache and loaded batch tracking +2. Determine "anchor" batch(es) — the last 1-2 batches that entries were pulled from +3. Immediately reload those batches (no loading indicators) +4. Maintain scroll position (center entry should remain visible) + +This avoids jarring skeleton flashes for events the user can't see. ## Answers to Design Questions @@ -130,4 +191,76 @@ getIndexById(id: string): number | undefined; // from incremental id removeEntryById(id: string): void; // handles delete updateEntry(entry: IEntry): void; // handles update reset(): void; // full reset + +// V2 additions +getVersion(index: number): number; // for VList key invalidation +``` + +## V2 Testing Strategy + +Tests live in `frontend/viewer/tests/entries-list-v2.test.ts`. + +### Test Utilities + +**Creating an entry at a specific index:** +```typescript +// Get entry at targetIndex-1, append '-inserted' to its headword +// This guarantees the new entry sorts immediately after (at targetIndex) +const entryBefore = await demoApi.getEntries({offset: targetIndex - 1, count: 1, order: ...}); +const newHeadword = (entryBefore?.lexemeForm?.seh ?? '#') + '-inserted'; +const entry = await demoApi.createEntry({id: crypto.randomUUID(), lexemeForm: {seh: newHeadword}, ...}); ``` + +**Measuring item height (once per test):** +```typescript +async function getItemHeight(page: Page): Promise { + const {entryRows} = getLocators(page); + await expect(entryRows.first()).toBeVisible(); + + // Measure two consecutive items to get height + gap + const firstBox = await entryRows.first().boundingBox(); + const secondBox = await entryRows.nth(1).boundingBox(); + if (!firstBox || !secondBox) throw new Error('Could not measure entry rows'); + + return secondBox.y - firstBox.y; // Includes margin/gap +} +``` + +**Scrolling to an index:** +```typescript +async function scrollToIndex(page: Page, targetIndex: number, itemHeight: number): Promise { + const {vlist} = getLocators(page); + const targetScroll = targetIndex * itemHeight; + await vlist.evaluate((el, target) => { el.scrollTop = target; }, targetScroll); + await page.waitForTimeout(300); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); +} +``` + +> **Note**: Measure `itemHeight` once at test start, reuse for all scroll operations in that test. + +**Finding center-visible entry:** +```typescript +// Get VList container bounds, find entry row whose box contains centerY +const containerBox = await vlist.boundingBox(); +const centerY = containerBox.y + containerBox.height / 2; +// Iterate entry rows, find one where box.y <= centerY < box.y + box.height +``` + +### V2 Test Cases + +| Test | Setup | Action | Verification | +|------|-------|--------|--------------| +| **Entry added in loaded batch** | View batch 0 | Create entry at index 25 | New entry at 25 contains `-inserted`; old 25 now at 26; old 49 pushed to 50; count +1 | +| **Entry added after loaded** | View batch 0 | Create entry at index 5000 | Count +1; visible unchanged; scroll to 5000 → entry visible | +| **Entry added before loaded** | Scroll to batch 2 | Create entry at index 25 | Center entry text unchanged after reset; count +1 | +| **Entry updated not in cache** | View batch 0 | Update entry at index 100 | No visible change; scroll to 100 → updated text visible | + +### Batch Boundary Verification (Test 1) + +When inserting at index 25 with batch size 50: +- Entry at old index 49 (last in batch 0) gets pushed to index 50 (first in batch 1) +- After insert, scroll to verify: + - Index 49 shows what was previously at 48 + - Index 50 shows what was previously at 49 +- This ensures no entries are lost or duplicated at batch boundaries diff --git a/frontend/viewer/src/lib/services/event-bus.ts b/frontend/viewer/src/lib/services/event-bus.ts index ee64e1c06d..8f7d5adbf2 100644 --- a/frontend/viewer/src/lib/services/event-bus.ts +++ b/frontend/viewer/src/lib/services/event-bus.ts @@ -102,6 +102,10 @@ export class ProjectEventBus { this.notifyProjectEvent({entryId, type: FwEventType.EntryDeleted, isGlobal: false} satisfies IEntryDeletedEvent); } + public notifyEntryUpdated(entry: IEntry) { + this.notifyProjectEvent({entry, type: FwEventType.EntryChanged, isGlobal: false} satisfies IEntryChangedEvent); + } + private notifyProjectEvent(event: T) { this.eventBus.notifyEvent({ type: FwEventType.ProjectEvent, diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 002d5a98d9..9692e3c46c 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -5,7 +5,6 @@ import { type IComplexFormComponent, type IComplexFormType, type IEntry, - type IEntriesWindow, type IExampleSentence, type IFilterQueryOptions, type IMiniLcmJsInvokable, @@ -25,7 +24,7 @@ import {entries, partsOfSpeech, projectName, writingSystems} from './demo-entry- import {WritingSystemService} from '../data/writing-system-service.svelte'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; import {delay} from '$lib/utils/time'; -import {initProjectContext, ProjectContext} from '$project/project-context.svelte'; +import {initProjectContext, type ProjectContext} from '$project/project-context.svelte'; import type {IFwLiteConfig} from '$lib/dotnet-types/generated-types/FwLiteShared/IFwLiteConfig'; import type {IReadFileResponseJs} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IReadFileResponseJs'; import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; @@ -35,6 +34,8 @@ import {UploadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ import {DownloadProjectByCodeResult} from '$lib/dotnet-types/generated-types/FwLiteShared/Projects/DownloadProjectByCodeResult'; import type {IUpdateService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService'; import {type IAvailableUpdate, UpdateResult} from '$lib/dotnet-types/generated-types/FwLiteShared/AppUpdate'; +import {type EventBus, useEventBus, ProjectEventBus} from '$lib/services/event-bus'; +import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IJsEventListener'; function pickWs(ws: string, defaultWs: string): string { return ws === 'default' ? defaultWs : ws; @@ -83,67 +84,39 @@ export const mockUpdateService: IUpdateService = { } }; +const mockJsEventListener: IJsEventListener = { + nextEventAsync: () => Promise.resolve(null!), + lastEvent: () => Promise.resolve(null) +}; + export class InMemoryDemoApi implements IMiniLcmJsInvokable { #writingSystemService: WritingSystemService; - constructor(private projectContext: ProjectContext) { + #projectEventBus: ProjectEventBus; + constructor(private projectContext: ProjectContext, private eventBus: EventBus) { this.#writingSystemService = new WritingSystemService(projectContext); + this.#projectEventBus = new ProjectEventBus(projectContext, eventBus); } countEntries(query?: string, options?: IFilterQueryOptions): Promise { const entries = this.getFilteredEntries(query, options); return Promise.resolve(entries.length); } - async getEntriesWindow(query?: string, options?: IQueryOptions, targetEntryId?: string): Promise { - await delay(300); - const allEntries = this.queryEntriesUnpaged(query, options); - const totalCount = allEntries.length; - - let offset = options?.offset ?? 0; - const count = options?.count ?? 100; - let targetIndex: number | undefined = undefined; - - // If targetEntryId is provided, find it and center the window around it - if (targetEntryId) { - const targetGlobalIndex = allEntries.findIndex(e => e.id === targetEntryId); - if (targetGlobalIndex !== -1) { - // Center the window around the target entry - offset = Math.max(0, targetGlobalIndex - Math.floor(count / 2)); - // Adjust if we're near the end - if (offset + count > totalCount) { - offset = Math.max(0, totalCount - count); - } - // Calculate the index of the target within the returned window - targetIndex = targetGlobalIndex - offset; - } - } - - const entries = allEntries.slice(offset, offset + count); - console.log(`[DemoAPI] getEntriesWindow: totalCount=${totalCount}, offset=${offset}, count=${count}, returning=${entries.length}, targetEntryId=${targetEntryId}, targetIndex=${targetIndex}`); - return { - entries, - totalCount, - offset, - targetIndex, - }; - } - async getEntryIndex(entryId: string, query?: string, options?: IFilterQueryOptions): Promise { await delay(100); const entries = this.getFilteredSortedEntries(query, options); return entries.findIndex(e => e.id === entryId); } - public static newProjectContext() { - const projectContext = new ProjectContext(); - projectContext.setup({api: new InMemoryDemoApi(projectContext), projectName, projectCode: projectName}); - return projectContext; - } public static setup(): InMemoryDemoApi { const projectContext = initProjectContext(); - const inMemoryLexboxApi = new InMemoryDemoApi(projectContext); + const eventBus = useEventBus(); + const inMemoryLexboxApi = new InMemoryDemoApi(projectContext, eventBus); projectContext.setup({api: inMemoryLexboxApi, projectName: inMemoryLexboxApi.projectName, projectCode: inMemoryLexboxApi.projectName}) window.lexbox.ServiceProvider.setService(DotnetService.FwLiteConfig, mockFwLiteConfig); window.lexbox.ServiceProvider.setService(DotnetService.UpdateService, mockUpdateService); + window.lexbox.ServiceProvider.setService(DotnetService.JsEventListener, mockJsEventListener); + window.__PLAYWRIGHT_UTILS__ = { demoApi: inMemoryLexboxApi }; + window.lexbox.ServiceProvider.setService(DotnetService.CombinedProjectsService, { localProjects(): Promise { return Promise.resolve([]); @@ -255,25 +228,6 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { .slice(options.offset, options.offset + options.count); } - // Returns filtered and sorted entries without pagination (for windowed queries) - private queryEntriesUnpaged(query: string | undefined, options: IQueryOptions | undefined): IEntry[] { - const entries = this.getFilteredEntries(query, options); - - if (!options) return entries; - const defaultWs = writingSystems.vernacular[0].wsId; - const sortWs = pickWs(options.order.writingSystem, defaultWs); - return entries - .sort((e1, e2) => { - const v1 = this.#writingSystemService.headword(e1, sortWs); - const v2 = this.#writingSystemService.headword(e2, sortWs); - if (!v2) return -1; - if (!v1) return 1; - let compare = v1.localeCompare(v2, sortWs); - if (compare == 0) compare = e1.id.localeCompare(e2.id); - return options.order.ascending ? compare : -compare; - }); - } - // Returns filtered and sorted entries for index lookup private getFilteredSortedEntries(query?: string, options?: IFilterQueryOptions): IEntry[] { const entries = this.getFilteredEntries(query, options); @@ -315,32 +269,41 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { createEntry(entry: IEntry): Promise { this._entries.push(entry); + this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(entry); } updateEntry(_before: IEntry, after: IEntry): Promise { this._entries.splice(this._entries.findIndex(e => e.id === after.id), 1, after); + this.#projectEventBus.notifyEntryUpdated(after); return Promise.resolve(after); } createSense(entryGuid: string, sense: ISense): Promise { - this._entries.find(e => e.id === entryGuid)?.senses.push(sense); + const entry = this._entries.find(e => e.id === entryGuid); + entry?.senses.push(sense); + if (entry) this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(sense); } createExampleSentence(entryGuid: string, senseGuid: string, exampleSentence: IExampleSentence): Promise { - this._entries.find(e => e.id === entryGuid)?.senses.find(s => s.id === senseGuid)?.exampleSentences.push(exampleSentence); + const entry = this._entries.find(e => e.id === entryGuid); + entry?.senses.find(s => s.id === senseGuid)?.exampleSentences.push(exampleSentence); + if (entry) this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(exampleSentence); } deleteEntry(guid: string): Promise { - this._entries.splice(this._entries.findIndex(e => e.id === guid), 1); + console.log(guid, this._entries[0].id); + console.log('deleted', this._entries.splice(this._entries.findIndex(e => e.id === guid), 1)); + this.#projectEventBus.notifyEntryDeleted(guid); return Promise.resolve(); } deleteSense(entryGuid: string, senseGuid: string): Promise { const entry = this._entries.find(e => e.id === entryGuid)!; entry.senses.splice(entry.senses.findIndex(s => s.id === senseGuid), 1); + this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(); } @@ -348,6 +311,7 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { const entry = this._entries.find(e => e.id === entryGuid)!; const sense = entry.senses.find(s => s.id === senseGuid)!; sense.exampleSentences.splice(sense.exampleSentences.findIndex(es => es.id === exampleSentenceGuid), 1); + this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(); } @@ -437,6 +401,7 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { const index = entry.senses.findIndex(s => s.id == _before.id); if (index == -1) throw new Error(`Sense ${_before.id} not found`); entry.senses.splice(index, 1, _after); + this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(_after); } @@ -456,6 +421,7 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { const index = sense.exampleSentences.findIndex(es => es.id == _before.id); if (index == -1) throw new Error(`ExampleSentence ${_before.id} not found`); sense.exampleSentences.splice(index, 1, _after); + this.#projectEventBus.notifyEntryUpdated(entry); return Promise.resolve(_after); } diff --git a/frontend/viewer/tests/entries-list-v2.test.ts b/frontend/viewer/tests/entries-list-v2.test.ts new file mode 100644 index 0000000000..0b8dfe8712 --- /dev/null +++ b/frontend/viewer/tests/entries-list-v2.test.ts @@ -0,0 +1,259 @@ +import {type Page, expect, test} from '@playwright/test'; +import {SortField} from '$lib/dotnet-types/generated-types/MiniLcm/SortField'; + +/** + * Tests for V2 virtual scrolling features: + * - Jump to entry (when reloading with entry selected) + * - Entry update/delete event handling without full reset + * + * These tests are expected to fail until the frontend integration is complete. + */ + +test.describe('EntriesList V2 features', () => { + function getLocators(page: Page) { + return { + vlist: page.locator('[role="table"] > div > div'), + entryRows: page.locator('[role="table"] [role="row"]'), + skeletons: page.locator('[role="table"] [data-skeleton]'), + selectedEntry: page.locator('[role="table"] [role="row"][aria-selected="true"]'), + }; + } + + async function waitForProjectViewReady(page: Page, waitForTestUtils = false) { + await expect(page.locator('.i-mdi-loading')).toHaveCount(0, {timeout: 10000}); + await page.waitForFunction(() => document.fonts.ready); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 10000}); + // Wait for test utilities to be available if requested + if (waitForTestUtils) { + await page.waitForFunction(() => window.__PLAYWRIGHT_UTILS__?.demoApi, {timeout: 5000}); + } + } + + async function getVisibleEntryTexts(page: Page) { + const {entryRows} = getLocators(page); + const texts: string[] = []; + const count = await entryRows.count(); + for (let i = 0; i < Math.min(count, 10); i++) { + const text = await entryRows.nth(i).textContent(); + if (text) texts.push(text.trim()); + } + return texts; + } + + test.describe('Jump to entry', () => { + test('reloading with entry selected scrolls to that entry', async ({page}) => { + // Go to project view + await page.goto('/testing/project-view'); + await waitForProjectViewReady(page); + + const {vlist, entryRows} = getLocators(page); + + // Scroll far down to find an entry well into the list (not near the top) + // 6000px at ~60px per row = ~100 items down + await vlist.evaluate((el) => { el.scrollTop = 6000; }); + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Select an entry that's in the viewport + // (otherwise the vlist might scroll, recycle dom elements and make the selection not match the text) + const entryToSelect = entryRows.nth(6); + await expect(entryToSelect).toBeInViewport(); + + // Get the text BEFORE clicking (clicking may scroll and recycle the DOM element) + const selectedText = await entryToSelect.textContent(); + expect(selectedText).toBeTruthy(); + + // Now click to select + await entryToSelect.click(); + + // Verify the URL has the entry ID + const url = page.url(); + expect(url).toContain('entryId='); + + // Reload the page with the same URL (entry should remain selected) + await page.reload(); + await waitForProjectViewReady(page); + + // The selected entry should be visible (scrolled into view) + const {selectedEntry} = getLocators(page); + await expect(selectedEntry).toBeVisible({timeout: 10000}); + await expect(selectedEntry).toContainText(selectedText!.slice(0, 20)); + }); + + test('clearing search filter with entry selected keeps entry visible', async ({page}) => { + await page.goto('/testing/project-view'); + await waitForProjectViewReady(page); + + const {vlist, entryRows} = getLocators(page); + + // Scroll far into the list (entries are sorted alphabetically, so we need to scroll past a-z entries) + const estimatedItemHeight = 60; // Approximate height of each entry row + // Scroll down by approximately 100 items worth of height to get to entries starting with later letters + const scrollDistance = 500 * estimatedItemHeight; + await vlist.evaluate((el, dist) => el.scrollTop = dist, scrollDistance); + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Get an entry from this position in the list (one that's now visible) + const visibleEntry = entryRows.first(); + const entryText = await visibleEntry.textContent(); + // Extract the headword (first word in the entry row) + const headword = entryText?.split(/\s+/).filter(Boolean)[0] ?? ''; + expect(headword.length).toBeGreaterThan(0); + + // Now filter for that headword + const searchInput = page.locator('input.real-input').first(); + await searchInput.fill(headword); + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Select the entry from filtered results + await entryRows.first().click(); + const selectedText = await entryRows.first().textContent(); + expect(selectedText).toContain(headword); + + // Clear the search - this reloads the full list from the beginning + await searchInput.clear(); + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Without V2 jump-to-entry, the list will reset to the beginning + // and the selected entry won't be visible (it's far down in the list) + // With V2, the list should scroll to show the selected entry + const {selectedEntry} = getLocators(page); + await expect(selectedEntry).toBeVisible({timeout: 10000}); + await expect(selectedEntry).toContainText(headword); + }); + }); + + test.describe('Entry event handling', () => { + test.beforeEach(async ({page}) => { + await page.goto('/testing/project-view'); + await waitForProjectViewReady(page, true); // Wait for test utils + }); + + test('entry delete event removes entry from list without full reset', async ({page}) => { + // Get the first few entries' texts before deletion + const initialTexts = await getVisibleEntryTexts(page); + expect(initialTexts.length).toBeGreaterThan(2); + + // Get the ID of the first entry + const firstEntryId = await page.evaluate(async (headword) => { + const demoApi = window.__PLAYWRIGHT_UTILS__.demoApi; + const entries = await demoApi.getEntries({count: 1, offset: 0, order: {field: headword, writingSystem: 'default', ascending: true}}); + return entries[0]?.id; + }, SortField.Headword); + expect(firstEntryId).toBeTruthy(); + + // Delete the entry via the demo API + await page.evaluate(async (entryId) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + await testUtils.demoApi.deleteEntry(entryId); + }, firstEntryId); + + // Wait a bit for the event to be processed + await page.waitForTimeout(300); + + // The first entry should now be different (what was the second entry) + const newTexts = await getVisibleEntryTexts(page); + expect(newTexts[0]).toBe(initialTexts[1]); + + // The old first entry should not be in the list + expect(newTexts).not.toContain(initialTexts[0]); + }); + + test('entry update event updates entry in list without full reset', async ({page}) => { + const {entryRows} = getLocators(page); + + // Get the first entry's text before update + const firstEntryText = await entryRows.first().textContent(); + expect(firstEntryText).toBeTruthy(); + + // Get the first entry + const firstEntry = await page.evaluate(async (headword) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + const entries = await testUtils.demoApi.getEntries({count: 1, offset: 0, order: {field: headword, writingSystem: 'default', ascending: true}}); + return entries[0]; + }, SortField.Headword); + expect(firstEntry).toBeTruthy(); + + // Update the entry's headword via the demo API + await page.evaluate(async (entry) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + const updated = {...entry, lexemeForm: {...entry.lexemeForm, seh: 'UPDATED-' + (entry.lexemeForm.seh || entry.lexemeForm.en || 'entry')}}; + await testUtils.demoApi.updateEntry(entry, updated); + }, firstEntry); + + // Wait a bit for the event to be processed + await page.waitForTimeout(300); + + // The first entry should now show the updated text + const newFirstEntryText = await entryRows.first().textContent(); + expect(newFirstEntryText).toContain('UPDATED-'); + }); + + test('deleting selected entry clears selection', async ({page}) => { + const {entryRows, selectedEntry} = getLocators(page); + + // Click first entry to select it + await entryRows.first().click(); + await expect(selectedEntry).toBeVisible(); + + // Get the selected entry ID + const entryId = await page.evaluate(async () => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + const entries = await testUtils.demoApi.getEntries({count: 1, offset: 0, order: {field: 'Headword' as unknown as SortField, writingSystem: 'default', ascending: true}}); + return entries[0]?.id; + }); + + // Delete via API + await page.evaluate(async (id) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + await testUtils.demoApi.deleteEntry(id); + }, entryId); + + await page.waitForTimeout(300); + + // Selection should be cleared (no entry selected) + // URL should not have entryId parameter + const url = page.url(); + expect(url).not.toContain(`entryId=${entryId}`); + }); + + test('deleting entry not in cache triggers reset but maintains position', async ({page}) => { + const {vlist} = getLocators(page); + + // Scroll to the middle of the list + const scrollHeight = await vlist.evaluate((el) => el.scrollHeight); + const middleScroll = scrollHeight * 0.5; + await vlist.evaluate((el, target) => { el.scrollTop = target; }, middleScroll); + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Get visible entry texts at current position + const visibleTexts = await getVisibleEntryTexts(page); + expect(visibleTexts.length).toBeGreaterThan(0); + + // Get an entry ID from the TOP of the list (not loaded/cached) + const topEntryId = await page.evaluate(async (headword) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + const entries = await testUtils.demoApi.getEntries({count: 1, offset: 0, order: {field: headword, writingSystem: 'default', ascending: true}}); + return entries[0]?.id; + }, SortField.Headword); + + // Delete the top entry (which is NOT in cache since we scrolled to middle) + await page.evaluate(async (id) => { + const testUtils = window.__PLAYWRIGHT_UTILS__; + await testUtils.demoApi.deleteEntry(id); + }, topEntryId); + + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // After reset, we should still see similar entries (the visible ones shouldn't drastically change) + // Note: This test verifies the reset happens gracefully + const newVisibleTexts = await getVisibleEntryTexts(page); + expect(newVisibleTexts.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/viewer/tests/test.d.ts b/frontend/viewer/tests/test.d.ts new file mode 100644 index 0000000000..fb511c51c7 --- /dev/null +++ b/frontend/viewer/tests/test.d.ts @@ -0,0 +1,10 @@ +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; + +export { }; // for some reason this is required in order to make global changes + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/naming-convention + __PLAYWRIGHT_UTILS__: {demoApi: IMiniLcmJsInvokable} + } +} From baa584315f5f319d8205eeaa25b24614fbda76e3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 20 Jan 2026 12:12:50 +0100 Subject: [PATCH 15/42] Add more failing event handling test cases --- AGENTS.md | 5 + frontend/viewer/tests/entries-list-v2.test.ts | 212 ++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index eae0f76c70..a4c7cfd905 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,12 @@ Key documentation for this project: - `docs/DEVELOPER-linux.md` - Linux development setup - `docs/DEVELOPER-osx.md` - macOS development setup - `backend/README.md` - Backend architecture +- `backend/AGENTS.md` - General backend guidelines +- `backend/LexBoxApi/AGENTS.md` - API & GraphQL specific rules - `backend/FwLite/AGENTS.md` - **FwLite/CRDT** (Critical code! Data loss risks!) - `backend/FwHeadless/AGENTS.md` - **FwHeadless guide** (Critical code! Data loss risks! Mercurial sync, FwData processing) +- `frontend/AGENTS.md` - General frontend/SvelteKit rules +- `frontend/viewer/AGENTS.md` - **FwLite Viewer** (Specific frontend rules) - `deployment/README.md` - Deployment and infrastructure ## Guidelines @@ -65,6 +69,7 @@ Before implementing any change that will touch many files or is in a 🔴 **Crit ### Important Rules +- ✅ **ALWAYS read local `AGENTS.md` files** in the directories you are working in (and their parents) before starting. - ✅ New instructions in AGENTS.md files should be SUCCINCT. - ✅ Use `gh` CLI for GitHub issues/PRs, not browser tools - ✅ Use **Mermaid diagrams** for flowcharts and architecture (not ASCII art) diff --git a/frontend/viewer/tests/entries-list-v2.test.ts b/frontend/viewer/tests/entries-list-v2.test.ts index 0b8dfe8712..d7623ef9cf 100644 --- a/frontend/viewer/tests/entries-list-v2.test.ts +++ b/frontend/viewer/tests/entries-list-v2.test.ts @@ -1,5 +1,6 @@ import {type Page, expect, test} from '@playwright/test'; import {SortField} from '$lib/dotnet-types/generated-types/MiniLcm/SortField'; +import {MorphType} from '$lib/dotnet-types/generated-types/MiniLcm/Models/MorphType'; /** * Tests for V2 virtual scrolling features: @@ -40,6 +41,84 @@ test.describe('EntriesList V2 features', () => { return texts; } + async function getItemHeight(page: Page): Promise { + const {entryRows} = getLocators(page); + await expect(entryRows.first()).toBeVisible(); + const firstBox = await entryRows.first().boundingBox(); + const secondBox = await entryRows.nth(1).boundingBox(); + if (!firstBox || !secondBox) throw new Error('Could not measure entry rows'); + return secondBox.y - firstBox.y; + } + + async function scrollToIndex(page: Page, targetIndex: number, itemHeight: number): Promise { + const {vlist} = getLocators(page); + const targetScroll = targetIndex * itemHeight; + await vlist.evaluate((el, target) => { el.scrollTop = target; }, targetScroll); + await page.waitForTimeout(300); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + } + + async function getCenterVisibleEntryText(page: Page): Promise { + const {vlist, entryRows} = getLocators(page); + const containerBox = await vlist.boundingBox(); + if (!containerBox) throw new Error('Could not get vlist bounds'); + const centerY = containerBox.y + containerBox.height / 2; + + const rows = await entryRows.all(); + for (const row of rows) { + const box = await row.boundingBox(); + if (box && box.y <= centerY && box.y + box.height > centerY) { + return (await row.textContent()) ?? ''; + } + } + throw new Error('No entry at center'); + } + + async function createEntryAtIndex(page: Page, targetIndex: number): Promise<{id: string, headword: string}> { + return page.evaluate(async ({idx, headwordField, morphType}) => { + const api = window.__PLAYWRIGHT_UTILS__.demoApi; + const offset = Math.max(0, idx - 1); + const entries = await api.getEntries({ + offset, + count: 1, + order: {field: headwordField, writingSystem: 'default', ascending: true} + }); + const baseHeadword = entries[0]?.lexemeForm?.seh ?? '#'; + const newHeadword = baseHeadword + '-inserted'; + const newEntry = { + id: crypto.randomUUID(), + lexemeForm: {seh: newHeadword}, + citationForm: {}, + senses: [], + note: {}, + literalMeaning: {}, + morphType, + components: [], + complexForms: [], + complexFormTypes: [], + publishIn: [], + }; + const created = await api.createEntry(newEntry); + return {id: created.id, headword: newHeadword}; + }, {idx: targetIndex, headwordField: SortField.Headword, morphType: MorphType.Unknown}); + } + + + + async function getEntryTextAtIndex(page: Page, index: number): Promise { + return page.evaluate(async ({idx, headwordField}) => { + const api = window.__PLAYWRIGHT_UTILS__.demoApi; + const entries = await api.getEntries({ + offset: idx, + count: 1, + order: {field: headwordField, writingSystem: 'default', ascending: true} + }); + return entries[0]?.lexemeForm?.seh ?? ''; + }, {idx: index, headwordField: SortField.Headword}); + } + + + test.describe('Jump to entry', () => { test('reloading with entry selected scrolls to that entry', async ({page}) => { // Go to project view @@ -255,5 +334,138 @@ test.describe('EntriesList V2 features', () => { const newVisibleTexts = await getVisibleEntryTexts(page); expect(newVisibleTexts.length).toBeGreaterThan(0); }); + + test('entry added in loaded batch', async ({page}) => { + const {entryRows} = getLocators(page); + const itemHeight = await getItemHeight(page); + + // Setup: Get entry texts at key indices from API (before insert) + const old25Text = await getEntryTextAtIndex(page, 25); + const old49Text = await getEntryTextAtIndex(page, 49); + + // Action: Create entry at index 25 + const {headword} = await createEntryAtIndex(page, 25); + + // Give time for event handling + await page.waitForTimeout(300); + + // Verify entry at index 25 via API shows the new entry + const new25Text = await getEntryTextAtIndex(page, 25); + expect(new25Text).toContain('-inserted'); + expect(new25Text).toBe(headword); + + // Verify entry at index 26 via API shows what was at 25 + const new26Text = await getEntryTextAtIndex(page, 26); + expect(new26Text).toBe(old25Text); + + // Verify batch boundary push: entry at index 50 shows what was at 49 + const new50Text = await getEntryTextAtIndex(page, 50); + expect(new50Text).toBe(old49Text); + + // Verify UI: scroll to index 25 and verify new entry is visible + await scrollToIndex(page, 25, itemHeight); + await expect(entryRows.filter({hasText: headword}).first()).toBeVisible({timeout: 5000}); + + // Verify UI: scroll to index 50 and verify pushed entry is visible + await scrollToIndex(page, 50, itemHeight); + await expect(entryRows.filter({hasText: old49Text}).first()).toBeVisible({timeout: 5000}); + }); + + test('entry added after all loaded batches', async ({page}) => { + const {entryRows, vlist} = getLocators(page); + const itemHeight = await getItemHeight(page); + + // Setup: Stay at batch 0 + const initialVisibleTexts = await getVisibleEntryTexts(page); + const oldScrollHeight = await vlist.evaluate((el) => el.scrollHeight); + + // Action: Create entry at index 5000 + const {headword} = await createEntryAtIndex(page, 5000); + await page.waitForTimeout(300); + + // Verify scroll height increased (count +1 means more virtual height) + const newScrollHeight = await vlist.evaluate((el) => el.scrollHeight); + expect(newScrollHeight).toBeGreaterThan(oldScrollHeight); + + // Visible entries unchanged (batch 0 not affected) + const newVisibleTexts = await getVisibleEntryTexts(page); + expect(newVisibleTexts).toEqual(initialVisibleTexts); + + // Verify via API that entry exists at index 5000 + const entryAt5000 = await getEntryTextAtIndex(page, 5000); + expect(entryAt5000).toContain('-inserted'); + expect(entryAt5000).toBe(headword); + + // Scroll to index 5000 and verify entry is visible in UI + await scrollToIndex(page, 5000, itemHeight); + await expect(entryRows.filter({hasText: headword}).first()).toBeVisible({timeout: 5000}); + }); + + test('entry added before loaded batch (quiet reset test)', async ({page}) => { + const itemHeight = await getItemHeight(page); + const {vlist} = getLocators(page); + + // Setup: Scroll to batch 2 (index 100+) + await scrollToIndex(page, 120, itemHeight); + const oldScrollHeight = await vlist.evaluate((el) => el.scrollHeight); + + // Capture: Center-visible entry text BEFORE action + const centerTextBefore = await getCenterVisibleEntryText(page); + expect(centerTextBefore).toBeTruthy(); + + // Action: Create entry at index 25 (before currently loaded batch) + await createEntryAtIndex(page, 25); + + // Wait for quiet reset and reload + await page.waitForTimeout(500); + await expect(page.locator('[data-skeleton]')).toHaveCount(0, {timeout: 5000}); + + // Verify scroll height increased (count +1) + const newScrollHeight = await vlist.evaluate((el) => el.scrollHeight); + expect(newScrollHeight).toBeGreaterThan(oldScrollHeight); + + // Center-visible entry text unchanged after reset completes + // (This verifies scroll position was maintained through the quiet reset) + const centerTextAfter = await getCenterVisibleEntryText(page); + expect(centerTextAfter).toBe(centerTextBefore); + }); + + test('entry updated not in cache', async ({page}) => { + const {entryRows} = getLocators(page); + const itemHeight = await getItemHeight(page); + + // Setup: Stay at batch 0 + const initialVisibleTexts = await getVisibleEntryTexts(page); + + // Action: Update entry at index 100 by appending to its headword (preserves sort position) + const updatedHeadword = await page.evaluate(async ({idx, headwordField}) => { + const api = window.__PLAYWRIGHT_UTILS__.demoApi; + const entries = await api.getEntries({ + offset: idx, + count: 1, + order: {field: headwordField, writingSystem: 'default', ascending: true} + }); + const entry = entries[0]; + // Append suffix to preserve alphabetical sort position + const newHeadword = (entry.lexemeForm?.seh ?? 'entry') + '-UPDATED'; + const updated = { + ...entry, + lexemeForm: {...entry.lexemeForm, seh: newHeadword} + }; + await api.updateEntry(entry, updated); + return newHeadword; + }, {idx: 100, headwordField: SortField.Headword}); + + await page.waitForTimeout(300); + + // Verify no visible change to batch 0 (update was outside cache) + const newVisibleTexts = await getVisibleEntryTexts(page); + expect(newVisibleTexts).toEqual(initialVisibleTexts); + + // Scroll to index 100 and verify updated entry is visible + await scrollToIndex(page, 100, itemHeight); + await expect(entryRows.filter({hasText: '-UPDATED'}).first()).toBeVisible({timeout: 5000}); + await expect(entryRows.filter({hasText: updatedHeadword}).first()).toBeVisible({timeout: 5000}); + }); }); }); From 0daa812d5d9ad24426be6d300d2420665835c523 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 20 Jan 2026 12:43:45 +0100 Subject: [PATCH 16/42] Frontend: Add entry index lookup, versions and invalidation --- AGENTS.md | 1 + backend/FwLite/AGENTS.md | 11 +++ .../Services/MiniLcmJsInvokable.cs | 6 ++ frontend/viewer/AGENTS.md | 11 +++ .../viewer/src/lib/components/ListItem.svelte | 1 - .../Services/IMiniLcmJsInvokable.ts | 3 +- .../entry-loader-service.svelte.test.ts | 19 ++++- .../services/entry-loader-service.svelte.ts | 42 +++++++++-- .../src/project/browse/EntriesList.svelte | 69 +++++++++++-------- 9 files changed, 124 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4c7cfd905..d780f3cbe0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Before implementing any change that will touch many files or is in a 🔴 **Crit - ✅ New instructions in AGENTS.md files should be SUCCINCT. - ✅ Use `gh` CLI for GitHub issues/PRs, not browser tools - ✅ Use **Mermaid diagrams** for flowcharts and architecture (not ASCII art) +- ✅ Prefer IDE diagnostics (compiler/lint errors) over CLI tools for identifying issues. Fixing these diagnostics is part of completing any instruction. - ✅ Do NOT run integration tests unless user explicitly requests - ✅ When handling a user prompt ALWAYS ask for clarification if there are details to clarify, important decisions that must be made first or the plan sounds unwise - ❌ Do NOT git commit or git push without explicit user approval diff --git a/backend/FwLite/AGENTS.md b/backend/FwLite/AGENTS.md index 30b10390d9..224c1ea2ff 100644 --- a/backend/FwLite/AGENTS.md +++ b/backend/FwLite/AGENTS.md @@ -30,6 +30,17 @@ dotnet test FwLiteOnly.slnf dotnet build FwLiteMaui/FwLiteMaui.csproj --framework net9.0-windows10.0.19041.0 ``` +## Generated Types (TypeScript) + +The frontend viewer uses TypeScript types and API interfaces generated from .NET using **Reinforced.Typings**. These are automatically updated when you build the **FwLiteShared** project (or any project that depends on it like `FwLiteMaui` or `FwLiteWeb`). + +```bash +# To manually update generated types: +dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj +``` + +The configuration for this lives in `FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs` and `FwLiteShared/Reinforced.Typings.settings.xml`. + ## Project Structure | Directory | Priority | Purpose | diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 3daf61bddd..84fbc6b42f 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -95,6 +95,12 @@ public Task CountEntries(string? query, FilterQueryOptions? options) return Task.Run(() => _wrappedApi.CountEntries(query, options)); } + [JSInvokable] + public Task GetEntryIndex(Guid id, string? query, QueryOptions? options) + { + return Task.Run(() => _wrappedApi.GetEntryIndex(id, query, options)); + } + [JSInvokable] public Task GetEntries(QueryOptions? options = null) { diff --git a/frontend/viewer/AGENTS.md b/frontend/viewer/AGENTS.md index c9034864ff..f1a261b625 100644 --- a/frontend/viewer/AGENTS.md +++ b/frontend/viewer/AGENTS.md @@ -15,6 +15,17 @@ pnpm install pnpm run dev ``` +### Generated .NET Types + +This project depends on TypeScript types and API interfaces generated from .NET (via `Reinforced.Typings`). If you change .NET models or `JSInvokable` APIs, you must rebuild the backend to update these types. + +```bash +# From repo root +dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj +``` + +The generated files are located in `src/lib/generated-types/`. + ### E2E Testing (Playwright) To run E2E tests for the viewer: diff --git a/frontend/viewer/src/lib/components/ListItem.svelte b/frontend/viewer/src/lib/components/ListItem.svelte index dea32680b5..097b8693b1 100644 --- a/frontend/viewer/src/lib/components/ListItem.svelte +++ b/frontend/viewer/src/lib/components/ListItem.svelte @@ -33,7 +33,6 @@