feat: refactor banner service and add comprehensive tests

- Remove legacy community support banner tracking variables and logic
- Simplify banner dismissal handling by checking dismissal state before marking
- Replace timer-based community support banner with immediate registration
- Clean up unused constants and legacy storage keys
- Add comprehensive test suite with mocked dependencies
- Improve code maintainability and test coverage
This commit is contained in:
Will Miao
2025-11-03 19:50:35 +08:00
parent e6e7df7454
commit 4862419b61
2 changed files with 228 additions and 26 deletions

View File

@@ -12,7 +12,6 @@ const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
// Increment this version to reset the banner schedule after significant updates
const COMMUNITY_SUPPORT_STATE_VERSION = 'v2';
const COMMUNITY_SUPPORT_SHOWN_KEY_LEGACY = 'community_support_banner_shown';
const KO_FI_URL = 'https://ko-fi.com/pixelpawsai';
const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai';
const BANNER_HISTORY_KEY = 'banner_history';
@@ -28,8 +27,6 @@ class BannerService {
this.banners = new Map();
this.container = null;
this.initialized = false;
this.communitySupportBannerTimer = null;
this.communitySupportBannerRegistered = false;
this.recentHistory = this.loadBannerHistory();
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
@@ -114,7 +111,9 @@ class BannerService {
*/
dismissBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
if (!dismissedBanners.includes(bannerId)) {
let bannerAlreadyDismissed = dismissedBanners.includes(bannerId);
if (!bannerAlreadyDismissed) {
dismissedBanners.push(bannerId);
setStorageItem('dismissed_banners', dismissedBanners);
}
@@ -135,7 +134,9 @@ class BannerService {
}, 300);
}
this.markBannerDismissed(bannerId);
if (!bannerAlreadyDismissed) {
this.markBannerDismissed(bannerId);
}
}
/**
@@ -232,11 +233,6 @@ class BannerService {
}
prepareCommunitySupportBanner() {
if (this.communitySupportBannerTimer) {
clearTimeout(this.communitySupportBannerTimer);
this.communitySupportBannerTimer = null;
}
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
return;
}
@@ -250,29 +246,17 @@ class BannerService {
}
const availableAt = firstSeenAt + COMMUNITY_SUPPORT_BANNER_DELAY_MS;
const delay = Math.max(availableAt - now, 0);
if (delay === 0) {
if (now >= availableAt) {
this.registerCommunitySupportBanner();
} else {
this.communitySupportBannerTimer = setTimeout(() => {
this.registerCommunitySupportBanner();
}, delay);
}
}
registerCommunitySupportBanner() {
if (this.communitySupportBannerRegistered || this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
return;
}
if (this.communitySupportBannerTimer) {
clearTimeout(this.communitySupportBannerTimer);
this.communitySupportBannerTimer = null;
}
this.communitySupportBannerRegistered = true;
// Determine support URL based on user language
const currentLanguage = state.global.settings.language;
const supportUrl = currentLanguage === 'zh-CN' ? AFDIAN_URL : KO_FI_URL;
@@ -330,7 +314,6 @@ class BannerService {
setStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, COMMUNITY_SUPPORT_STATE_VERSION);
setStorageItem(COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, Date.now());
removeStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY_LEGACY);
}
loadBannerHistory() {

View File

@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import * as storageHelpers from '../../../static/js/utils/storageHelpers.js';
import * as i18nHelpers from '../../../static/js/utils/i18nHelpers.js';
import { state } from '../../../static/js/state/index.js';
// Mock storage helpers
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn(),
removeStorageItem: vi.fn()
}));
// Mock i18n helpers
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, params, defaultValue) => defaultValue || key)
}));
// Mock state
vi.mock('../../../static/js/state/index.js', () => ({
state: {
global: {
settings: {
language: 'en'
}
}
}
}));
describe('BannerService', () => {
beforeEach(() => {
// Clear all mocks
vi.clearAllMocks();
// Reset banner service state
bannerService.banners.clear();
bannerService.initialized = false;
// Clear DOM
document.body.innerHTML = '<div id="banner-container"></div>';
});
describe('Community Support Banner', () => {
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
beforeEach(() => {
// Mock the version check to avoid resetting state
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2'; // Current version
}
return defaultValue;
});
// Initialize the banner service
bannerService.initializeCommunitySupportState();
});
it('should not show community support banner before 5 days have passed', () => {
const now = Date.now();
const firstSeenAt = now - (3 * 24 * 60 * 60 * 1000); // 3 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should not be registered yet
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false);
} finally {
global.Date.now = originalNow;
}
});
it('should show community support banner after 5 days have passed', () => {
const now = Date.now();
const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should be registered
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(true);
} finally {
global.Date.now = originalNow;
}
});
it('should not show community support banner if it has been dismissed', () => {
const now = Date.now();
const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [COMMUNITY_SUPPORT_BANNER_ID]; // Dismissed
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should not be registered because it's dismissed
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false);
} finally {
global.Date.now = originalNow;
}
});
it('should set first seen time if not already set', () => {
const now = Date.now();
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return null; // Not set
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Should have set the first seen time
expect(storageHelpers.setStorageItem).toHaveBeenCalledWith(
COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY,
now
);
} finally {
global.Date.now = originalNow;
}
});
});
describe('Banner Dismissal', () => {
it('should add banner to dismissed_banners array when dismissed', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
bannerService.dismissBanner('test-banner');
expect(storageHelpers.setStorageItem).toHaveBeenCalledWith(
'dismissed_banners',
['test-banner']
);
});
it('should not add duplicate banner IDs to dismissed_banners array', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return ['test-banner'];
}
return defaultValue;
});
bannerService.dismissBanner('test-banner');
// Should not have been called again since it's already dismissed
expect(storageHelpers.setStorageItem).not.toHaveBeenCalled();
});
});
});