- {openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
+ {(openLink || preloadButton) &&
+ (actionsSlot ? createPortal(headerActions, actionsSlot) : headerActions)}
+ {preloadNotice && (
+
+ {preloadNotice.text}
+
+ )}
{urlOk ? (
{
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
activate('m0', slot());
- expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
+ expect(useCustomModuleCacheStore.getState().openIds).toEqual(
+ ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX),
+ );
- activate('m5', slot());
+ activate(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX], slot());
const state = useCustomModuleCacheStore.getState();
- expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
- expect(state.recent[0]).toBe('m5');
+ expect(state.openIds).toEqual(['m0', ...ids.slice(2)]);
+ expect(state.recent[0]).toBe(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX]);
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
expect(state.modules.m1).toBeDefined();
});
@@ -134,3 +136,94 @@ describe('useCustomModuleCacheStore — preload (quick-261008-who)', () => {
expect(s.active?.id).toBe('a');
});
});
+
+describe('Grenze acht mit gepinnten Modulen (quick-261008-who)', () => {
+ const mod = (id: string) => ({ id, name: id, url: `https://x.test/${id}` });
+ const slot = () => document.createElement('div');
+ const st = () => useCustomModuleCacheStore.getState();
+ const open = (id: string) => {
+ st().remember(mod(id));
+ st().activate(id, slot());
+ };
+
+ beforeEach(() => {
+ st().clear();
+ });
+
+ it('Grenzwerte: Offenhalten ist acht und deckt die Vorlade-Grenze', () => {
+ expect(CUSTOM_MODULE_KEEP_ALIVE_MAX).toBe(8);
+ expect(CUSTOM_MODULE_PRELOAD_MAX).toBeLessThanOrEqual(CUSTOM_MODULE_KEEP_ALIVE_MAX);
+ });
+
+ it('touchLru verwirft ohne Gepinnte das neunte Modul nach LRU', () => {
+ const recent = ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'];
+ expect(touchLru(recent, 'i', 8)).toEqual({
+ recent: ['i', ...recent.slice(0, 7)],
+ evicted: ['a'],
+ });
+ });
+
+ it('touchLru verwirft mit Gepinnten nur uebrige: 3 gepinnt + 5 uebrige, ein sechstes', () => {
+ const pinned = ['p1', 'p2', 'p3'];
+ const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
+ const result = touchLru(recent, 'o6', 8, pinned);
+ expect(result.evicted).toEqual(['o1']);
+ expect(result.recent).toEqual(['o6', 'o5', 'o4', 'o3', 'o2', 'p3', 'p2', 'p1']);
+ });
+
+ it('touchLru: gepinnte Module aktivieren zaehlt nicht unter den uebrigen', () => {
+ const pinned = ['p1', 'p2', 'p3'];
+ const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
+ expect(touchLru(recent, 'p1', 8, pinned).evicted).toEqual([]);
+ });
+
+ it('acht gepinnt: ein uebriges bleibt als aktives offen (neun), ein weiteres verwirft es', () => {
+ for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
+ open('x');
+ expect(st().openIds).toHaveLength(9);
+ open('y');
+ const s = st();
+ expect(s.openIds).toHaveLength(9);
+ expect(s.openIds).not.toContain('x');
+ expect(s.openIds).toContain('y');
+ for (let i = 0; i < 8; i++) expect(s.pinned).toContain(`p${i}`);
+ });
+
+ it('neu gepinnt verdraengt ueberzaehlige uebrige, nie das aktive', () => {
+ for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) open(id);
+ st().preload(mod('p'));
+ const s = st();
+ expect(s.openIds).not.toContain('a');
+ expect(s.openIds).toContain('h');
+ expect(s.openIds).toHaveLength(8);
+ expect(s.openIds).toContain('p');
+ });
+
+ it('activate eines gepinnten Moduls laesst es gepinnt', () => {
+ st().preload(mod('a'));
+ st().activate('a', slot());
+ expect(st().pinned).toEqual(['a']);
+ });
+
+ it('setPinned(false) macht das Modul wieder verwerfbar, ohne den Rahmen zu schliessen', () => {
+ st().preload(mod('a'));
+ st().setPinned('a', false);
+ expect(st().pinned).toEqual([]);
+ expect(st().openIds).toEqual(['a']);
+ expect(st().recent).toContain('a');
+ // beim naechsten Aktivieren gilt die Grenze: a ist das aelteste
+ for (const id of ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) open(id);
+ expect(st().openIds).not.toContain('a');
+ });
+
+ it('setPinned(true) nur fuer offene Module und bis zur Grenze; Reihenfolge bleibt stabil', () => {
+ st().setPinned('unbekannt', true);
+ expect(st().pinned).toEqual([]);
+ for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
+ open('x');
+ const before = st().openIds;
+ st().setPinned('x', true);
+ expect(st().pinned).not.toContain('x');
+ expect(st().openIds).toEqual(before);
+ });
+});
diff --git a/apps/web/src/lib/stores/custom-module-cache-store.ts b/apps/web/src/lib/stores/custom-module-cache-store.ts
index 227dd68..ea63f38 100644
--- a/apps/web/src/lib/stores/custom-module-cache-store.ts
+++ b/apps/web/src/lib/stores/custom-module-cache-store.ts
@@ -17,15 +17,26 @@ import type { CustomModule } from '@/lib/custom-modules-api';
* die