diff --git a/CHANGELOG.md b/CHANGELOG.md index 523deca..7fc6594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,22 @@ version with its date and start a fresh empty `[Unreleased]` above it. snapshots now carry the configured context-window tier, and zeroed snapshots can no longer overwrite an existing reading. +### Changed + +- The credits usage button now shows a static "Usage" tooltip through + Obsidian's native tooltip (aria-label), matching the other nav-row + buttons, instead of a browser title tooltip with the live percentage. + +### Fixed + +- Settings changed on Obsidian 1.13+ (language, auto-scroll, and the + other simple toggles) now persist across restarts: the declarative + control writes were only mirrored into the plugin data file, not the + `.qoderian/qoderian-settings.json` store Qoderian loads at startup. + Changing the language also re-localizes open chat views immediately + (nav tooltips and tab titles) instead of keeping the old language + until the view is reopened. + ## [1.0.4] - 2026-08-12 ### Fixed diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 968f267..c1a4309 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -11,6 +11,7 @@ import { scheduleAnimationFrame, type ScheduledAnimationFrame, } from '../../shared/dom/animation-frame'; +import { setButtonTooltip } from '../../shared/dom/tooltip'; import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons'; import type { HistoryConversationStatus } from './controllers/conversation-controller'; import { @@ -44,6 +45,8 @@ export class QoderianView extends ItemView { private viewContainerEl: HTMLElement | null = null; private logoEl: HTMLElement | null = null; private newTabButtonEl: HTMLElement | null = null; + private newConversationButtonEl: HTMLElement | null = null; + private historyButtonEl: HTMLElement | null = null; // Header elements private historyDropdown: HTMLElement | null = null; @@ -244,14 +247,15 @@ export class QoderianView extends ItemView { this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); setIcon(this.newTabButtonEl, 'square-plus'); - this.newTabButtonEl.setAttribute('aria-label', 'New tab'); + setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); this.newTabButtonEl.addEventListener('click', () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }); const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(newBtn, 'square-pen'); - newBtn.setAttribute('aria-label', 'New conversation'); + setButtonTooltip(newBtn, t('nav.newConversation')); + this.newConversationButtonEl = newBtn; newBtn.addEventListener('click', () => { void (async () => { await this.tabManager?.createNewConversation(); @@ -263,7 +267,8 @@ export class QoderianView extends ItemView { const historyContainer = navActionsEl.createDiv({ cls: 'qoderian-history-container' }); const historyBtn = historyContainer.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(historyBtn, 'history'); - historyBtn.setAttribute('aria-label', 'Chat history'); + setButtonTooltip(historyBtn, t('nav.chatHistory')); + this.historyButtonEl = historyBtn; this.historyDropdown = historyContainer.createDiv({ cls: 'qoderian-history-menu' }); @@ -346,6 +351,17 @@ export class QoderianView extends ItemView { this.updateTabBarVisibility(); } + /** Re-applies locale-dependent static text after a language change. */ + refreshLocalizedChrome(): void { + if (this.newTabButtonEl) setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); + if (this.newConversationButtonEl) { + setButtonTooltip(this.newConversationButtonEl, t('nav.newConversation')); + } + if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); + this.creditsUsageButton?.refreshLocale(); + this.updateTabBar(); + } + // ============================================ // Tab Management // ============================================ diff --git a/src/features/chat/tabs/tab-bar.ts b/src/features/chat/tabs/tab-bar.ts index 7876c64..40a2c55 100644 --- a/src/features/chat/tabs/tab-bar.ts +++ b/src/features/chat/tabs/tab-bar.ts @@ -1,4 +1,5 @@ import { scheduleAnimationFrame } from '../../../shared/dom/animation-frame'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; import type { TabBarItem, TabId } from './types'; const EXPANDED_TITLE_MAX_LENGTH = 32; @@ -82,7 +83,7 @@ export class TabBar { }); // Obsidian uses aria-label for hover tooltips here; adding title causes duplicate tooltip text. - badgeEl.setAttribute('aria-label', item.title); + setButtonTooltip(badgeEl, item.title); badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); // Click handler to switch tab diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index 3e9477b..eb443ab 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -1,3 +1,4 @@ +import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; import { cleanupThinkingBlock } from '../rendering/thinking-block-renderer'; import type { TabData } from './types'; @@ -71,5 +72,5 @@ export function getTabTitle(tab: TabData, plugin: QoderianPlugin): string { const conversation = plugin.getConversationSync(tab.conversationId); if (conversation?.title) return conversation.title; } - return 'New Chat'; + return t('nav.newChat'); } diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts index 14e1398..7dc334e 100644 --- a/src/features/chat/ui/credits-usage-button.ts +++ b/src/features/chat/ui/credits-usage-button.ts @@ -7,6 +7,7 @@ import type { } from '../../../core/types/services'; import { getLocale, t } from '../../../i18n/i18n'; import { getQoderAccountUsageUrl } from '../../../qoder/config/cli-edition'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; import { ClickPopover } from './toolbar/click-popover'; /** Usage snapshots older than this are refreshed when the panel opens. */ @@ -97,6 +98,12 @@ export class CreditsUsageButton { this.container.remove(); } + /** Re-applies locale-dependent text after a language change. */ + refreshLocale(): void { + this.updateButton(); + this.renderPanel(); + } + /** Fetches a fresh snapshot; cached snapshots within the TTL are kept. */ async refresh(force: boolean): Promise { if (this.loading) return; @@ -125,12 +132,9 @@ export class CreditsUsageButton { }; private updateButton(): void { - const percent = this.snapshot?.totalUsagePercentage; - if (typeof percent === 'number') { - this.buttonEl.setAttribute('title', t('credits.trigger', { percent: Math.round(percent) })); - } else { - this.buttonEl.setAttribute('title', t('credits.unavailable')); - } + // Same tooltip path as the other nav-row buttons (aria-label + 300ms + // delay); only the click behavior (popover) differs. + setButtonTooltip(this.buttonEl, t('credits.trigger')); } private renderPanel(): void { diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index 096c6f5..6ba12d5 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -417,15 +417,30 @@ export class QoderianSettingTab extends PluginSettingTab { return this.plugin.saveSettings(); } - if (key === 'mediaFolder') { - return super.setControlValue(key, String(value).trim()); - } + return this.persistControlValue(key, value); + } + // Unreachable: Obsidian < 1.13 never calls setControlValue. + } - const result = super.setControlValue(key, value); + /** + * The declarative base class persists into the plugin data file, but + * Qoderian keeps its settings in .qoderian/qoderian-settings.json, so per + * the API contract ("override to write to a different data source") we + * mutate the settings bag ourselves and save once through the store that + * loadSettings() actually reads — no duplicate write to data.json. + */ + private async persistControlValue(key: string, value: unknown): Promise { + // Only reached from setControlValue on Obsidian 1.13+; re-checked here so + // the 1.13-only view-refresh calls stay behind an explicit version guard. + if (requireApiVersion('1.13.0')) { + const settings = this.plugin.settings as unknown as Record; + settings[key] = key === 'mediaFolder' ? String(value).trim() : value; + await this.plugin.saveSettings(); if (key === 'locale') { setLocale(this.plugin.settings.locale as Locale); this.update(); + this.refreshViewChrome(); } else if (key === 'maxTabs') { for (const view of this.plugin.getAllViews()) { view.refreshTabControls(); @@ -435,10 +450,14 @@ export class QoderianSettingTab extends PluginSettingTab { } else if (PROMPT_SETTING_KEYS.has(key)) { this.schedulePromptRestart(); } + } + } - return result; + /** Re-applies locale-dependent text in open chat views after a language change. */ + private refreshViewChrome(): void { + for (const view of this.plugin.getAllViews()) { + view.refreshLocalizedChrome(); } - // Unreachable: Obsidian < 1.13 never calls setControlValue. } /** @@ -518,6 +537,7 @@ export class QoderianSettingTab extends PluginSettingTab { this.plugin.settings.locale = locale; await this.plugin.saveSettings(); this.display(); + this.refreshViewChrome(); }); }); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 128c14f..dda8c80 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -38,6 +38,11 @@ "newSession": "Neue Sitzung (im aktuellen Tab)", "closeCurrentTab": "Aktuellen Tab schließen" }, + "nav": { + "newConversation": "Neue Unterhaltung", + "chatHistory": "Chatverlauf", + "newChat": "Neuer Chat" + }, "chat": { "rewind": { "confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.", @@ -292,7 +297,7 @@ "renewsOn": "Verlängert am {date}", "usedPercent": "{percent}% verwendet", "left": "{count} übrig", - "trigger": "Nutzung - {percent}%", + "trigger": "Nutzung", "unavailable": "Nutzung nicht verfügbar" }, "model": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1646cc4..a753846 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,6 +38,11 @@ "newSession": "New session (in current tab)", "closeCurrentTab": "Close current tab" }, + "nav": { + "newConversation": "New conversation", + "chatHistory": "Chat history", + "newChat": "New Chat" + }, "chat": { "rewind": { "confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.", @@ -292,7 +297,7 @@ "renewsOn": "Renews on {date}", "usedPercent": "{percent}% used", "left": "{count} left", - "trigger": "Usage - {percent}%", + "trigger": "Usage", "unavailable": "Usage unavailable" }, "model": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 07d9e6c..4a91e5f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,6 +38,11 @@ "newSession": "Nueva sesión (en la pestaña actual)", "closeCurrentTab": "Cerrar pestaña actual" }, + "nav": { + "newConversation": "Nueva conversación", + "chatHistory": "Historial del chat", + "newChat": "Nuevo chat" + }, "chat": { "rewind": { "confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.", @@ -292,7 +297,7 @@ "renewsOn": "Se renueva el {date}", "usedPercent": "{percent}% usado", "left": "{count} restantes", - "trigger": "Uso - {percent}%", + "trigger": "Uso", "unavailable": "Uso no disponible" }, "model": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 77ac30a..7b48beb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -38,6 +38,11 @@ "newSession": "Nouvelle session (dans l'onglet actuel)", "closeCurrentTab": "Fermer l'onglet actuel" }, + "nav": { + "newConversation": "Nouvelle conversation", + "chatHistory": "Historique du chat", + "newChat": "Nouveau chat" + }, "chat": { "rewind": { "confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.", @@ -292,7 +297,7 @@ "renewsOn": "Renouvellement le {date}", "usedPercent": "{percent}% utilisés", "left": "{count} restants", - "trigger": "Utilisation - {percent}%", + "trigger": "Utilisation", "unavailable": "Utilisation indisponible" }, "model": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4d7910b..0b21cc7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -38,6 +38,11 @@ "newSession": "新しいセッション(現在のタブ)", "closeCurrentTab": "現在のタブを閉じる" }, + "nav": { + "newConversation": "新しい会話", + "chatHistory": "チャット履歴", + "newChat": "新しいチャット" + }, "chat": { "rewind": { "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", @@ -292,7 +297,7 @@ "renewsOn": "{date} に更新", "usedPercent": "{percent}% 使用済み", "left": "残り {count}", - "trigger": "利用量 - {percent}%", + "trigger": "利用量", "unavailable": "利用量情報を取得できません" }, "model": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8271196..046862d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -38,6 +38,11 @@ "newSession": "새 세션 (현재 탭)", "closeCurrentTab": "현재 탭 닫기" }, + "nav": { + "newConversation": "새 대화", + "chatHistory": "대화 기록", + "newChat": "새 채팅" + }, "chat": { "rewind": { "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", @@ -292,7 +297,7 @@ "renewsOn": "{date}에 갱신", "usedPercent": "{percent}% 사용", "left": "{count} 남음", - "trigger": "사용량 - {percent}%", + "trigger": "사용량", "unavailable": "사용량을 확인할 수 없음" }, "model": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index be38e07..a5e1589 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -38,6 +38,11 @@ "newSession": "Nova sessão (na aba atual)", "closeCurrentTab": "Fechar aba atual" }, + "nav": { + "newConversation": "Nova conversa", + "chatHistory": "Histórico de conversas", + "newChat": "Novo chat" + }, "chat": { "rewind": { "confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.", @@ -292,7 +297,7 @@ "renewsOn": "Renova em {date}", "usedPercent": "{percent}% usado", "left": "{count} restantes", - "trigger": "Uso - {percent}%", + "trigger": "Uso", "unavailable": "Uso indisponível" }, "model": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 71874bc..2ccff00 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -38,6 +38,11 @@ "newSession": "Новая сессия (в текущей вкладке)", "closeCurrentTab": "Закрыть текущую вкладку" }, + "nav": { + "newConversation": "Новый разговор", + "chatHistory": "История чата", + "newChat": "Новый чат" + }, "chat": { "rewind": { "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", @@ -292,7 +297,7 @@ "renewsOn": "Обновление {date}", "usedPercent": "использовано {percent}%", "left": "осталось {count}", - "trigger": "Использование - {percent}%", + "trigger": "Использование", "unavailable": "Использование недоступно" }, "model": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6c9644d..57b45a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -38,6 +38,11 @@ "newSession": "新建会话(当前标签页)", "closeCurrentTab": "关闭当前标签页" }, + "nav": { + "newConversation": "新建会话", + "chatHistory": "聊天历史", + "newChat": "新会话" + }, "chat": { "rewind": { "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", @@ -292,7 +297,7 @@ "renewsOn": "将于 {date} 刷新", "usedPercent": "已使用 {percent}%", "left": "剩余 {count}", - "trigger": "用量 - {percent}%", + "trigger": "用量", "unavailable": "用量暂不可用" }, "model": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7d04f1c..73517db 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -38,6 +38,11 @@ "newSession": "新增工作階段(目前分頁)", "closeCurrentTab": "關閉目前分頁" }, + "nav": { + "newConversation": "新建對話", + "chatHistory": "聊天歷史", + "newChat": "新對話" + }, "chat": { "rewind": { "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", @@ -292,7 +297,7 @@ "renewsOn": "將於 {date} 刷新", "usedPercent": "已使用 {percent}%", "left": "剩餘 {count}", - "trigger": "用量 - {percent}%", + "trigger": "用量", "unavailable": "用量暫不可用" }, "model": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 3c4aed1..1487f76 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -11,6 +11,11 @@ export type TranslationKey = | 'commands.newSession' | 'commands.closeCurrentTab' + // Nav row buttons and tab badges + | 'nav.newConversation' + | 'nav.chatHistory' + | 'nav.newChat' + // Common UI elements | 'common.save' | 'common.cancel' diff --git a/src/shared/dom/tooltip.ts b/src/shared/dom/tooltip.ts new file mode 100644 index 0000000..1e4164f --- /dev/null +++ b/src/shared/dom/tooltip.ts @@ -0,0 +1,16 @@ +import { setTooltip } from 'obsidian'; + +/** + * Hover delay (ms) for icon-button tooltips. + * Obsidian's native aria-label tooltips use a 1000ms cold-start delay, which + * feels sluggish next to the 200-500ms range common in web toolbars. + */ +const BUTTON_TOOLTIP_DELAY = 300; + +/** + * Sets a hover tooltip on an icon button with a snappier-than-default delay. + * Keeps the native Obsidian tooltip rendering (placement, arrow, a11y) intact. + */ +export function setButtonTooltip(el: HTMLElement, text: string): void { + setTooltip(el, text, { delay: BUTTON_TOOLTIP_DELAY }); +} diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index e6a0974..0cf7c23 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -32,6 +32,17 @@ export class PluginSettingTab { } display() {} + + // Mirrors the 1.13 declarative base: mutates plugin.settings in place. + getControlValue(key: string): unknown { + return (this.plugin.settings as Record)[key]; + } + + setControlValue(key: string, value: unknown): void { + (this.plugin.settings as Record)[key] = value; + } + + update() {} } export class ItemView { @@ -263,6 +274,15 @@ export const MarkdownRenderer = { export const setIcon = jest.fn(); +// Mirrors the real helper: writes aria-label plus any delay override that the +// native tooltip system would read back on hover. +export function setTooltip(el: HTMLElement, tooltip: string, options?: { delay?: number }): void { + el.setAttribute('aria-label', tooltip); + if (options?.delay !== undefined) { + el.setAttribute('data-tooltip-delay', String(options.delay)); + } +} + // Tests run against the newest API surface, so version gates take the // modern branch by default. export const requireApiVersion = jest.fn(() => true); diff --git a/tests/unit/features/chat/ui/credits-usage-button.test.ts b/tests/unit/features/chat/ui/credits-usage-button.test.ts index b5ec70c..e983c3d 100644 --- a/tests/unit/features/chat/ui/credits-usage-button.test.ts +++ b/tests/unit/features/chat/ui/credits-usage-button.test.ts @@ -2,6 +2,7 @@ import { createMockEl } from '@test/helpers/mock-element'; import type { CreditsUsageSnapshot } from '@/core/types/services'; import { CreditsUsageButton } from '@/features/chat/ui/credits-usage-button'; +import { setLocale } from '@/i18n/i18n'; import { setActiveQoderCliEdition } from '@/qoder/config/cli-edition'; const SNAPSHOT: CreditsUsageSnapshot = { @@ -27,25 +28,42 @@ function flushPromises(): Promise { interface ButtonHarness { parentEl: ReturnType; fetchUsage: jest.Mock; + button: CreditsUsageButton; } function createButton(cached: CreditsUsageSnapshot | null = SNAPSHOT): ButtonHarness { const parentEl = createMockEl(); const fetchUsage = jest.fn().mockResolvedValue(SNAPSHOT); - new CreditsUsageButton(parentEl, { + const button = new CreditsUsageButton(parentEl, { getCachedUsage: () => cached, fetchUsage, subscribeRuntimeStatus: jest.fn().mockReturnValue(() => {}), }); - return { parentEl, fetchUsage }; + return { parentEl, fetchUsage, button }; } describe('CreditsUsageButton', () => { - it('shows the overall percentage as the button tooltip', () => { + it('shows a static usage tooltip like the other nav-row buttons', () => { const { parentEl } = createButton(); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage - 100%'); + const btn = parentEl.querySelector('.qoderian-credits-btn'); + expect(btn?.getAttribute('aria-label')).toBe('Usage'); + // Same shared helper as the other nav buttons: 300ms hover delay. + expect(btn?.getAttribute('data-tooltip-delay')).toBe('300'); + expect(btn?.getAttribute('title')).toBeNull(); + }); + + it('re-applies localized text when the locale changes', () => { + const { parentEl, button } = createButton(); + + setLocale('zh-CN'); + try { + button.refreshLocale(); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('用量'); + } finally { + setLocale('en'); + } }); it('renders plan and resource package sections like the IDE usage panel', () => { @@ -118,8 +136,8 @@ describe('CreditsUsageButton', () => { it('shows an unavailable state without a snapshot', () => { const { parentEl } = createButton(null); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage unavailable'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('Usage'); expect(parentEl.querySelector('.qoderian-credits-empty')?.textContent) .toBe('Usage unavailable'); }); @@ -131,8 +149,8 @@ describe('CreditsUsageButton', () => { await flushPromises(); expect(fetchUsage).toHaveBeenCalledTimes(1); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage - 100%'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('Usage'); expect(parentEl.querySelector('.qoderian-credits-empty')).toBeNull(); }); diff --git a/tests/unit/features/settings/settings-tab.test.ts b/tests/unit/features/settings/settings-tab.test.ts new file mode 100644 index 0000000..faebdb6 --- /dev/null +++ b/tests/unit/features/settings/settings-tab.test.ts @@ -0,0 +1,42 @@ +import { QoderianSettingTab } from '@/features/settings/settings-tab'; + +function createTab() { + const view = { + refreshLocalizedChrome: jest.fn(), + refreshTabControls: jest.fn(), + }; + const plugin = { + settings: { + locale: 'zh-CN', + enableAutoScroll: true, + excludedTags: [] as string[], + mediaFolder: '', + }, + saveSettings: jest.fn().mockResolvedValue(undefined), + getAllViews: jest.fn(() => [view]), + }; + const tab = new QoderianSettingTab({} as any, plugin as any); + return { tab, plugin, view }; +} + +describe('QoderianSettingTab declarative controls', () => { + it('persists locale changes to the Qoderian settings store', async () => { + const { tab, plugin, view } = createTab(); + + await tab.setControlValue('locale', 'en'); + + expect(plugin.settings.locale).toBe('en'); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + // Open views re-apply localized static text immediately. + expect(view.refreshLocalizedChrome).toHaveBeenCalledTimes(1); + }); + + it('persists plain declarative controls too', async () => { + const { tab, plugin } = createTab(); + + await tab.setControlValue('enableAutoScroll', false); + + expect(plugin.settings.enableAutoScroll).toBe(false); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + }); +});