Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Attachments/AttachmentPolicyTests.swift
T
weishu 05d063cc33 feat(ios): composer attachments + dictation (A-M3f)
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).
2026-08-18 11:27:28 +08:00

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)
}
}