fix: address reviewbot feedback and update tests (#61)

* fix: address reviewbot feedback and update tests

Align request handling with Obsidian requestUrl and clean up UI text/lint directives while updating tests for the new mocks.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: ignore release_log.txt

Add release_log.txt to gitignore to keep local release logs out of version control.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: align requesturl handling with review feedback

centralize requesturl availability errors, bind provider defaults once, and stabilize timeout test.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
asyouplz 2026-02-02 23:09:49 +09:00 committed by GitHub
parent cff3aab185
commit eefd7f136d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 225 additions and 316 deletions

1
.gitignore vendored
View file

@ -71,6 +71,7 @@ $RECYCLE.BIN/
# Logs
logs/
*.log
release_log.txt
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -22,6 +22,7 @@ export class TranscriptionService implements ITranscriptionService {
private eventManager: IEventManager;
private logger: ILogger;
private settings?: Record<string, unknown>;
private readonly requestUrlAvailable: boolean;
constructor(
whisperServiceOrSettings: IWhisperService | Record<string, unknown>,
@ -46,6 +47,13 @@ export class TranscriptionService implements ITranscriptionService {
this.eventManager = eventManager ?? this.createNoopEventManager();
this.logger = logger ?? this.createNoopLogger();
}
this.requestUrlAvailable = typeof requestUrl === 'function';
if (!this.requestUrlAvailable) {
this.logger.error(
'requestUrl API is not available. Update Obsidian to the latest version.'
);
}
}
async transcribe(
@ -201,6 +209,15 @@ export class TranscriptionService implements ITranscriptionService {
return this.status;
}
private ensureRequestUrlAvailable(): void {
if (this.requestUrlAvailable) {
return;
}
throw new Error(
'requestUrl API is not available. Please update Obsidian to the latest version.'
);
}
private createNoopLogger(): ILogger {
return {
debug: () => undefined,
@ -393,76 +410,26 @@ export class TranscriptionService implements ITranscriptionService {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const requestResult =
typeof requestUrl === 'function'
? requestUrl({
url,
method: 'POST',
headers,
body: body as string | ArrayBuffer,
throw: false,
})
: undefined;
this.ensureRequestUrlAvailable();
const responsePromise =
requestResult && typeof (requestResult as PromiseLike<unknown>).then === 'function'
? (
requestResult as PromiseLike<{
status: number;
json?: unknown;
text?: string;
}>
).then((response) => ({
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: String(response.status),
json: response.json,
text: response.text,
}))
: (async () => {
if (typeof fetch !== 'function') {
throw new Error('Fetch API not available');
}
const fetchResponse = await fetch(url, {
method: 'POST',
headers,
body: body as BodyInit,
signal,
});
let json: unknown;
let text: string | undefined;
if (typeof fetchResponse.json === 'function') {
try {
json = await fetchResponse.json();
} catch {
json = undefined;
}
}
if (json === undefined && typeof fetchResponse.text === 'function') {
try {
text = await fetchResponse.text();
} catch {
text = undefined;
}
}
const status =
typeof fetchResponse.status === 'number'
? fetchResponse.status
: fetchResponse.ok
? 200
: 500;
const ok =
typeof fetchResponse.ok === 'boolean'
? fetchResponse.ok
: status >= 200 && status < 300;
return {
ok,
status,
statusText: fetchResponse.statusText ?? String(status),
json,
text,
};
})();
const responsePromise = requestUrl({
url,
method: 'POST',
headers,
body: body as string | ArrayBuffer,
throw: false,
}).then((response) => {
const status = response.status;
const json = response.json as unknown;
const text = response.text;
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
json,
text,
};
});
const effectiveFetchPromise =
this.isTestEnvironment() && delayForAbort

View file

@ -228,6 +228,7 @@ export class WhisperService implements IWhisperService {
private apiKey: string;
private logger: ILogger;
private readonly requestUrlAvailable: boolean;
private abortController?: AbortController;
private retryStrategy: RetryStrategy;
@ -251,6 +252,14 @@ export class WhisperService implements IWhisperService {
this.timeoutMs = config.timeout;
}
}
this.requestUrlAvailable = typeof requestUrl === 'function';
if (!this.requestUrlAvailable) {
this.logger.error(
'requestUrl API is not available. Update Obsidian to the latest version.'
);
}
this.retryStrategy = new ExponentialBackoffRetry(this.logger);
this.circuitBreaker = new CircuitBreaker(this.logger);
}
@ -264,6 +273,15 @@ export class WhisperService implements IWhisperService {
};
}
private ensureRequestUrlAvailable(): void {
if (this.requestUrlAvailable) {
return;
}
throw new Error(
'requestUrl API is not available. Please update Obsidian to the latest version.'
);
}
private async resolveAudioBuffer(audio: ArrayBuffer | Blob): Promise<ArrayBuffer> {
if (audio instanceof ArrayBuffer) {
return audio;
@ -405,7 +423,7 @@ export class WhisperService implements IWhisperService {
})
);
const response = await this.executeRequest(requestParams, formData);
const response = await this.executeRequest(requestParams);
const processingTime = Date.now() - startTime;
this.logger.info(`Transcription completed in ${processingTime}ms`, {
@ -472,7 +490,9 @@ export class WhisperService implements IWhisperService {
return formData;
}
private buildRequestParams(formData: FormData): RequestUrlParam {
private buildRequestParams(
formData: FormData
): RequestUrlParam & { body: RequestUrlParam['body']; timeout: number } {
const requestParams = {
url: this.API_ENDPOINT,
method: 'POST',
@ -482,92 +502,31 @@ export class WhisperService implements IWhisperService {
body: formData as unknown as RequestUrlParam['body'],
timeout: this.timeoutMs,
throw: false,
} as RequestUrlParam & { timeout: number };
} as RequestUrlParam & { body: RequestUrlParam['body']; timeout: number };
return requestParams;
}
private async executeRequest(
requestParams: RequestUrlParam & { timeout?: number },
formData: FormData
requestParams: RequestUrlParam & { body: RequestUrlParam['body']; timeout?: number }
): Promise<{
status: number;
headers?: Record<string, string>;
json?: unknown;
text?: string;
}> {
const requestResult =
typeof requestUrl === 'function' ? requestUrl(requestParams) : undefined;
this.ensureRequestUrlAvailable();
if (requestResult && typeof (requestResult as PromiseLike<unknown>).then === 'function') {
const response = await requestResult;
return {
status: response.status,
headers: response.headers,
json: response.json as unknown,
text: response.text,
};
if (!requestParams.body) {
throw new Error('Request body not set for transcription');
}
if (typeof fetch !== 'function') {
throw new Error('Fetch API not available');
}
const controller = this.abortController;
const timeoutMs =
typeof requestParams.timeout === 'number' ? requestParams.timeout : this.timeoutMs;
const timeoutId =
timeoutMs > 0 && controller
? window.setTimeout(() => controller.abort(), timeoutMs)
: undefined;
try {
const fetchResponse = await fetch(requestParams.url, {
method: requestParams.method,
headers: requestParams.headers as HeadersInit,
body: (requestParams.body as BodyInit) ?? (formData as BodyInit),
signal: controller?.signal,
});
let json: unknown;
let text: string | undefined;
if (typeof fetchResponse.json === 'function') {
try {
json = await fetchResponse.json();
} catch {
json = undefined;
}
}
if (json === undefined && typeof fetchResponse.text === 'function') {
try {
text = await fetchResponse.text();
} catch {
text = undefined;
}
}
const headers: Record<string, string> = {};
if (fetchResponse.headers) {
fetchResponse.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
}
return {
status:
typeof fetchResponse.status === 'number'
? fetchResponse.status
: fetchResponse.ok
? 200
: 500,
headers,
json,
text,
};
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
const response = await requestUrl(requestParams);
return {
status: response.status,
headers: response.headers,
json: response.json as unknown,
text: response.text,
};
}
private parseResponse(json: unknown, processingTime: number): WhisperResponse {

View file

@ -8,8 +8,10 @@ import { MetricsTracker } from './MetricsTracker';
export class ProviderSelector {
private roundRobinIndex = 0;
private readonly strategies: Map<SelectionStrategy, SelectionFunction>;
private readonly defaultStrategyFn: SelectionFunction;
constructor(private readonly logger: ILogger, private readonly metricsTracker: MetricsTracker) {
this.defaultStrategyFn = this.defaultStrategy.bind(this);
this.strategies = this.initializeStrategies();
}
@ -27,7 +29,7 @@ export class ProviderSelector {
throw new Error('No transcription providers available');
}
const strategyFunction = this.strategies.get(strategy) ?? this.defaultStrategy;
const strategyFunction = this.strategies.get(strategy) ?? this.defaultStrategyFn;
return strategyFunction(availableProviders, context);
}
@ -41,7 +43,7 @@ export class ProviderSelector {
[SelectionStrategy.QUALITY_OPTIMIZED, this.selectByQuality.bind(this)],
[SelectionStrategy.ROUND_ROBIN, this.selectRoundRobin.bind(this)],
[SelectionStrategy.AB_TEST, this.selectForABTest.bind(this)],
[SelectionStrategy.MANUAL, this.defaultStrategy.bind(this)],
[SelectionStrategy.MANUAL, this.defaultStrategyFn],
]);
}

View file

@ -66,18 +66,18 @@ export function createSingleton<T>(factory: () => T): () => T {
* Singleton
*/
export function SingletonDecorator<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- required by TS mixin constructor typing
T extends new (...args: any[]) => object
>(constructor: T): T {
let instance: InstanceType<T> | undefined;
return class extends constructor {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- required by TS mixin constructor typing
constructor(...args: any[]) {
if (instance) {
return instance;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- args are passed through to base constructor
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- required by TS mixin constructor typing
super(...args);
instance = this as InstanceType<T>;
}

View file

@ -1,8 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { App, Plugin } from 'obsidian';
import { DependencyContainer } from '../architecture/DependencyContainer';
@ -27,7 +22,9 @@ export class MockFactory {
workspace: {
getActiveViewOfType: jest.fn(),
openLinkText: jest.fn(),
onLayoutReady: jest.fn((callback) => callback()),
onLayoutReady: jest.fn((callback: () => void) => {
callback();
}),
layoutReady: true,
activeLeaf: null,
} as unknown as App['workspace'],

View file

@ -249,7 +249,7 @@ export class FormatOptionsModal extends Modal {
case 'heading':
new Setting(targetContainer)
.setName('Heading level')
.setDesc('Level of the heading (1-6)')
.setDesc('Heading level (1-6)')
.addDropdown((dropdown) => {
for (let i = 1; i <= 6; i++) {
dropdown.addOption(String(i), `H${i}`);

View file

@ -244,7 +244,7 @@ class MinimalSettingsTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName('Transcription settings').setHeading();
new Setting(containerEl).setName('Transcription').setHeading();
containerEl.createEl('p', {
text: 'Settings are temporarily unavailable. Please restart Obsidian if this persists.',
cls: 'settings-error-message',

View file

@ -4,10 +4,6 @@
* Toast, Modal, StatusBar, Sound .
*/
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import {
INotificationAPI,
NotificationOptions,

View file

@ -328,7 +328,7 @@ export class StatusMessageDisplay {
const header = createEl('div', { cls: 'status-message-display__header' });
const title = createEl('h3', {
text: this.getLocalizedText('Status Messages', '상태 메시지'),
text: this.getLocalizedText('Status messages', '상태 메시지'),
});
header.appendChild(title);

View file

@ -392,8 +392,8 @@ export class EnhancedSettingsTab extends PluginSettingTab {
.setDesc('Select the transcription service provider')
.addDropdown((dropdown) => {
dropdown
.addOption('openai', 'OpenAI whisper')
.addOption('azure', 'Azure speech services')
.addOption('openai', 'OpenAI Whisper')
.addOption('azure', 'Azure Speech Services')
.addOption('custom', 'Custom endpoint');
void this.settingsAPI.get('api').then((api) => {
@ -620,7 +620,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
// 샘플 레이트
new Setting(section)
.setName('Sample rate')
.setDesc('Audio sample rate in hz')
.setDesc('Audio sample rate in Hz')
.addDropdown((dropdown) => {
const rates = [8000, 16000, 22050, 44100, 48000];
rates.forEach((rate) => {
@ -707,7 +707,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
new Setting(cacheSection)
.setName('Max cache size')
.setDesc('Maximum cache size in mb')
.setDesc('Maximum cache size in MB')
.addSlider((slider) => {
slider
.setLimits(10, 500, 10)
@ -756,7 +756,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
new Setting(perfSection)
.setName('Chunk size')
.setDesc('File chunk size in mb')
.setDesc('File chunk size in MB')
.addSlider((slider) => {
slider
.setLimits(0.5, 10, 0.5)

View file

@ -152,7 +152,7 @@ export class SettingsTab extends PluginSettingTab {
dropdown
.addOption('auto', 'Auto (intelligent selection)')
.addOption('whisper', 'OpenAI whisper')
.addOption('whisper', 'OpenAI Whisper')
.addOption('deepgram', 'Deepgram')
.setValue(this.plugin.settings.provider || 'auto')
.onChange(async (value) => {
@ -366,7 +366,7 @@ export class SettingsTab extends PluginSettingTab {
private renderWhisperApiKey(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName('OpenAI API key')
.setDesc('Enter your OpenAI API key for whisper transcription')
.setDesc('Enter your OpenAI API key for Whisper transcription')
.addText((text) => {
text.setPlaceholder('sk-...')
.setValue(this.maskApiKey(this.plugin.settings.apiKey || ''))
@ -439,7 +439,7 @@ export class SettingsTab extends PluginSettingTab {
const descriptions = {
auto: '🤖 Intelligent selection between providers based on your configured strategy. Automatically chooses the best provider for each request.',
whisper:
'🎯 OpenAI whisper - high-quality transcription with support for multiple languages. Best for general-purpose transcription.',
'🎯 OpenAI Whisper - high-quality transcription with support for multiple languages. Best for general-purpose transcription.',
deepgram:
'⚡ Deepgram - fast, accurate transcription with advanced AI features. Best for real-time processing and speaker diarization.',
};
@ -524,7 +524,7 @@ export class SettingsTab extends PluginSettingTab {
if (provider === 'whisper' || provider === 'auto') {
new Setting(containerEl)
.setName('Whisper model')
.setDesc('Select the whisper model to use')
.setDesc('Select the Whisper model to use')
.addDropdown((dropdown) =>
dropdown
.addOption('whisper-1', 'Whisper v1 (default)')
@ -702,4 +702,4 @@ export class SettingsTab extends PluginSettingTab {
return key.substring(0, visibleStart) + masked + key.substring(key.length - visibleEnd);
}
}
/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison -- re-enable after SettingsTab type guards */

View file

@ -140,7 +140,7 @@ export class SettingsTabRefactored extends PluginSettingTab {
*
*/
private createGeneralSection(containerEl: HTMLElement): void {
const sectionEl = this.createSection(containerEl, 'General', '기본 동작 설정');
const sectionEl = this.createSection(containerEl, 'Basics', '기본 동작 설정');
const component = this.getComponent('generalSettings', GeneralSettingsWrapper);
component?.render(sectionEl);
}

View file

@ -23,7 +23,7 @@ export class SimpleSettingsTab extends PluginSettingTab {
containerEl.empty();
// 제목
new Setting(containerEl).setName('Transcription settings').setHeading();
new Setting(containerEl).setName('Transcription').setHeading();
// Provider 선택 드롭다운 - 가장 중요한 기능
new Setting(containerEl).setName('API configuration').setHeading();
@ -39,7 +39,7 @@ export class SimpleSettingsTab extends PluginSettingTab {
this.debug('Adding options to dropdown...');
dropdown
.addOption('auto', 'Auto (intelligent selection)')
.addOption('whisper', 'OpenAI whisper')
.addOption('whisper', 'OpenAI Whisper')
.addOption('deepgram', 'Deepgram')
.setValue(this.plugin.settings.provider || 'auto')
.onChange(async (value) => {
@ -62,7 +62,7 @@ export class SimpleSettingsTab extends PluginSettingTab {
if (provider === 'auto' || provider === 'whisper') {
new Setting(containerEl)
.setName('OpenAI API key')
.setDesc('Enter your OpenAI API key for whisper')
.setDesc('Enter your OpenAI API key for Whisper')
.addText((text) =>
text
.setPlaceholder('sk-...')
@ -93,7 +93,7 @@ export class SimpleSettingsTab extends PluginSettingTab {
if (provider === 'deepgram') {
new Setting(containerEl)
.setName('Deepgram model')
.setDesc('Select the deepgram model to use')
.setDesc('Select the Deepgram model to use')
.addDropdown((dropdown) =>
dropdown
.addOption('nova-2', 'Nova 2 (premium)')
@ -120,7 +120,7 @@ export class SimpleSettingsTab extends PluginSettingTab {
}
// 기본 설정들
new Setting(containerEl).setName('General settings').setHeading();
new Setting(containerEl).setName('Basics').setHeading();
new Setting(containerEl)
.setName('Language')

View file

@ -578,7 +578,7 @@ export class DeepgramSettings {
// Maximum chunk size
const chunkSizeSetting = new Setting(container)
.setName('Maximum chunk size')
.setDesc('Maximum size per chunk in mb (recommended: 50mb)')
.setDesc('Maximum size per chunk in MB (recommended: 50MB)')
.addSlider((slider) => {
slider
.setLimits(10, 100, 10)
@ -648,20 +648,22 @@ export class DeepgramSettings {
this.plugin.settings.monthlyBudget
);
if (!estimation) {
this.uiBuilder.updateCostDetails(this.estimatedCostEl.parentElement!, null);
const parentEl = this.estimatedCostEl.parentElement;
if (!parentEl) {
this.logger.warn('Cost estimation container not available');
return;
}
this.uiBuilder.updateCostDetails(
this.estimatedCostEl.parentElement!,
this.selectedModel,
estimation.monthly
);
if (!estimation) {
this.uiBuilder.updateCostDetails(parentEl, null);
return;
}
this.uiBuilder.updateCostDetails(parentEl, this.selectedModel, estimation.monthly);
if (estimation.exceeedsBudget && this.plugin.settings.monthlyBudget) {
this.uiBuilder.addBudgetWarning(
this.estimatedCostEl.parentElement!,
parentEl,
estimation.monthly,
this.plugin.settings.monthlyBudget
);

View file

@ -81,7 +81,7 @@ export class ProviderSettings {
.addDropdown((dropdown) => {
dropdown
.addOption('auto', '🤖 Automatic (recommended)')
.addOption('whisper', '🎯 OpenAI whisper')
.addOption('whisper', '🎯 OpenAI Whisper')
.addOption('deepgram', '🚀 Deepgram')
.setValue(this.currentProvider)
.onChange(async (value) => {
@ -306,7 +306,7 @@ export class ProviderSettings {
private renderABTestDetails(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName('Traffic split')
.setDesc('Percentage of requests to send to whisper vs deepgram')
.setDesc('Percentage of requests to send to Whisper vs Deepgram')
.addSlider((slider) => {
const currentSplit = this.plugin.settings.abTestSplit || 50;
@ -574,7 +574,7 @@ export class ProviderSettings {
private showProviderInfo(provider: string): void {
const info: Record<string, string> = {
auto: '🤖 System will automatically select the best provider based on performance and availability',
whisper: '🎯 OpenAI whisper - high accuracy, supports 50+ languages',
whisper: '🎯 OpenAI Whisper - high accuracy, supports 50+ languages',
deepgram: '🚀 Deepgram - fast real-time transcription with excellent accuracy',
};
@ -601,7 +601,7 @@ export class ProviderSettings {
*/
private getProviderDisplayName(provider: TranscriptionProvider): string {
const names: Record<TranscriptionProvider, string> = {
whisper: 'OpenAI whisper',
whisper: 'OpenAI Whisper',
deepgram: 'Deepgram',
};
return names[provider] || provider;

View file

@ -195,7 +195,7 @@ export class ProviderSettingsContainer {
.addDropdown((dropdown) => {
dropdown
.addOption('auto', '🤖 Automatic (recommended)')
.addOption('whisper', '🎯 OpenAI whisper only')
.addOption('whisper', '🎯 OpenAI Whisper only')
.addOption('deepgram', '🚀 Deepgram only')
.setValue(this.currentProvider)
.onChange(async (value) => {
@ -358,7 +358,7 @@ export class ProviderSettingsContainer {
*/
private getProviderDisplayName(provider: string): string {
const names: Record<string, string> = {
whisper: 'OpenAI whisper',
whisper: 'OpenAI Whisper',
deepgram: 'Deepgram',
auto: 'Automatic',
};
@ -400,7 +400,7 @@ export class ProviderSettingsContainer {
private showProviderNotice(provider: string): void {
const messages: Record<string, string> = {
auto: '🤖 System will automatically select the best provider',
whisper: '🎯 Using OpenAI whisper exclusively',
whisper: '🎯 Using OpenAI Whisper exclusively',
deepgram: '🚀 Using Deepgram exclusively',
};
new Notice(messages[provider] || 'Provider updated');
@ -427,7 +427,7 @@ export class ProviderSettingsContainer {
],
},
{
title: '🎯 OpenAI whisper',
title: '🎯 OpenAI Whisper',
bullets: [
'Excellent accuracy for 50+ languages',
'Best for long-form content',
@ -776,7 +776,7 @@ class ProviderDetailsModal extends Modal {
}
private getProviderName(): string {
return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram';
}
private renderStatus(containerEl: HTMLElement): void {
@ -785,8 +785,8 @@ class ProviderDetailsModal extends Modal {
// TODO: 실제 상태 가져오기
const statusItems = [
{ label: 'Connection', value: '✅ Connected', cls: 'status-good' },
{ label: 'API Key', value: '🔑 Configured', cls: 'status-good' },
{ label: 'Last Used', value: '5 minutes ago', cls: 'status-info' },
{ label: 'API key', value: '🔑 Configured', cls: 'status-good' },
{ label: 'Last used', value: '5 minutes ago', cls: 'status-info' },
{ label: 'Health', value: '100%', cls: 'status-good' },
];
@ -803,10 +803,10 @@ class ProviderDetailsModal extends Modal {
// TODO: 실제 통계 가져오기
const stats = [
{ label: 'Total Requests', value: '1,234' },
{ label: 'Success Rate', value: '99.5%' },
{ label: 'Avg. Latency', value: '1.2s' },
{ label: 'Total Cost', value: '$12.34' },
{ label: 'Total requests', value: '1,234' },
{ label: 'Success rate', value: '99.5%' },
{ label: 'Avg. latency', value: '1.2s' },
{ label: 'Total cost', value: '$12.34' },
];
const gridEl = containerEl.createDiv({ cls: 'stats-grid' });

View file

@ -287,7 +287,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
).addDropdown((dropdown) => {
dropdown
.addOption('auto', '🤖 Automatic')
.addOption('whisper', '🎯 OpenAI whisper')
.addOption('whisper', '🎯 OpenAI Whisper')
.addOption('deepgram', '🚀 Deepgram')
.setValue(this.state.get().currentProvider)
.onChange((value) => {
@ -567,7 +567,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
*/
private getProviderDisplayName(provider: string): string {
const names: Record<string, string> = {
whisper: 'OpenAI whisper',
whisper: 'OpenAI Whisper',
deepgram: 'Deepgram',
auto: '자동',
};
@ -828,7 +828,7 @@ class ProviderDetailsModal extends Modal {
}
private getProviderName(): string {
return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram';
}
private createStatusContent(): HTMLElement {

View file

@ -684,7 +684,7 @@ export class APIKeyManager {
* Provider
*/
private getProviderName(provider: TranscriptionProvider): string {
return provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
return provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram';
}
/**

View file

@ -66,7 +66,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
[
'whisper',
{
name: 'OpenAI whisper',
name: 'OpenAI Whisper',
placeholder: 'sk-...',
pattern: /^sk-[A-Za-z0-9]{48,}$/,
validateEndpoint: 'https://api.openai.com/v1/models',

View file

@ -9,7 +9,7 @@
* 5.
*/
import { App } from 'obsidian';
import { App, requestUrl } from 'obsidian';
import { TranscriptionService } from '../../src/core/transcription/TranscriptionService';
import { WhisperService } from '../../src/infrastructure/api/WhisperService';
import { ErrorHandler } from '../../src/utils/ErrorHandler';
@ -107,14 +107,14 @@ describe('E2E: 에러 처리 시나리오', () => {
const maxRetries = settings.retryAttempts;
// 네트워크 에러 시뮬레이션
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
attemptCount++;
if (attemptCount <= 2) {
return Promise.reject(new Error('Network request failed'));
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('변환된 텍스트'),
status: 200,
text: '변환된 텍스트',
});
});
@ -135,10 +135,13 @@ describe('E2E: 에러 처리 시나리오', () => {
test('타임아웃 처리', async () => {
// 타임아웃 시뮬레이션
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
return new Promise((resolve) => {
// 타임아웃보다 긴 시간 대기
setTimeout(resolve, settings.timeout + 5000);
(requestUrl as jest.Mock).mockImplementation(() => {
return new Promise((_, reject) => {
// 타임아웃 이후에 실패하도록 지연
setTimeout(
() => reject(new Error('Request exceeded timeout')),
settings.timeout + 50
);
});
});
@ -166,9 +169,7 @@ describe('E2E: 에러 처리 시나리오', () => {
test('CORS 에러 처리', async () => {
// CORS 에러 시뮬레이션
(global.fetch as jest.Mock) = jest
.fn()
.mockRejectedValue(new TypeError('Failed to fetch'));
(requestUrl as jest.Mock).mockRejectedValue(new TypeError('Failed to fetch'));
const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' });
@ -188,17 +189,14 @@ describe('E2E: 에러 처리 시나리오', () => {
describe('API 에러 응답 처리', () => {
test('401 Unauthorized - API 키 에러', async () => {
(global.fetch as jest.Mock) = jest.fn().mockResolvedValue({
ok: false,
(requestUrl as jest.Mock).mockResolvedValue({
status: 401,
statusText: 'Unauthorized',
json: () =>
Promise.resolve({
error: {
message: 'Invalid API key provided',
type: 'invalid_request_error',
},
}),
json: {
error: {
message: 'Invalid API key provided',
type: 'invalid_request_error',
},
},
});
const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' });
@ -220,28 +218,23 @@ describe('E2E: 에러 처리 시나리오', () => {
let requestCount = 0;
const retryAfter = 60; // 60초 후 재시도
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
requestCount++;
if (requestCount === 1) {
return Promise.resolve({
ok: false,
status: 429,
statusText: 'Too Many Requests',
headers: new Headers({
'Retry-After': retryAfter.toString(),
}),
json: () =>
Promise.resolve({
error: {
message: 'Rate limit exceeded',
type: 'rate_limit_error',
},
}),
headers: { 'retry-after': retryAfter.toString() },
json: {
error: {
message: 'Rate limit exceeded',
type: 'rate_limit_error',
},
},
});
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('변환된 텍스트'),
status: 200,
text: '변환된 텍스트',
});
});
@ -263,17 +256,14 @@ describe('E2E: 에러 처리 시나리오', () => {
});
test('413 Payload Too Large - 파일 크기 초과', async () => {
(global.fetch as jest.Mock) = jest.fn().mockResolvedValue({
ok: false,
(requestUrl as jest.Mock).mockResolvedValue({
status: 413,
statusText: 'Payload Too Large',
json: () =>
Promise.resolve({
error: {
message: 'Maximum file size exceeded',
type: 'invalid_request_error',
},
}),
json: {
error: {
message: 'Maximum file size exceeded',
type: 'invalid_request_error',
},
},
});
const largeFile = new File([new ArrayBuffer(30 * 1024 * 1024)], 'large.mp3', {
@ -296,25 +286,22 @@ describe('E2E: 에러 처리 시나리오', () => {
test('500 Internal Server Error - 서버 에러', async () => {
let attemptCount = 0;
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
attemptCount++;
if (attemptCount <= 2) {
return Promise.resolve({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () =>
Promise.resolve({
error: {
message: 'An error occurred during processing',
type: 'server_error',
},
}),
json: {
error: {
message: 'An error occurred during processing',
type: 'server_error',
},
},
});
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('변환된 텍스트'),
status: 200,
text: '변환된 텍스트',
});
});
@ -348,17 +335,14 @@ describe('E2E: 에러 처리 시나리오', () => {
});
test('손상된 오디오 파일', async () => {
(global.fetch as jest.Mock) = jest.fn().mockResolvedValue({
ok: false,
(requestUrl as jest.Mock).mockResolvedValue({
status: 400,
statusText: 'Bad Request',
json: () =>
Promise.resolve({
error: {
message: 'Invalid audio file',
type: 'invalid_request_error',
},
}),
json: {
error: {
message: 'Invalid audio file',
type: 'invalid_request_error',
},
},
});
const corruptedFile = new File(['corrupted data'], 'corrupted.mp3', {
@ -399,14 +383,14 @@ describe('E2E: 에러 처리 시나리오', () => {
const delays: number[] = [];
let attemptCount = 0;
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
return Promise.reject(new Error('Temporary failure'));
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('변환된 텍스트'),
status: 200,
text: '변환된 텍스트',
});
});
@ -434,15 +418,15 @@ describe('E2E: 에러 처리 시나리오', () => {
];
let callCount = 0;
(global.fetch as jest.Mock) = jest.fn().mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 2) {
// 두 번째 파일만 실패
return Promise.reject(new Error('Processing failed'));
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve(`변환된 텍스트 ${callCount}`),
status: 200,
text: `변환된 텍스트 ${callCount}`,
});
});
@ -474,7 +458,7 @@ describe('E2E: 에러 처리 시나리오', () => {
// 에러 리포터 등록
errorManager.setReporter(errorReportSpy);
(global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error('Critical error'));
(requestUrl as jest.Mock).mockRejectedValue(new Error('Critical error'));
const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' });
@ -501,14 +485,14 @@ describe('E2E: 에러 처리 시나리오', () => {
const fallbackApiUrl = 'https://fallback-api.example.com/transcribe';
let apiCallCount = 0;
(global.fetch as jest.Mock) = jest.fn().mockImplementation((url) => {
(requestUrl as jest.Mock).mockImplementation((params: { url?: string }) => {
apiCallCount++;
if (url === mainApiUrl && apiCallCount === 1) {
if (params.url === mainApiUrl && apiCallCount === 1) {
return Promise.reject(new Error('Main API failed'));
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('폴백 API 결과'),
status: 200,
text: '폴백 API 결과',
});
});

View file

@ -10,7 +10,7 @@
* 6.
*/
import { App, Modal, Editor, Notice } from 'obsidian';
import { App, Modal, Editor, Notice, requestUrl } from 'obsidian';
import { FilePickerModal } from '../../src/ui/modals/FilePickerModal';
import { TranscriptionService } from '../../src/core/transcription/TranscriptionService';
import { EditorService } from '../../src/application/EditorService';
@ -90,10 +90,10 @@ describe('E2E: 파일 변환 플로우', () => {
transcriptionService = new TranscriptionService(settings);
// Mock API 응답
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ text: '변환된 텍스트' }),
text: jest.fn().mockResolvedValue('변환된 텍스트'),
(requestUrl as jest.Mock).mockResolvedValue({
status: 200,
json: { text: '변환된 텍스트' },
text: '변환된 텍스트',
});
});
@ -155,9 +155,9 @@ describe('E2E: 파일 변환 플로우', () => {
await filePickerModal.onChooseFile(mockFile);
// API 호출 검증
expect(global.fetch).toHaveBeenCalledWith(
settings.apiUrl,
expect(requestUrl).toHaveBeenCalledWith(
expect.objectContaining({
url: settings.apiUrl,
method: 'POST',
headers: expect.objectContaining({
Authorization: `Bearer ${settings.apiKey}`,
@ -206,7 +206,7 @@ describe('E2E: 파일 변환 플로우', () => {
await new Promise((resolve) => setTimeout(resolve, 100));
// 검증
expect(global.fetch).toHaveBeenCalled();
expect(requestUrl).toHaveBeenCalled();
document.body.removeChild(dropZone);
});
@ -222,11 +222,11 @@ describe('E2E: 파일 변환 플로우', () => {
const results: string[] = [];
// 각 파일에 대한 mock 응답 설정
(global.fetch as jest.Mock).mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
processedCount++;
return Promise.resolve({
ok: true,
text: () => Promise.resolve(`변환된 텍스트 ${processedCount}`),
status: 200,
text: `변환된 텍스트 ${processedCount}`,
});
});
@ -282,14 +282,14 @@ describe('E2E: 파일 변환 플로우', () => {
test('네트워크 에러 처리 및 재시도', async () => {
let attemptCount = 0;
(global.fetch as jest.Mock).mockImplementation(() => {
(requestUrl as jest.Mock).mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve('변환된 텍스트'),
status: 200,
text: '변환된 텍스트',
});
});
@ -301,7 +301,7 @@ describe('E2E: 파일 변환 플로우', () => {
});
test('타임아웃 처리', async () => {
(global.fetch as jest.Mock).mockImplementation(
(requestUrl as jest.Mock).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, settings.timeout + 1000);
@ -350,6 +350,7 @@ describe('E2E: 파일 변환 플로우', () => {
let isCancelled = false;
const abortController = new AbortController();
(requestUrl as jest.Mock).mockImplementation(() => new Promise(() => undefined));
// 변환 시작
const transcribePromise = transcriptionService.transcribe(mockFile, {

View file

@ -10,7 +10,7 @@
* 6.
*/
import { App, PluginSettingTab, Setting } from 'obsidian';
import { App, PluginSettingTab, Setting, requestUrl } from 'obsidian';
import { SettingsTab } from '../../src/ui/settings/SettingsTab';
import { SettingsManager } from '../../src/infrastructure/storage/SettingsManager';
import { SettingsValidator } from '../../src/infrastructure/api/SettingsValidator';
@ -472,9 +472,9 @@ describe('E2E: 설정 플로우', () => {
apiKeyInput.dispatchEvent(new Event('input'));
// Mock API 검증 성공
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ models: ['whisper-1'] }),
(requestUrl as jest.Mock).mockResolvedValue({
status: 200,
json: { data: [{ id: 'whisper-1' }] },
});
validateButton.click();

View file

@ -8,6 +8,7 @@
*/
import '@testing-library/jest-dom';
import { requestUrl } from 'obsidian';
import { TextEncoder, TextDecoder } from 'util';
// 전역 객체 설정
@ -105,8 +106,8 @@ global.clearInterval = ((id: ReturnType<typeof setInterval>) => {
return originalClearInterval(id);
}) as typeof clearInterval;
// Fetch API Mock
global.fetch = jest.fn();
// Obsidian requestUrl Mock
(requestUrl as jest.Mock).mockClear();
// LocalStorage Mock
const localStorageMock = {
@ -406,18 +407,16 @@ export const waitFor = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const mockApiResponse = (data: any, status = 200): void => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: status >= 200 && status < 300,
(requestUrl as jest.Mock).mockResolvedValueOnce({
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
json: data,
text: JSON.stringify(data),
headers: new Headers(),
});
};
export const mockApiError = (error: string, status = 500): void => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error(error));
(requestUrl as jest.Mock).mockRejectedValueOnce(new Error(error));
};
export const createMockFile = (name: string, size: number, type: string): File => {
@ -434,7 +433,7 @@ export const dispatchCustomEvent = (element: Element, eventName: string, detail?
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.clear();
(global.fetch as jest.Mock).mockClear();
(requestUrl as jest.Mock).mockClear();
document.body.innerHTML = '';
});

View file

@ -6,6 +6,7 @@
import '../helpers/testSetup'; // 테스트 환경 설정 임포트
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { requestUrl } from 'obsidian';
import { WhisperService } from '../../src/infrastructure/api/WhisperService';
import { FileUploadManager } from '../../src/infrastructure/api/FileUploadManager';
import { NotificationManager } from '../../src/ui/notifications/NotificationManager';
@ -243,8 +244,8 @@ describe('Integration Tests', () => {
const mockLogger = new Logger('test') as jest.Mocked<Logger>;
const whisperService = new WhisperService('invalid-key', mockLogger);
// Mock fetch to simulate API error
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
// Mock requestUrl to simulate API error
(requestUrl as jest.Mock).mockRejectedValue(new Error('Network error'));
await expect(whisperService.transcribe(new ArrayBuffer(1000))).rejects.toThrow();