mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
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:
parent
cff3aab185
commit
eefd7f136d
25 changed files with 225 additions and 316 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -71,6 +71,7 @@ $RECYCLE.BIN/
|
|||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
release_log.txt
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 결과',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue