mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
Attachments (Android B-M3f semantics ported verbatim): - HapiClient/Attachments/AttachmentPolicy — pure plan matrix (>4 MB recompressible image -> 2048 px JPEG q85 with .jpg rename, 50 MB hard reject, 192 MB image read cap, 512 px q80 previewUrl data-URL thumbs, data-URL parse/round-trip). - HapiClient/Attachments/ComposerAttachments — upload-on-pick tray over an AttachmentUploading seam (APIClient conforms): uploading/ready/failed chips, retained payload for retry, remove -> best-effort delete, mid-upload removal deletes the orphan on completion, consume() -> AttachmentMetadata with JPEG data-URL previewUrl, discardAllDetached + deinit orphan cleanup (Android onCleared analogue). - ChatInteractor: tray ownership, unsettled chips refuse the send with a notice, attachments-only sends post empty text, optimistic rows carry the metadata, appendDictatedText/postNotice/discardAttachments. - App: AttachmentPreparer (capped security-scoped reads, ImageIO downscale/encode with EXIF transform, HEIC-undecodable fallback), PhotosPicker multi (videos via FileRepresentation temp files), UIImagePickerController camera capture, fileImporter; composer chip row (thumb/spinner/tap-to-retry/remove) with attachment-aware send gating; user bubbles upgrade chips to off-main-decoded previewUrl thumbnails (web-sent attachments included). Dictation (Android B-M3ce port): - HapiProtocol/Models/VoiceApi — TranscriptionResponse, TranscriptionProvidersResponse, TranscriptionProviderInfo. - HapiClient/Endpoints/VoiceEndpoints — GET /api/voice/transcription/providers + multipart POST /api/voice/transcription (file/provider/mode/language, Android part order) over MultipartFormData; DictationTranscribing conformance. - HapiClient/Voice/DictationController — idle/starting/recording/ transcribing, transcribed/noProvider/error events, provider memoized (first standard-capable entry), appendTranscript port. - App: AVAudioRecorderDictation (m4a/AAC mono 44.1 kHz 96 kbps, session activate/deactivate), mic button + recording chip (elapsed + cancel), record-permission request via AVAudioApplication. Info.plist gains NSMicrophoneUsageDescription; the camera string now covers attachment capture (modern PhotosPicker needs no photo-library permission). Tests: policy matrix, tray over the real client with exact base64 upload bodies + gated in-flight scenarios, dictation controller suite with fake recorder/transport, voice endpoint request shapes, and the interactor attachment-send matrix transcribed from the Android VM tests (wire bodies byte-for-byte).
103 lines
5.1 KiB
Swift
103 lines
5.1 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import Testing
|
|
|
|
private let mb = 1024 * 1024
|
|
|
|
/// Pure compression/sizing decisions (A-M3f) — transcription of the Android
|
|
/// `AttachmentPolicyTest`; the preparer defers to these.
|
|
@Suite("AttachmentPolicy")
|
|
struct AttachmentPolicyTests {
|
|
|
|
// MARK: - plan(forMimeType:sizeBytes:)
|
|
|
|
@Test func smallFilesOfAnyTypeKeepTheirOriginalBytes() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/jpeg", sizeBytes: 3 * mb) == .keepOriginal)
|
|
#expect(AttachmentPolicy.plan(forMimeType: "application/pdf", sizeBytes: 20 * mb) == .keepOriginal)
|
|
#expect(AttachmentPolicy.plan(forMimeType: "text/plain", sizeBytes: 10) == .keepOriginal)
|
|
}
|
|
|
|
@Test func imagesOverThe4MBThresholdAreCompressed() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/jpeg", sizeBytes: 5 * mb) == .compressImage)
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/png", sizeBytes: 12 * mb) == .compressImage)
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/webp", sizeBytes: 4 * mb + 1) == .compressImage)
|
|
// Case-insensitive mime match.
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/JPEG", sizeBytes: 5 * mb) == .compressImage)
|
|
}
|
|
|
|
@Test func imagesAtExactlyTheThresholdStayOriginal() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/jpeg", sizeBytes: 4 * mb) == .keepOriginal)
|
|
}
|
|
|
|
@Test func oversizedRecompressibleImagesAreStillCompressedNotRejected() {
|
|
// Downscaling brings any camera photo far under the cap; the preparer
|
|
// re-checks the compressed size against the 50 MB contract.
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/jpeg", sizeBytes: 60 * mb) == .compressImage)
|
|
}
|
|
|
|
@Test func oversizedNonRecompressibleFilesAreRejected() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "application/zip", sizeBytes: 51 * mb) == .reject)
|
|
#expect(AttachmentPolicy.plan(forMimeType: "video/mp4", sizeBytes: 200 * mb) == .reject)
|
|
// GIF recompression would drop animation — over-cap GIFs reject.
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/gif", sizeBytes: 51 * mb) == .reject)
|
|
}
|
|
|
|
@Test func filesAtExactly50MBPass() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "application/zip", sizeBytes: 50 * mb) == .keepOriginal)
|
|
}
|
|
|
|
@Test func gifsUnderTheCapKeepOriginalBytesRegardlessOfSize() {
|
|
#expect(AttachmentPolicy.plan(forMimeType: "image/gif", sizeBytes: 30 * mb) == .keepOriginal)
|
|
}
|
|
|
|
@Test func readCapAllowsOversizedSourcesOnlyForRecompressibleImages() {
|
|
#expect(AttachmentPolicy.readCap(forMimeType: "image/jpeg") == AttachmentPolicy.maxImageSourceBytes)
|
|
#expect(AttachmentPolicy.readCap(forMimeType: "image/gif") == AttachmentPolicy.maxUploadBytes)
|
|
#expect(AttachmentPolicy.readCap(forMimeType: "video/mp4") == AttachmentPolicy.maxUploadBytes)
|
|
#expect(AttachmentPolicy.readCap(forMimeType: "application/pdf") == AttachmentPolicy.maxUploadBytes)
|
|
}
|
|
|
|
// MARK: - Scaling
|
|
|
|
@Test func scaledDimensionsClampsTheLongestEdgeAndPreservesAspect() {
|
|
#expect(AttachmentPolicy.scaledDimensions(width: 4096, height: 3072, maxDimension: 2048) == (2048, 1536))
|
|
#expect(AttachmentPolicy.scaledDimensions(width: 2048, height: 4096, maxDimension: 2048) == (1024, 2048))
|
|
// Under the cap: untouched.
|
|
#expect(AttachmentPolicy.scaledDimensions(width: 640, height: 480, maxDimension: 2048) == (640, 480))
|
|
// Degenerate aspect never collapses to zero.
|
|
#expect(AttachmentPolicy.scaledDimensions(width: 100_000, height: 10, maxDimension: 2048) == (2048, 1))
|
|
}
|
|
|
|
// MARK: - Filenames
|
|
|
|
@Test func compressedFilenameSwapsTheExtensionToJpg() {
|
|
#expect(AttachmentPolicy.compressedFilename("shot.png") == "shot.jpg")
|
|
#expect(AttachmentPolicy.compressedFilename("IMG_0001.HEIC") == "IMG_0001.jpg")
|
|
#expect(AttachmentPolicy.compressedFilename("photo.jpg") == "photo.jpg")
|
|
#expect(AttachmentPolicy.compressedFilename("archive.tar.png") == "archive.tar.jpg")
|
|
#expect(AttachmentPolicy.compressedFilename("noext") == "noext.jpg")
|
|
// A leading dot is a hidden-file name, not an extension.
|
|
#expect(AttachmentPolicy.compressedFilename(".hidden") == ".hidden.jpg")
|
|
}
|
|
|
|
// MARK: - Data URLs
|
|
|
|
@Test func dataUrlAndBytesFromDataUrlRoundTrip() {
|
|
let bytes = Data([1, 2, 3, 4, 5])
|
|
let url = AttachmentPolicy.dataUrl(mimeType: "image/jpeg", bytes: bytes)
|
|
#expect(url == "data:image/jpeg;base64,AQIDBAU=")
|
|
#expect(AttachmentPolicy.bytesFromDataUrl(url) == bytes)
|
|
}
|
|
|
|
@Test func webAuthoredDataUrlsParseRegardlessOfMime() {
|
|
#expect(AttachmentPolicy.base64FromDataUrl("data:image/png;base64,iVBORw0K") == "iVBORw0K")
|
|
}
|
|
|
|
@Test func nonDataUrlsAndMalformedPayloadsReturnNil() {
|
|
#expect(AttachmentPolicy.base64FromDataUrl("https://example.com/a.png") == nil)
|
|
#expect(AttachmentPolicy.base64FromDataUrl("data:image/png,plain") == nil)
|
|
#expect(AttachmentPolicy.base64FromDataUrl("data:image/png;base64,") == nil)
|
|
#expect(AttachmentPolicy.bytesFromDataUrl("data:image/png;base64,!!!not-base64!!!") == nil)
|
|
}
|
|
}
|