v0.5.0 — Phase β-4 Media + Advanced Card-Types

Alle 7 Card-Types werden gerendert und können erstellt werden.
image-occlusion mit Touch-Drag-Mask-Editor (kein PencilKit — Server-
Schema erlaubt nur Rechtecke), audio-front mit AVAudioPlayer und
File-Picker.

- MediaUploadResponse-DTO, MaskRegion-Codable mit 0..1-Coordinates
- MaskRegions.parse/encode (1:1-Port aus cards-domain, Sortierung
  nach ID lexikographisch)
- CardFieldsBuilder.imageOcclusion mit stringified-JSON-mask_regions
  + audioFront
- CardsAPI.uploadMedia (Multipart, 25 MiB) + fetchMedia (streamed)
- MediaCache actor mit LRU 200 MB (contentModificationDate-Eviction)
- mediaCache Environment-Key
- RemoteImage + AudioPlayerButton SwiftUI-Views
- CardRenderer: imageOcclusion (Mask-Overlay über RemoteImage) +
  audioFront (AudioPlayerButton + back-Text auf Flip)
- MaskEditorView: Touch-Drag-Rechteck, Label-Edit, Delete
- CardEditorView erweitert: PhotosPicker für Image, fileImporter
  für Audio, Magic-Byte-MIME-Detection
- 6 neue Tests für MaskRegions (30 Total grün)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:35:36 +02:00
parent cf1160b270
commit 80eb3708b4
12 changed files with 923 additions and 44 deletions

View file

@ -0,0 +1,79 @@
import Foundation
import Testing
@testable import CardsNative
@Suite("MaskRegions")
struct MaskRegionsTests {
@Test("Parsed Liste sortiert nach ID lexikographisch")
func parseSortsByIdLexically() {
let json = """
[
{"id":"m003","x":0.1,"y":0.1,"w":0.2,"h":0.2,"label":"C"},
{"id":"m001","x":0,"y":0,"w":0.1,"h":0.1,"label":"A"},
{"id":"m002","x":0.5,"y":0.5,"w":0.3,"h":0.3}
]
"""
let regions = MaskRegions.parse(json)
#expect(regions.count == 3)
#expect(regions[0].id == "m001")
#expect(regions[1].id == "m002")
#expect(regions[2].id == "m003")
#expect(regions[2].label == "C")
#expect(regions[1].label == nil)
}
@Test("Bei Parse-Fehler → leere Liste")
func parseInvalidReturnsEmpty() {
#expect(MaskRegions.parse("[}").isEmpty)
#expect(MaskRegions.parse("{}").isEmpty)
#expect(MaskRegions.parse("").isEmpty)
}
@Test("region(forSubIndex:) mappt aufsteigend")
func subIndexLookup() {
let json = """
[{"id":"b","x":0,"y":0,"w":0.1,"h":0.1},
{"id":"a","x":0,"y":0,"w":0.2,"h":0.2}]
"""
#expect(MaskRegions.region(for: json, subIndex: 0)?.id == "a")
#expect(MaskRegions.region(for: json, subIndex: 1)?.id == "b")
#expect(MaskRegions.region(for: json, subIndex: 2) == nil)
}
@Test("Encode-Roundtrip")
func encodeRoundtrip() {
let original = [
MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"),
MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil),
]
let encoded = MaskRegions.encode(original)
let parsed = MaskRegions.parse(encoded)
#expect(parsed.count == 2)
#expect(parsed[0].id == "m1")
#expect(parsed[0].label == "test")
#expect(parsed[1].label == nil)
}
@Test("CardFieldsBuilder.imageOcclusion produziert korrekte Felder")
func builderImageOcclusion() {
let regions = [MaskRegion(id: "m1", x: 0, y: 0, w: 0.5, h: 0.5, label: "x")]
let fields = CardFieldsBuilder.imageOcclusion(
imageRef: "media_123",
regions: regions,
note: "Hinweis"
)
#expect(fields["image_ref"] == "media_123")
#expect(fields["note"] == "Hinweis")
let reparsed = MaskRegions.parse(fields["mask_regions"] ?? "")
#expect(reparsed.count == 1)
#expect(reparsed[0].id == "m1")
}
@Test("CardFieldsBuilder.audioFront produziert korrekte Felder")
func builderAudioFront() {
let fields = CardFieldsBuilder.audioFront(audioRef: "audio_456", back: "Antwort")
#expect(fields["audio_ref"] == "audio_456")
#expect(fields["back"] == "Antwort")
#expect(fields.count == 2)
}
}