feat(banners): rotate active banners one at a time with a pager

Stacking every active banner vertically ate header height when several
were active at once. Only the highest-priority banner renders now; a
‹ 1/N › pager cycles through the rest, and all active banners are still
recorded in the notification-center history so cycled-away ones stay
reachable. Newly registered banners preempt the displayed one only when
they outrank it.

Also fix the startup flow: the restart-required banner (now priority 80)
outranks the model-folders setup warning (60), and the setup banner is
retired once a non-empty folder path is saved.

New banners.pager.* keys translated in all 9 locales.
This commit is contained in:
Will Miao
2026-09-18 21:40:33 +08:00
parent d45a523fb5
commit 3b9e8efb3d
15 changed files with 417 additions and 34 deletions
@@ -43,6 +43,7 @@ describe('BannerService', () => {
// Reset banner service state
bannerService.banners.clear();
bannerService.initialized = false;
bannerService.currentBannerIndex = 0;
bannerService.recentHistory = []; // Clear history for each test
// Clear DOM
@@ -331,6 +332,116 @@ describe('BannerService', () => {
});
});
describe('Banner Rotation', () => {
const registerTestBanner = (id, priority) => {
bannerService.registerBanner(id, {
id,
title: `Banner ${id}`,
content: `Content ${id}`,
dismissible: true,
priority
});
};
const displayedBannerId = () =>
document.querySelector('#banner-container .banner-item')
?.getAttribute('data-banner-id');
let dismissedStore;
beforeEach(() => {
dismissedStore = [];
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissedStore;
}
return defaultValue;
});
storageHelpers.setStorageItem.mockImplementation((key, value) => {
if (key === 'dismissed_banners') {
dismissedStore = value;
}
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
});
it('renders only the highest priority banner when multiple are active', () => {
registerTestBanner('low', 1);
registerTestBanner('high', 10);
const rendered = document.querySelectorAll('#banner-container .banner-item');
expect(rendered).toHaveLength(1);
expect(displayedBannerId()).toBe('high');
});
it('shows a pager with position indicator when multiple banners are active', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
const pager = document.querySelector('.banner-pager');
expect(pager).not.toBeNull();
expect(pager.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('1 / 2');
});
it('does not show a pager for a single banner', () => {
registerTestBanner('only', 1);
expect(document.querySelector('.banner-pager')).toBeNull();
});
it('cycles to the next banner and wraps around', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
document.querySelector('[data-pager="next"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('a');
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('2 / 2');
document.querySelector('[data-pager="next"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('b');
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('1 / 2');
});
it('cycles backwards with the previous button', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
document.querySelector('[data-pager="prev"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('a');
});
it('shows the next banner after the displayed one is dismissed', async () => {
vi.useFakeTimers();
try {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
expect(displayedBannerId()).toBe('b');
await bannerService.dismissBanner('b');
vi.advanceTimersByTime(300);
expect(displayedBannerId()).toBe('a');
} finally {
vi.useRealTimers();
}
});
it('records all active banners in history, not just the displayed one', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
const historyIds = bannerService.recentHistory.map(entry => entry.id);
expect(historyIds).toEqual(expect.arrayContaining(['a', 'b']));
});
});
describe('Banner History', () => {
const testBanner = {
id: 'test-banner',