refactor: extract format helpers (sanitize, twitch-duration, date-pattern, merge-phase) + 24 tests
src/main/infra/format-helpers.ts. main.ts adapter for getMergeGroupPhaseText injects config.language. 210 unit tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd1db9b873
commit
35189f6776
56
src/main.ts
56
src/main.ts
@ -8,6 +8,12 @@ import { autoUpdater } from 'electron-updater';
|
|||||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||||
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
||||||
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
||||||
|
import {
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
formatTwitchDurationFromSeconds,
|
||||||
|
formatDateWithPattern,
|
||||||
|
getMergeGroupPhaseText as getMergeGroupPhaseTextCore,
|
||||||
|
} from './main/infra/format-helpers';
|
||||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
||||||
import type { DbHandle } from './main/infra/db';
|
import type { DbHandle } from './main/infra/db';
|
||||||
import {
|
import {
|
||||||
@ -86,14 +92,7 @@ type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
|||||||
type UpdateDownloadSource = 'auto' | 'manual';
|
type UpdateDownloadSource = 'auto' | 'manual';
|
||||||
|
|
||||||
function getMergeGroupPhaseText(phase: string): string {
|
function getMergeGroupPhaseText(phase: string): string {
|
||||||
const isEnglish = config.language === 'en';
|
return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de');
|
||||||
switch (phase) {
|
|
||||||
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
|
||||||
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
|
||||||
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
|
||||||
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
|
||||||
default: return phase;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -967,36 +966,6 @@ function releaseClaimedFilenamesForItem(itemId: string): void {
|
|||||||
itemClaimedFilenames.delete(itemId);
|
itemClaimedFilenames.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
|
||||||
const cleaned = (input || '')
|
|
||||||
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
|
||||||
.replace(/[\\/]/g, '_')
|
|
||||||
.trim();
|
|
||||||
return cleaned || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateWithPattern(date: Date, pattern: string): string {
|
|
||||||
const tokenMap: Record<string, string> = {
|
|
||||||
yyyy: date.getFullYear().toString(),
|
|
||||||
yy: date.getFullYear().toString().slice(-2),
|
|
||||||
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
||||||
M: (date.getMonth() + 1).toString(),
|
|
||||||
dd: date.getDate().toString().padStart(2, '0'),
|
|
||||||
d: date.getDate().toString(),
|
|
||||||
HH: date.getHours().toString().padStart(2, '0'),
|
|
||||||
H: date.getHours().toString(),
|
|
||||||
hh: date.getHours().toString().padStart(2, '0'),
|
|
||||||
h: date.getHours().toString(),
|
|
||||||
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
||||||
m: date.getMinutes().toString(),
|
|
||||||
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
||||||
s: date.getSeconds().toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
return pattern
|
|
||||||
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
||||||
.replace(/\\(.)/g, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
||||||
const safe = Math.max(0, Math.floor(totalSeconds));
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
||||||
@ -1714,17 +1683,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
|||||||
return await requestTwitchLogin();
|
return await requestTwitchLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
|
||||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
|
|
||||||
if (h > 0) return `${h}h${m}m${s}s`;
|
|
||||||
if (m > 0) return `${m}m${s}s`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
||||||
// 4xx (other than 408/429) are application errors and not retried.
|
// 4xx (other than 408/429) are application errors and not retried.
|
||||||
function isTransientAxiosError(err: unknown): boolean {
|
function isTransientAxiosError(err: unknown): boolean {
|
||||||
|
|||||||
103
src/main/infra/format-helpers.test.ts
Normal file
103
src/main/infra/format-helpers.test.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { test, expect, describe } from 'vitest';
|
||||||
|
import {
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
formatTwitchDurationFromSeconds,
|
||||||
|
formatDateWithPattern,
|
||||||
|
getMergeGroupPhaseText,
|
||||||
|
} from './format-helpers';
|
||||||
|
|
||||||
|
describe('sanitizeFilenamePart', () => {
|
||||||
|
test('replaces Windows-invalid chars with underscore', () => {
|
||||||
|
expect(sanitizeFilenamePart('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
|
||||||
|
});
|
||||||
|
test('replaces path separators', () => {
|
||||||
|
expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c');
|
||||||
|
});
|
||||||
|
test('strips control chars', () => {
|
||||||
|
expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c');
|
||||||
|
});
|
||||||
|
test('trims whitespace', () => {
|
||||||
|
expect(sanitizeFilenamePart(' hi ')).toBe('hi');
|
||||||
|
});
|
||||||
|
test('empty falls back to default', () => {
|
||||||
|
expect(sanitizeFilenamePart('')).toBe('unnamed');
|
||||||
|
});
|
||||||
|
test('custom fallback', () => {
|
||||||
|
expect(sanitizeFilenamePart('', 'FB')).toBe('FB');
|
||||||
|
});
|
||||||
|
test('only-invalid-chars falls back', () => {
|
||||||
|
expect(sanitizeFilenamePart('////').trim()).not.toBe('');
|
||||||
|
// '////' becomes '____' which is non-empty, so no fallback
|
||||||
|
expect(sanitizeFilenamePart('////')).toBe('____');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTwitchDurationFromSeconds', () => {
|
||||||
|
test('0 = 0s', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(0)).toBe('0s');
|
||||||
|
});
|
||||||
|
test('45 = 45s', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(45)).toBe('45s');
|
||||||
|
});
|
||||||
|
test('65 = 1m5s', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s');
|
||||||
|
});
|
||||||
|
test('3725 = 1h2m5s', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s');
|
||||||
|
});
|
||||||
|
test('3600 = 1h0m0s', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s');
|
||||||
|
});
|
||||||
|
test('negative clamped to 0', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(-5)).toBe('0s');
|
||||||
|
});
|
||||||
|
test('NaN clamped to 0', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s');
|
||||||
|
});
|
||||||
|
test('Infinity clamped to 0', () => {
|
||||||
|
expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateWithPattern', () => {
|
||||||
|
const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07
|
||||||
|
|
||||||
|
test('yyyy-MM-dd', () => {
|
||||||
|
expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11');
|
||||||
|
});
|
||||||
|
test('yy MM dd', () => {
|
||||||
|
expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11');
|
||||||
|
});
|
||||||
|
test('HH:mm:ss', () => {
|
||||||
|
expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07');
|
||||||
|
});
|
||||||
|
test('combined pattern', () => {
|
||||||
|
expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07');
|
||||||
|
});
|
||||||
|
test('backslashes are stripped after token substitution', () => {
|
||||||
|
// Note: \ does NOT escape the date-token (no negative-lookbehind in regex).
|
||||||
|
// It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'.
|
||||||
|
expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMergeGroupPhaseText', () => {
|
||||||
|
test('known DE phases', () => {
|
||||||
|
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
|
||||||
|
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
|
||||||
|
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
|
||||||
|
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
|
||||||
|
});
|
||||||
|
test('known EN phases', () => {
|
||||||
|
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
|
||||||
|
expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...');
|
||||||
|
expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part');
|
||||||
|
expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...');
|
||||||
|
});
|
||||||
|
test('unknown phase passes through', () => {
|
||||||
|
expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown');
|
||||||
|
});
|
||||||
|
test('unknown language falls back to DE', () => {
|
||||||
|
expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/main/infra/format-helpers.ts
Normal file
78
src/main/infra/format-helpers.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
|
||||||
|
|
||||||
|
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g;
|
||||||
|
const FILENAME_PATH_SEP_RE = /[\\/]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
|
||||||
|
* Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup
|
||||||
|
* nichts uebrig bleibt.
|
||||||
|
*/
|
||||||
|
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
||||||
|
const cleaned = (input || '')
|
||||||
|
.replace(FILENAME_INVALID_RE, '_')
|
||||||
|
.replace(FILENAME_PATH_SEP_RE, '_')
|
||||||
|
.trim();
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder
|
||||||
|
* NaN-Inputs werden auf 0 geclamt.
|
||||||
|
*/
|
||||||
|
export function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
||||||
|
const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0));
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
|
||||||
|
if (h > 0) return `${h}h${m}m${s}s`;
|
||||||
|
if (m > 0) return `${m}m${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h,
|
||||||
|
* mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal.
|
||||||
|
*/
|
||||||
|
export function formatDateWithPattern(date: Date, pattern: string): string {
|
||||||
|
const tokenMap: Record<string, string> = {
|
||||||
|
yyyy: date.getFullYear().toString(),
|
||||||
|
yy: date.getFullYear().toString().slice(-2),
|
||||||
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
M: (date.getMonth() + 1).toString(),
|
||||||
|
dd: date.getDate().toString().padStart(2, '0'),
|
||||||
|
d: date.getDate().toString(),
|
||||||
|
HH: date.getHours().toString().padStart(2, '0'),
|
||||||
|
H: date.getHours().toString(),
|
||||||
|
hh: date.getHours().toString().padStart(2, '0'),
|
||||||
|
h: date.getHours().toString(),
|
||||||
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
||||||
|
m: date.getMinutes().toString(),
|
||||||
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
||||||
|
s: date.getSeconds().toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
.replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token)
|
||||||
|
.replace(/\\(.)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MergeGroupLanguage = 'de' | 'en';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache
|
||||||
|
* wird vom Caller injiziert.
|
||||||
|
*/
|
||||||
|
export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string {
|
||||||
|
const isEnglish = language === 'en';
|
||||||
|
switch (phase) {
|
||||||
|
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
||||||
|
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
||||||
|
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
||||||
|
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
||||||
|
default: return phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user