Schritt 1 der Umsetzungsreihenfolge. Anker ist das Format-Segment (9x16/4x5), nicht die Position — dadurch bleiben Projektnamen und Sprachkürzel mit eigenem Bindestrich intakt (z.B. "Somos-Pendant" oder "EN-US"). Sucht das rechteste Vorkommen, verlangt mindestens ein Segment davor und danach, erkennt Finder-Duplikate und meldet Ablehnungen/Skips unterscheidbar statt still zu scheitern. 14 Tests inklusive der Bindestrich- und Groß-/Kleinschreibungs-Kantenfälle.main
parent
7dcbfc2ba0
commit
2b7839592b
@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// Ergebnis eines einzelnen Parse-Vorgangs. Trägt den Originalnamen mit,
|
||||
/// damit eine Fehlerliste in der UI ohne Zusatzkontext aufgebaut werden kann.
|
||||
public struct FileNameParseResult: Equatable {
|
||||
public let fileName: String
|
||||
public let outcome: Outcome
|
||||
|
||||
public enum Outcome: Equatable {
|
||||
case parsed(ParsedFileName)
|
||||
case skipped(SkipReason)
|
||||
case rejected(RejectionReason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Erfolgreich zerlegter Dateiname nach dem Schema
|
||||
/// `[Projekt]-[Format]-[Version]-[Sprache].[ext]`, wobei das Format-Segment
|
||||
/// (`9x16`) als Anker dient und nicht die Position.
|
||||
public struct ParsedFileName: Equatable {
|
||||
/// Wörtlich wie übergeben — wird nicht aus den Segmenten rekonstruiert,
|
||||
/// damit ein Rundtrip-Fehler (Groß-/Kleinschreibung, Sonderzeichen) nie
|
||||
/// den gespeicherten Originalnamen verfälscht.
|
||||
public let originalFileName: String
|
||||
public let projectName: String
|
||||
/// Das Format-Segment, wie es im Original vorgefunden wurde (z.B. "9X16").
|
||||
public let sourceFormatToken: String
|
||||
/// Alles nach dem Format-Segment, zeichengleich, z.B. "v3-DE" oder "v3-EN-US".
|
||||
public let remainder: String
|
||||
/// Ohne führenden Punkt, unverändert übernommen (Groß-/Kleinschreibung inklusive).
|
||||
public let fileExtension: String
|
||||
|
||||
/// Zieldateiname mit ausschließlich dem Format-Segment ersetzt (immer
|
||||
/// klein geschrieben als "4x5"). Alle anderen Segmente bleiben zeichengleich.
|
||||
public var targetFileName: String {
|
||||
let ext = fileExtension.isEmpty ? "" : ".\(fileExtension)"
|
||||
return "\(projectName)-\(FileNameParser.targetFormatToken)-\(remainder)\(ext)"
|
||||
}
|
||||
}
|
||||
|
||||
public enum SkipReason: Equatable {
|
||||
/// Format-Segment ist bereits "4x5" — nichts zu tun.
|
||||
case alreadyTargetFormat
|
||||
}
|
||||
|
||||
public enum RejectionReason: Equatable {
|
||||
/// Dateistamm endet auf ein Finder-Duplikat-Suffix wie " (1)".
|
||||
case finderDuplicate
|
||||
/// Kein nutzbares Format-Segment gefunden: weder "9x16" noch "4x5" als
|
||||
/// eigenständiges Segment mit mindestens einem Segment davor und danach.
|
||||
case formatSegmentMissing
|
||||
}
|
||||
|
||||
/// Zerlegt und baut Dateinamen nach der Namenskonvention. Komplett UI-frei
|
||||
/// und zustandslos — reine Zeichenketten rein, reine Zeichenketten (bzw.
|
||||
/// Ergebnistypen) raus.
|
||||
public enum FileNameParser {
|
||||
fileprivate static let sourceFormatToken = "9x16"
|
||||
fileprivate static let targetFormatToken = "4x5"
|
||||
|
||||
public static func parse(_ fileName: String) -> FileNameParseResult {
|
||||
let (stem, fileExtension) = splitExtension(fileName)
|
||||
|
||||
// Finder-Duplikat zuerst prüfen: ist eine Datei sowohl Duplikat als
|
||||
// auch strukturell kaputt, ist "falsche Kopie erwischt" die
|
||||
// handlungsleitende Meldung, nicht die technische Detailursache.
|
||||
if isFinderDuplicate(stem) {
|
||||
return FileNameParseResult(fileName: fileName, outcome: .rejected(.finderDuplicate))
|
||||
}
|
||||
|
||||
let segments = stem.components(separatedBy: "-")
|
||||
|
||||
if let index = lastIndex(ofSegmentMatching: sourceFormatToken, in: segments),
|
||||
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
|
||||
let parsed = ParsedFileName(
|
||||
originalFileName: fileName,
|
||||
projectName: segments[..<index].joined(separator: "-"),
|
||||
sourceFormatToken: segments[index],
|
||||
remainder: segments[(index + 1)...].joined(separator: "-"),
|
||||
fileExtension: fileExtension
|
||||
)
|
||||
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
|
||||
}
|
||||
|
||||
if let index = lastIndex(ofSegmentMatching: targetFormatToken, in: segments),
|
||||
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
|
||||
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
|
||||
}
|
||||
|
||||
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
|
||||
}
|
||||
|
||||
/// Sucht von rechts, damit bei mehrfachem Vorkommen (z.B. weil der
|
||||
/// Projektname selbst "9x16" enthält) das echte Format-Segment gewinnt —
|
||||
/// das direkt vor Version/Sprache.
|
||||
private static func lastIndex(ofSegmentMatching token: String, in segments: [String]) -> Int? {
|
||||
for index in stride(from: segments.count - 1, through: 0, by: -1) {
|
||||
if segments[index].caseInsensitiveCompare(token) == .orderedSame {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Mindestens ein Segment davor (Projekt) und mindestens eins danach
|
||||
/// (Rest). Bewusst NICHT auf genau ein Segment danach geprüft — sonst
|
||||
/// bricht ein mehrteiliges Sprachkürzel wie "EN-US".
|
||||
private static func hasProjectAndRemainder(atIndex index: Int, segmentCount: Int) -> Bool {
|
||||
index > 0 && index < segmentCount - 1
|
||||
}
|
||||
|
||||
private static func splitExtension(_ fileName: String) -> (stem: String, extension: String) {
|
||||
guard let dotIndex = fileName.lastIndex(of: ".") else {
|
||||
return (fileName, "")
|
||||
}
|
||||
let stem = String(fileName[fileName.startIndex..<dotIndex])
|
||||
let fileExtension = String(fileName[fileName.index(after: dotIndex)...])
|
||||
return (stem, fileExtension)
|
||||
}
|
||||
|
||||
/// Erkennt das von Finder angehängte Duplikat-Suffix, z.B. "Name (1)".
|
||||
private static func isFinderDuplicate(_ stem: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"\s\(\d+\)$"#) else { return false }
|
||||
let range = NSRange(stem.startIndex..<stem.endIndex, in: stem)
|
||||
return regex.firstMatch(in: stem, options: [], range: range) != nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import XCTest
|
||||
@testable import DropTwoCore
|
||||
|
||||
final class FileNameParserTests: XCTestCase {
|
||||
|
||||
// MARK: - Erfolgreiches Parsen
|
||||
|
||||
func testParsesStandardFileName() {
|
||||
let result = FileNameParser.parse("SOMOS-9x16-v3-DE.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.projectName, "SOMOS")
|
||||
XCTAssertEqual(parsed.sourceFormatToken, "9x16")
|
||||
XCTAssertEqual(parsed.remainder, "v3-DE")
|
||||
XCTAssertEqual(parsed.fileExtension, "mp4")
|
||||
XCTAssertEqual(parsed.originalFileName, "SOMOS-9x16-v3-DE.mp4")
|
||||
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.mp4")
|
||||
}
|
||||
|
||||
func testProjectNameWithHyphenIsPreserved() {
|
||||
let result = FileNameParser.parse("Somos-Pendant-9x16-v3-DE.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.projectName, "Somos-Pendant")
|
||||
XCTAssertEqual(parsed.targetFileName, "Somos-Pendant-4x5-v3-DE.mp4")
|
||||
}
|
||||
|
||||
func testLanguageSegmentWithHyphenIsPreserved() {
|
||||
let result = FileNameParser.parse("SOMOS-9x16-v3-EN-US.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.projectName, "SOMOS")
|
||||
XCTAssertEqual(parsed.remainder, "v3-EN-US")
|
||||
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-EN-US.mp4")
|
||||
}
|
||||
|
||||
func testCaseInsensitiveFormatTokenIsRecognized() {
|
||||
let result = FileNameParser.parse("somos-9X16-v3-de.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
// Sonst unveränderte Schreibweise bleibt erhalten.
|
||||
XCTAssertEqual(parsed.projectName, "somos")
|
||||
XCTAssertEqual(parsed.sourceFormatToken, "9X16")
|
||||
XCTAssertEqual(parsed.remainder, "v3-de")
|
||||
XCTAssertEqual(parsed.targetFileName, "somos-4x5-v3-de.mp4")
|
||||
}
|
||||
|
||||
func testFileExtensionIsPreservedVerbatim() {
|
||||
for ext in ["mp4", "mov", "mxf", "MOV"] {
|
||||
let fileName = "SOMOS-9x16-v3-DE.\(ext)"
|
||||
let result = FileNameParser.parse(fileName)
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed für \(fileName), bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.fileExtension, ext)
|
||||
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.\(ext)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skip
|
||||
|
||||
func testAlreadyTargetFormatIsSkipped() {
|
||||
let result = FileNameParser.parse("SOMOS-4x5-v3-DE.mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .skipped(.alreadyTargetFormat))
|
||||
}
|
||||
|
||||
// MARK: - Rejection
|
||||
|
||||
func testMissingFormatSegmentIsRejected() {
|
||||
let result = FileNameParser.parse("SOMOS-v3-DE.mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
|
||||
}
|
||||
|
||||
func testFinderDuplicateIsRejected() {
|
||||
let result = FileNameParser.parse("SOMOS-9x16-v3-DE (1).mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .rejected(.finderDuplicate))
|
||||
}
|
||||
|
||||
func testFinderDuplicateTakesPriorityOverMissingFormatSegment() {
|
||||
// Beide Aussagen wären technisch wahr; die handlungsleitende
|
||||
// Meldung (falsche Kopie erwischt) muss gewinnen.
|
||||
let result = FileNameParser.parse("SOMOS-v3-DE (1).mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .rejected(.finderDuplicate))
|
||||
}
|
||||
|
||||
// MARK: - a) Rechtestes Vorkommen gewinnt
|
||||
|
||||
func testRightmostFormatOccurrenceWins() {
|
||||
let result = FileNameParser.parse("Kampagne-9x16-Teaser-9x16-v3-DE.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.projectName, "Kampagne-9x16-Teaser")
|
||||
XCTAssertEqual(parsed.remainder, "v3-DE")
|
||||
XCTAssertEqual(parsed.targetFileName, "Kampagne-9x16-Teaser-4x5-v3-DE.mp4")
|
||||
}
|
||||
|
||||
func testRightmostAlreadyTargetFormatOccurrenceWins() {
|
||||
let result = FileNameParser.parse("Kampagne-4x5-Teaser-4x5-v3-DE.mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .skipped(.alreadyTargetFormat))
|
||||
}
|
||||
|
||||
// MARK: - b) Projekt und Rest müssen existieren
|
||||
|
||||
func testRejectsWhenNoProjectSegment() {
|
||||
let result = FileNameParser.parse("9x16-v3-DE.mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
|
||||
}
|
||||
|
||||
func testRejectsWhenNoRemainderSegment() {
|
||||
let result = FileNameParser.parse("SOMOS-9x16.mp4")
|
||||
|
||||
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
|
||||
}
|
||||
|
||||
// MARK: - c) Format-Segment in der Ausgabe immer klein geschrieben
|
||||
|
||||
func testTargetFormatTokenIsAlwaysLowercase() {
|
||||
let result = FileNameParser.parse("SOMOS-9X16-v3-DE.mp4")
|
||||
|
||||
guard case .parsed(let parsed) = result.outcome else {
|
||||
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
|
||||
}
|
||||
XCTAssertEqual(parsed.sourceFormatToken, "9X16")
|
||||
XCTAssertTrue(parsed.targetFileName.contains("-4x5-"))
|
||||
XCTAssertFalse(parsed.targetFileName.contains("4X16"))
|
||||
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.mp4")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue