0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class AccountConfigurationTests: XCTestCase {
|
||||
func testValidAccountIsNormalized() throws {
|
||||
let account = AccountConfiguration(
|
||||
jid: " Alice@Example.ORG ",
|
||||
resource: "",
|
||||
manualHost: " xmpp.example.org ",
|
||||
manualPort: 5222
|
||||
)
|
||||
|
||||
let validated = try account.validated()
|
||||
|
||||
XCTAssertEqual(validated.normalizedJID, "alice@example.org")
|
||||
XCTAssertEqual(validated.domain, "example.org")
|
||||
XCTAssertEqual(validated.effectiveResource, "Luma")
|
||||
XCTAssertEqual(validated.manualHost, "xmpp.example.org")
|
||||
}
|
||||
|
||||
func testInvalidJIDIsRejected() {
|
||||
XCTAssertThrowsError(try AccountConfiguration(jid: "not-a-jid").validated())
|
||||
XCTAssertThrowsError(try AccountConfiguration(jid: "@example.org").validated())
|
||||
XCTAssertThrowsError(try AccountConfiguration(jid: "alice@").validated())
|
||||
}
|
||||
|
||||
func testInvalidPortIsRejected() {
|
||||
XCTAssertThrowsError(
|
||||
try AccountConfiguration(jid: "alice@example.org", manualPort: 70_000).validated()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ArchiveMessageBatchPolicyTests: XCTestCase {
|
||||
func testMamPagesStaySmallEnoughForInteractiveUI() {
|
||||
XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.pageSize, 32)
|
||||
XCTAssertGreaterThan(ArchiveMessageBatchPolicy.pageSize, 0)
|
||||
}
|
||||
|
||||
func testOnlyOneStanzaIsDecodedPerMainActorSlice() {
|
||||
XCTAssertEqual(ArchiveMessageBatchPolicy.decodeSliceSize, 1)
|
||||
XCTAssertGreaterThan(ArchiveMessageBatchPolicy.interSliceDelayNanoseconds, 0)
|
||||
}
|
||||
|
||||
func testInboxCanHoldAProtocolViolatingDoublePageWithoutGrowingForever() {
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
ArchiveMessageBatchPolicy.maximumBufferedStanzas,
|
||||
ArchiveMessageBatchPolicy.pageSize * 2
|
||||
)
|
||||
XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.maximumBufferedStanzas, 128)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ArchiveSyncCheckpointTests: XCTestCase {
|
||||
func testStoredCursorIsPreferredOverTimestampOverlap() {
|
||||
let checkpoint = ArchiveSyncCheckpoint(
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
cursor: " mam-42 "
|
||||
)
|
||||
|
||||
let position = ArchiveSyncCursorPolicy.requestPosition(
|
||||
checkpoint: checkpoint,
|
||||
resumeAfter: nil,
|
||||
overlap: 60
|
||||
)
|
||||
|
||||
XCTAssertEqual(position.after, "mam-42")
|
||||
XCTAssertNil(position.start)
|
||||
}
|
||||
|
||||
func testLegacyTimestampUsesBoundedOverlap() {
|
||||
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let position = ArchiveSyncCursorPolicy.requestPosition(
|
||||
checkpoint: ArchiveSyncCheckpoint(timestamp: timestamp),
|
||||
resumeAfter: nil,
|
||||
overlap: 60
|
||||
)
|
||||
|
||||
XCTAssertNil(position.after)
|
||||
XCTAssertEqual(position.start, timestamp.addingTimeInterval(-60))
|
||||
}
|
||||
|
||||
func testResumeCursorWinsOverDurableCursor() {
|
||||
let position = ArchiveSyncCursorPolicy.requestPosition(
|
||||
checkpoint: ArchiveSyncCheckpoint(
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
cursor: "saved"
|
||||
),
|
||||
resumeAfter: "page-in-flight",
|
||||
overlap: 60
|
||||
)
|
||||
|
||||
XCTAssertEqual(position.after, "page-in-flight")
|
||||
XCTAssertNil(position.start)
|
||||
}
|
||||
|
||||
func testCaughtUpCheckpointKeepsCursorAndAdvancesTimestamp() {
|
||||
let previous = ArchiveSyncCheckpoint(
|
||||
timestamp: Date(timeIntervalSince1970: 100),
|
||||
cursor: "known"
|
||||
)
|
||||
let queryStart = Date(timeIntervalSince1970: 200)
|
||||
|
||||
let resolved = ArchiveSyncCursorPolicy.resolvedCheckpoint(
|
||||
previous: previous,
|
||||
cursor: nil,
|
||||
highWatermark: nil,
|
||||
queryStartedAt: queryStart,
|
||||
caughtUp: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved.cursor, "known")
|
||||
XCTAssertEqual(resolved.timestamp, queryStart)
|
||||
}
|
||||
|
||||
func testSameTimestampStillAdvancesWhenCursorChanges() {
|
||||
let previous = ArchiveSyncCheckpoint(
|
||||
timestamp: Date(timeIntervalSince1970: 100),
|
||||
cursor: "old"
|
||||
)
|
||||
let resolved = ArchiveSyncCursorPolicy.resolvedCheckpoint(
|
||||
previous: previous,
|
||||
cursor: "new",
|
||||
highWatermark: previous.timestamp,
|
||||
queryStartedAt: Date(timeIntervalSince1970: 200),
|
||||
caughtUp: false
|
||||
)
|
||||
|
||||
XCTAssertTrue(resolved.advances(over: previous))
|
||||
XCTAssertEqual(resolved.cursor, "new")
|
||||
}
|
||||
|
||||
func testStoredCursorFallbackCanRunOnlyOnFirstPageAndOnlyOnce() {
|
||||
let checkpoint = ArchiveSyncCheckpoint(
|
||||
timestamp: Date(timeIntervalSince1970: 100),
|
||||
cursor: "stored"
|
||||
)
|
||||
|
||||
XCTAssertTrue(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor(
|
||||
requestedAfter: "stored",
|
||||
checkpoint: checkpoint,
|
||||
hasCompletedPage: false,
|
||||
fallbackAlreadyUsed: false
|
||||
))
|
||||
XCTAssertFalse(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor(
|
||||
requestedAfter: "stored",
|
||||
checkpoint: checkpoint,
|
||||
hasCompletedPage: true,
|
||||
fallbackAlreadyUsed: false
|
||||
))
|
||||
XCTAssertFalse(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor(
|
||||
requestedAfter: "stored",
|
||||
checkpoint: checkpoint,
|
||||
hasCompletedPage: false,
|
||||
fallbackAlreadyUsed: true
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ArchiveSyncPaginationTests: XCTestCase {
|
||||
func testCompleteResponseFinishesEvenWhenCursorExists() {
|
||||
var pagination = ArchiveSyncPagination()
|
||||
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: true,
|
||||
lastCursor: "server-last",
|
||||
requestedAfter: nil
|
||||
),
|
||||
.finished
|
||||
)
|
||||
}
|
||||
|
||||
func testIncompleteResponseContinuesWithNewCursor() {
|
||||
var pagination = ArchiveSyncPagination()
|
||||
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "page-1-last",
|
||||
requestedAfter: nil
|
||||
),
|
||||
.next("page-1-last")
|
||||
)
|
||||
}
|
||||
|
||||
func testRepeatedCursorStopsPaginationLoop() {
|
||||
var pagination = ArchiveSyncPagination()
|
||||
_ = pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "repeated",
|
||||
requestedAfter: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "repeated",
|
||||
requestedAfter: "repeated"
|
||||
),
|
||||
.invalidCursor
|
||||
)
|
||||
}
|
||||
|
||||
func testCursorCycleStopsPaginationLoop() {
|
||||
var pagination = ArchiveSyncPagination()
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "page-a",
|
||||
requestedAfter: nil
|
||||
),
|
||||
.next("page-a")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "page-b",
|
||||
requestedAfter: "page-a"
|
||||
),
|
||||
.next("page-b")
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: "page-a",
|
||||
requestedAfter: "page-b"
|
||||
),
|
||||
.invalidCursor
|
||||
)
|
||||
}
|
||||
|
||||
func testIncompleteResponseWithoutCursorDoesNotAdvanceCheckpoint() {
|
||||
var pagination = ArchiveSyncPagination()
|
||||
|
||||
XCTAssertEqual(
|
||||
pagination.decision(
|
||||
complete: false,
|
||||
lastCursor: nil,
|
||||
requestedAfter: nil
|
||||
),
|
||||
.invalidCursor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ArchiveSyncRecoveryPolicyTests: XCTestCase {
|
||||
func testIncrementalOverlapDoesNotReplaySeveralMinutesPerPass() {
|
||||
XCTAssertEqual(ArchiveSyncRecoveryPolicy.incrementalOverlap, 60)
|
||||
}
|
||||
|
||||
func testQueryTimeoutIsFinite() {
|
||||
XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds, 0)
|
||||
XCTAssertLessThanOrEqual(
|
||||
ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds,
|
||||
15_000_000_000
|
||||
)
|
||||
}
|
||||
|
||||
func testPageApplicationAlsoHasAFiniteWatchdog() {
|
||||
XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds, 0)
|
||||
XCTAssertLessThanOrEqual(
|
||||
ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds,
|
||||
ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds
|
||||
)
|
||||
}
|
||||
|
||||
func testFailedPagesHaveAtMostOneAutomaticRetry() {
|
||||
XCTAssertEqual(ArchiveSyncRecoveryPolicy.pageRetryLimit, 1)
|
||||
}
|
||||
|
||||
func testCaptureResumeDelayLetsCameraReleaseResources() {
|
||||
XCTAssertGreaterThan(
|
||||
ArchiveSyncRecoveryPolicy.resumeAfterCaptureDelayNanoseconds,
|
||||
ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ArchiveSyncWorkBudgetTests: XCTestCase {
|
||||
func testStopsAfterFinitePageBudget() {
|
||||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var budget = ArchiveSyncWorkBudget(startedAt: start)
|
||||
|
||||
for page in 1..<ArchiveSyncWorkBudget.maximumCompletedPages {
|
||||
XCTAssertFalse(
|
||||
budget.recordCompletedPage(at: start.addingTimeInterval(Double(page)))
|
||||
)
|
||||
}
|
||||
XCTAssertTrue(budget.recordCompletedPage(at: start.addingTimeInterval(6)))
|
||||
}
|
||||
|
||||
func testStopsAfterRuntimeBudgetEvenWithFewPages() {
|
||||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var budget = ArchiveSyncWorkBudget(startedAt: start)
|
||||
|
||||
XCTAssertTrue(budget.recordCompletedPage(
|
||||
at: start.addingTimeInterval(ArchiveSyncWorkBudget.maximumRuntime)
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class CallSDPOrderingTests: XCTestCase {
|
||||
func testAnswerKeepsOfferMediaOrder() {
|
||||
XCTAssertEqual(
|
||||
CallSDPOrdering.answerIndices(
|
||||
localOrder: ["audio", "video"],
|
||||
remoteOrder: ["audio", "video"]
|
||||
),
|
||||
[0, 1]
|
||||
)
|
||||
}
|
||||
|
||||
func testAnswerWithReversedJingleContentsIsReordered() {
|
||||
XCTAssertEqual(
|
||||
CallSDPOrdering.answerIndices(
|
||||
localOrder: ["audio", "video"],
|
||||
remoteOrder: ["video", "audio"]
|
||||
),
|
||||
[1, 0]
|
||||
)
|
||||
}
|
||||
|
||||
func testAnswerWithMissingOrDuplicateContentIsRejected() {
|
||||
XCTAssertNil(
|
||||
CallSDPOrdering.answerIndices(
|
||||
localOrder: ["audio", "video"],
|
||||
remoteOrder: ["audio"]
|
||||
)
|
||||
)
|
||||
XCTAssertNil(
|
||||
CallSDPOrdering.answerIndices(
|
||||
localOrder: ["audio", "video"],
|
||||
remoteOrder: ["audio", "audio"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testJingleCandidateIsNormalizedForWebRTC() {
|
||||
XCTAssertEqual(
|
||||
CallSDPOrdering.webRTCCandidateLine("a=candidate:1 1 UDP 1 192.0.2.1 5000 typ host"),
|
||||
"candidate:1 1 UDP 1 192.0.2.1 5000 typ host"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CallSDPOrdering.webRTCCandidateLine("candidate:2 1 UDP 1 192.0.2.2 5002 typ host"),
|
||||
"candidate:2 1 UDP 1 192.0.2.2 5002 typ host"
|
||||
)
|
||||
}
|
||||
|
||||
func testMartinParseInputPreservesFinalMSIDCharacter() {
|
||||
let raw = [
|
||||
"v=0",
|
||||
"m=video 9 UDP/TLS/RTP/SAVPF 96",
|
||||
"a=ssrc-group:FID 2408145738 2025569816",
|
||||
"a=ssrc:2408145738 msid:luma-stream luma-video-0435",
|
||||
"a=ssrc:2025569816 msid:luma-stream luma-video-0435"
|
||||
].joined(separator: "\r\n") + "\n"
|
||||
|
||||
let parseInput = CallSDPNormalization.martinParseInput(raw)
|
||||
|
||||
XCTAssertTrue(parseInput.hasSuffix("luma-video-0435\r\n"))
|
||||
XCTAssertTrue(String(parseInput.dropLast(2)).hasSuffix("luma-video-0435"))
|
||||
}
|
||||
|
||||
func testMartinParseInputUsesExactlyOneTrailingCRLF() {
|
||||
for suffix in ["", "\n", "\r\n", "\n\n"] {
|
||||
let parseInput = CallSDPNormalization.martinParseInput(
|
||||
"v=0\nm=audio 9 UDP/TLS/RTP/SAVPF 111\na=mid:0" + suffix
|
||||
)
|
||||
XCTAssertTrue(parseInput.hasSuffix("a=mid:0\r\n"))
|
||||
XCTAssertFalse(parseInput.hasSuffix("\r\n\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func testFIDMSIDsUseTheCompleteTrackIdentifier() {
|
||||
let repaired = CallSDPCompatibility.canonicalFIDMSIDs(
|
||||
groups: [
|
||||
.init(semantics: "FID", sources: ["primary", "rtx"])
|
||||
],
|
||||
msids: [
|
||||
"primary": "luma-stream luma-video-9FBECB58-45C5-445B-9F4E-0598594F4F2D",
|
||||
"rtx": "luma-stream luma-video-9FBECB58-45C5-445B-9F4E-0598594F4F2"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(repaired["primary"], repaired["rtx"])
|
||||
XCTAssertTrue(repaired["rtx"]?.hasSuffix("F4F2D") == true)
|
||||
}
|
||||
|
||||
func testFIDMSIDRepairAlsoHandlesMissingRTXMetadata() {
|
||||
let repaired = CallSDPCompatibility.canonicalFIDMSIDs(
|
||||
groups: [
|
||||
.init(semantics: "fid", sources: ["primary", "rtx"]),
|
||||
.init(semantics: "SIM", sources: ["sim-a", "sim-b"])
|
||||
],
|
||||
msids: [
|
||||
"primary": "stream video-track",
|
||||
"sim-a": "stream camera-a",
|
||||
"sim-b": "stream camera-b"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(repaired["rtx"], "stream video-track")
|
||||
XCTAssertEqual(repaired["sim-a"], "stream camera-a")
|
||||
XCTAssertEqual(repaired["sim-b"], "stream camera-b")
|
||||
}
|
||||
|
||||
func testCandidateDispatchWaitsForRemoteAnswerAndFullJID() {
|
||||
XCTAssertFalse(CallCandidateDispatchGate.canSend(
|
||||
initialDescriptionWasSignaled: true,
|
||||
hasLocalDescription: true,
|
||||
hasRemoteDescription: false,
|
||||
hasFullPeerJID: true
|
||||
))
|
||||
XCTAssertFalse(CallCandidateDispatchGate.canSend(
|
||||
initialDescriptionWasSignaled: true,
|
||||
hasLocalDescription: true,
|
||||
hasRemoteDescription: true,
|
||||
hasFullPeerJID: false
|
||||
))
|
||||
}
|
||||
|
||||
func testCandidateDispatchOpensAfterSessionAccept() {
|
||||
XCTAssertTrue(CallCandidateDispatchGate.canSend(
|
||||
initialDescriptionWasSignaled: true,
|
||||
hasLocalDescription: true,
|
||||
hasRemoteDescription: true,
|
||||
hasFullPeerJID: true
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class CallSnapshotTests: XCTestCase {
|
||||
func testIncomingVideoCallProjection() {
|
||||
let snapshot = CallSnapshot(
|
||||
id: UUID(),
|
||||
peerJID: "alice@example.org",
|
||||
direction: .incoming,
|
||||
media: [.audio, .video],
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
isMuted: false,
|
||||
isCameraEnabled: true,
|
||||
isSpeakerEnabled: true,
|
||||
hasLocalVideo: false,
|
||||
hasRemoteVideo: false
|
||||
)
|
||||
|
||||
XCTAssertTrue(snapshot.isVideoCall)
|
||||
XCTAssertTrue(snapshot.isIncomingRinging)
|
||||
}
|
||||
|
||||
func testConnectedAudioCallIsNotRinging() {
|
||||
let snapshot = CallSnapshot(
|
||||
id: UUID(),
|
||||
peerJID: "bob@example.org",
|
||||
direction: .outgoing,
|
||||
media: [.audio],
|
||||
phase: .connected,
|
||||
connectedAt: Date(),
|
||||
isMuted: true,
|
||||
isCameraEnabled: false,
|
||||
isSpeakerEnabled: false,
|
||||
hasLocalVideo: false,
|
||||
hasRemoteVideo: false
|
||||
)
|
||||
|
||||
XCTAssertFalse(snapshot.isVideoCall)
|
||||
XCTAssertFalse(snapshot.isIncomingRinging)
|
||||
}
|
||||
|
||||
func testConnectedCallAlwaysBecomesCompletedHistory() {
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .outgoing,
|
||||
phase: .connected,
|
||||
connectedAt: Date(),
|
||||
cause: .failed
|
||||
),
|
||||
.completed
|
||||
)
|
||||
}
|
||||
|
||||
func testDeclinedCallClassification() {
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .incoming,
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
cause: .localRejected
|
||||
),
|
||||
.declined
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .outgoing,
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
cause: .remoteRejected
|
||||
),
|
||||
.declined
|
||||
)
|
||||
}
|
||||
|
||||
func testMissedCancelledAndUnansweredClassification() {
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .incoming,
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
cause: .remoteCancelled
|
||||
),
|
||||
.missed
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .outgoing,
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
cause: .localEnded
|
||||
),
|
||||
.cancelled
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .outgoing,
|
||||
phase: .ringing,
|
||||
connectedAt: nil,
|
||||
cause: .timedOut
|
||||
),
|
||||
.unanswered
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CallHistoryPolicy.outcome(
|
||||
direction: .incoming,
|
||||
phase: .connecting,
|
||||
connectedAt: nil,
|
||||
cause: .remoteEnded
|
||||
),
|
||||
.failed
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ChatArchiveTests: XCTestCase {
|
||||
func testSnapshotRoundTrip() async throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let archive = ChatArchive(accountJID: jid)
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let message = ChatMessage(
|
||||
conversationID: conversation.id,
|
||||
senderJID: jid,
|
||||
body: "Encrypted hello",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
|
||||
try await archive.save(.init(
|
||||
conversations: [conversation],
|
||||
messages: [message],
|
||||
locallyDeletedMessageIDs: ["deleted-message-id"],
|
||||
rosterContactJIDs: [conversation.jid],
|
||||
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
lastSuccessfulMAMCursor: "mam-cursor-42"
|
||||
))
|
||||
let loaded = await archive.load()
|
||||
|
||||
XCTAssertEqual(loaded.conversations, [conversation])
|
||||
XCTAssertEqual(loaded.messages, [message])
|
||||
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
|
||||
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
|
||||
XCTAssertEqual(
|
||||
loaded.lastSuccessfulMAMSync,
|
||||
Date(timeIntervalSince1970: 1_700_000_000)
|
||||
)
|
||||
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
|
||||
XCTAssertEqual(loaded.schemaVersion, ChatArchive.Snapshot.currentSchemaVersion)
|
||||
try await archive.erase()
|
||||
}
|
||||
|
||||
func testLegacySnapshotDefaultsToSchemaOne() throws {
|
||||
let data = Data(
|
||||
#"{"conversations":[],"messages":[],"locallyDeletedMessageIDs":[]}"#.utf8
|
||||
)
|
||||
|
||||
let snapshot = try JSONDecoder().decode(ChatArchive.Snapshot.self, from: data)
|
||||
|
||||
XCTAssertEqual(snapshot.schemaVersion, 1)
|
||||
XCTAssertTrue(snapshot.rosterContactJIDs.isEmpty)
|
||||
XCTAssertNil(snapshot.lastSuccessfulMAMSync)
|
||||
XCTAssertNil(snapshot.lastSuccessfulMAMCursor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ChatScrollPositionPolicyTests: XCTestCase {
|
||||
func testBottomSentinelCrossesOnlyTheTelegramButtonThreshold() {
|
||||
XCTAssertTrue(ChatScrollPositionPolicy.isNearBottom(
|
||||
bottomY: 796,
|
||||
viewportHeight: 700
|
||||
))
|
||||
XCTAssertFalse(ChatScrollPositionPolicy.isNearBottom(
|
||||
bottomY: 797,
|
||||
viewportHeight: 700
|
||||
))
|
||||
}
|
||||
|
||||
func testInvalidFirstLayoutDoesNotFlashTheButton() {
|
||||
XCTAssertTrue(ChatScrollPositionPolicy.isNearBottom(
|
||||
bottomY: 0,
|
||||
viewportHeight: 0
|
||||
))
|
||||
}
|
||||
|
||||
func testNativeScrollDistanceCanHideAndRestoreButtonRepeatedly() {
|
||||
XCTAssertFalse(ChatScrollPositionPolicy.isNearBottom(distanceFromBottom: 180))
|
||||
XCTAssertTrue(ChatScrollPositionPolicy.isNearBottom(distanceFromBottom: 0))
|
||||
XCTAssertFalse(ChatScrollPositionPolicy.isNearBottom(distanceFromBottom: 220))
|
||||
XCTAssertTrue(ChatScrollPositionPolicy.isNearBottom(distanceFromBottom: -12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ChatTimelineEntryTests: XCTestCase {
|
||||
func testDayBoundariesArePrecomputedOnceForStableRows() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let first = ChatMessage(
|
||||
id: "first",
|
||||
conversationID: "friend@example.org",
|
||||
senderJID: "friend@example.org",
|
||||
body: "one",
|
||||
timestamp: Date(timeIntervalSince1970: 1_704_067_200),
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .plaintext
|
||||
)
|
||||
var sameDay = first
|
||||
sameDay.body = "two"
|
||||
sameDay.timestamp = first.timestamp.addingTimeInterval(60)
|
||||
let sameDayMessage = ChatMessage(
|
||||
id: "same-day",
|
||||
conversationID: sameDay.conversationID,
|
||||
senderJID: sameDay.senderJID,
|
||||
body: sameDay.body,
|
||||
timestamp: sameDay.timestamp,
|
||||
direction: sameDay.direction,
|
||||
delivery: sameDay.delivery,
|
||||
security: sameDay.security
|
||||
)
|
||||
let nextDay = ChatMessage(
|
||||
id: "next-day",
|
||||
conversationID: first.conversationID,
|
||||
senderJID: first.senderJID,
|
||||
body: "three",
|
||||
timestamp: first.timestamp.addingTimeInterval(86_400),
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .plaintext
|
||||
)
|
||||
|
||||
let entries = ChatTimelineEntry.make(
|
||||
from: [first, sameDayMessage, nextDay],
|
||||
calendar: calendar
|
||||
)
|
||||
|
||||
XCTAssertEqual(entries.map(\.id), ["first", "same-day", "next-day"])
|
||||
XCTAssertEqual(entries.map(\.startsNewDay), [true, false, true])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ChatTypingPolicyTests: XCTestCase {
|
||||
func testDirectChatUsesCompactTypingText() {
|
||||
XCTAssertEqual(
|
||||
ChatTypingPolicy.displayText(names: ["Alice"], isGroup: false),
|
||||
"печатает…"
|
||||
)
|
||||
}
|
||||
|
||||
func testGroupChatNamesOneOrTwoParticipants() {
|
||||
XCTAssertEqual(
|
||||
ChatTypingPolicy.displayText(names: ["Alice"], isGroup: true),
|
||||
"Alice печатает…"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ChatTypingPolicy.displayText(names: ["Bob", "Alice"], isGroup: true),
|
||||
"Alice и Bob печатают…"
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyTypingSetHasNoIndicator() {
|
||||
XCTAssertNil(ChatTypingPolicy.displayText(names: [], isGroup: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import CoreGraphics
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ComposerRecordingGestureTests: XCTestCase {
|
||||
func testShortMovementKeepsRecording() {
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.resolution(
|
||||
for: CGSize(width: -35, height: -24)
|
||||
),
|
||||
.continueRecording
|
||||
)
|
||||
}
|
||||
|
||||
func testHorizontalSwipeCancelsRecording() {
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.resolution(
|
||||
for: CGSize(width: -110, height: -18)
|
||||
),
|
||||
.cancel
|
||||
)
|
||||
}
|
||||
|
||||
func testVerticalSwipeLocksRecording() {
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.resolution(
|
||||
for: CGSize(width: -12, height: -95)
|
||||
),
|
||||
.lock
|
||||
)
|
||||
}
|
||||
|
||||
func testDominantDiagonalDirectionWins() {
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.resolution(
|
||||
for: CGSize(width: -105, height: -82)
|
||||
),
|
||||
.cancel
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.resolution(
|
||||
for: CGSize(width: -94, height: -105)
|
||||
),
|
||||
.lock
|
||||
)
|
||||
}
|
||||
|
||||
func testCancelHintOpacityUsesCGFloatMathAndReturnsDouble() {
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.cancelHintOpacity(for: .zero),
|
||||
1,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.cancelHintOpacity(
|
||||
for: CGSize(width: -75, height: 0)
|
||||
),
|
||||
0.5,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.cancelHintOpacity(
|
||||
for: CGSize(width: -200, height: 0)
|
||||
),
|
||||
0.28,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ComposerRecordingGesturePolicy.cancelHintOpacity(
|
||||
for: CGSize(width: 50, height: 0)
|
||||
),
|
||||
1,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
}
|
||||
|
||||
func testDelayedMainActorStillRecognizesPhysicalLongPress() {
|
||||
XCTAssertFalse(ComposerRecordingGesturePolicy.isLongPress(elapsed: 0.1))
|
||||
XCTAssertTrue(ComposerRecordingGesturePolicy.isLongPress(elapsed: 0.3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class CryptoKitAESGCMEngineTests: XCTestCase {
|
||||
func testRoundTrip() {
|
||||
let engine = CryptoKitAESGCMEngine()
|
||||
let key = Data(repeating: 0x31, count: 16)
|
||||
let iv = Data(repeating: 0x22, count: 12)
|
||||
let plaintext = Data("hello omemo".utf8)
|
||||
var ciphertext = Data()
|
||||
var tag = Data()
|
||||
|
||||
XCTAssertTrue(engine.encrypt(iv: iv, key: key, message: plaintext, output: &ciphertext, tag: &tag))
|
||||
|
||||
var decrypted = Data()
|
||||
XCTAssertTrue(engine.decrypt(iv: iv, key: key, encoded: ciphertext, auth: tag, output: &decrypted))
|
||||
XCTAssertEqual(decrypted, plaintext)
|
||||
}
|
||||
|
||||
func testModifiedTagFails() {
|
||||
let engine = CryptoKitAESGCMEngine()
|
||||
let key = Data(repeating: 0x31, count: 16)
|
||||
let iv = Data(repeating: 0x22, count: 12)
|
||||
var ciphertext = Data()
|
||||
var tag = Data()
|
||||
XCTAssertTrue(engine.encrypt(iv: iv, key: key, message: Data("secret".utf8), output: &ciphertext, tag: &tag))
|
||||
tag[0] ^= 0x01
|
||||
|
||||
var decrypted = Data()
|
||||
XCTAssertFalse(engine.decrypt(iv: iv, key: key, encoded: ciphertext, auth: tag, output: &decrypted))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class EncryptionPreferenceTests: XCTestCase {
|
||||
func testResolutionUsesGlobalDefaultAndPerChatOverride() {
|
||||
XCTAssertTrue(EncryptionPreference.inheritGlobal.resolved(globalEnabled: true))
|
||||
XCTAssertFalse(EncryptionPreference.inheritGlobal.resolved(globalEnabled: false))
|
||||
XCTAssertTrue(EncryptionPreference.enabled.resolved(globalEnabled: false))
|
||||
XCTAssertFalse(EncryptionPreference.disabled.resolved(globalEnabled: true))
|
||||
}
|
||||
|
||||
func testConversationWithoutEncryptionFieldStillDecodes() throws {
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let encoded = try JSONEncoder().encode(conversation)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "encryptionPreference")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
||||
|
||||
let decoded = try JSONDecoder().decode(Conversation.self, from: legacyData)
|
||||
|
||||
XCTAssertEqual(decoded.encryptionPreference, .inheritGlobal)
|
||||
XCTAssertEqual(decoded.jid, conversation.jid)
|
||||
}
|
||||
|
||||
func testGlobalSettingIsStoredPerAccount() throws {
|
||||
let suite = "LumaTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let preferences = AccountPreferences(defaults: defaults)
|
||||
|
||||
XCTAssertTrue(preferences.encryptionEnabled(for: "alice@example.org"))
|
||||
preferences.setEncryptionEnabled(false, for: "alice@example.org")
|
||||
|
||||
XCTAssertFalse(preferences.encryptionEnabled(for: "alice@example.org"))
|
||||
XCTAssertTrue(preferences.encryptionEnabled(for: "bob@example.org"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class GeoLocationTests: XCTestCase {
|
||||
func testGeoURIFormattingAndParsing() throws {
|
||||
let location = try XCTUnwrap(
|
||||
GeoLocation(latitude: 59.3293, longitude: 18.0686, uncertainty: 14.4)
|
||||
)
|
||||
|
||||
XCTAssertEqual(location.uriString, "geo:59.329300,18.068600;u=14")
|
||||
|
||||
let decoded = try XCTUnwrap(GeoLocation(uri: location.uriString))
|
||||
XCTAssertEqual(decoded.latitude, 59.3293, accuracy: 0.000_001)
|
||||
XCTAssertEqual(decoded.longitude, 18.0686, accuracy: 0.000_001)
|
||||
XCTAssertEqual(try XCTUnwrap(decoded.uncertainty), 14, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testGeoURIWithoutUncertainty() throws {
|
||||
let location = try XCTUnwrap(GeoLocation(uri: "geo:-33.8688,151.2093"))
|
||||
|
||||
XCTAssertEqual(location.latitude, -33.8688, accuracy: 0.000_001)
|
||||
XCTAssertEqual(location.longitude, 151.2093, accuracy: 0.000_001)
|
||||
XCTAssertNil(location.uncertainty)
|
||||
}
|
||||
|
||||
func testInvalidCoordinatesAreRejected() {
|
||||
XCTAssertNil(GeoLocation(latitude: 91, longitude: 0))
|
||||
XCTAssertNil(GeoLocation(latitude: 0, longitude: -181))
|
||||
XCTAssertNil(GeoLocation(uri: "https://maps.example.org/59,18"))
|
||||
XCTAssertNil(GeoLocation(uri: "geo:not-a-number,18"))
|
||||
XCTAssertNil(GeoLocation(uri: "geo:59,18;u=unknown"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class GroupConversationTests: XCTestCase {
|
||||
func testGroupConversationRoundTrips() throws {
|
||||
let conversation = Conversation(
|
||||
jid: "team@conference.example.org",
|
||||
displayName: "Команда",
|
||||
encryptionPreference: .disabled,
|
||||
kind: .group,
|
||||
groupNickname: "Alice",
|
||||
isGroupJoined: true,
|
||||
shouldAutojoin: true,
|
||||
occupantCount: 4,
|
||||
invitedBy: "bob@example.org"
|
||||
)
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Conversation.self,
|
||||
from: JSONEncoder().encode(conversation)
|
||||
)
|
||||
|
||||
XCTAssertTrue(decoded.isGroup)
|
||||
XCTAssertEqual(decoded.groupNickname, "Alice")
|
||||
XCTAssertEqual(decoded.occupantCount, 4)
|
||||
XCTAssertEqual(decoded.invitedBy, "bob@example.org")
|
||||
}
|
||||
|
||||
func testLegacyConversationDefaultsToDirect() throws {
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let encoded = try JSONEncoder().encode(conversation)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
for key in ["kind", "groupNickname", "isGroupJoined", "shouldAutojoin", "occupantCount", "invitedBy"] {
|
||||
object.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Conversation.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertFalse(decoded.isGroup)
|
||||
XCTAssertFalse(decoded.isGroupJoined)
|
||||
XCTAssertEqual(decoded.occupantCount, 0)
|
||||
}
|
||||
|
||||
func testNewGroupInheritsGlobalEncryptionByDefault() {
|
||||
let conversation = Conversation(
|
||||
jid: "team@conference.example.org",
|
||||
kind: .group
|
||||
)
|
||||
|
||||
XCTAssertEqual(conversation.encryptionPreference, .inheritGlobal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaFileIOTests: XCTestCase {
|
||||
func testStagedDraftSurvivesThePickerURLBeingRemoved() async throws {
|
||||
let sourceDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: sourceDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let source = sourceDirectory.appendingPathComponent("photo.jpg")
|
||||
let payload = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
try payload.write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: sourceDirectory) }
|
||||
|
||||
let staged = try await MediaFileIO().stageAttachmentDraft(from: source)
|
||||
defer { try? FileManager.default.removeItem(at: staged.url) }
|
||||
try FileManager.default.removeItem(at: source)
|
||||
|
||||
XCTAssertEqual(staged.filename, "photo.jpg")
|
||||
XCTAssertEqual(staged.byteCount, payload.count)
|
||||
XCTAssertEqual(try Data(contentsOf: staged.url), payload)
|
||||
}
|
||||
|
||||
func testEmptyPickerFileIsRejectedBeforePreviewOrUpload() async throws {
|
||||
let source = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("empty-\(UUID().uuidString).mov")
|
||||
try Data().write(to: source)
|
||||
defer { try? FileManager.default.removeItem(at: source) }
|
||||
|
||||
do {
|
||||
_ = try await MediaFileIO().stageAttachmentDraft(from: source)
|
||||
XCTFail("An empty media file must not become a sendable draft")
|
||||
} catch MediaFileIOError.emptyFile(_) {
|
||||
// Expected.
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaMetadataTests: XCTestCase {
|
||||
func testVoiceFilenameRoundTrip() throws {
|
||||
let filename = MediaMetadata.transportFilename(
|
||||
originalFilename: "voice-123.m4a",
|
||||
kind: .voice,
|
||||
duration: 12.345
|
||||
)
|
||||
|
||||
let inferred = MediaMetadata.infer(from: filename)
|
||||
|
||||
XCTAssertEqual(filename, "luma-voice-12345-voice-123.m4a")
|
||||
XCTAssertEqual(inferred.kind, .voice)
|
||||
XCTAssertEqual(try XCTUnwrap(inferred.duration), 12.345, accuracy: 0.001)
|
||||
XCTAssertNotNil(inferred.mimeType)
|
||||
}
|
||||
|
||||
func testVideoNoteFilenameRoundTrip() throws {
|
||||
let filename = MediaMetadata.transportFilename(
|
||||
originalFilename: "video-note.mov",
|
||||
kind: .videoNote,
|
||||
duration: 59.9
|
||||
)
|
||||
|
||||
let inferred = MediaMetadata.infer(from: filename)
|
||||
|
||||
XCTAssertEqual(inferred.kind, .videoNote)
|
||||
XCTAssertEqual(try XCTUnwrap(inferred.duration), 59.9, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testStandardExtensionsAreClassified() {
|
||||
XCTAssertEqual(MediaMetadata.infer(from: "image.jpg").kind, .photo)
|
||||
XCTAssertEqual(MediaMetadata.infer(from: "clip.mov").kind, .video)
|
||||
XCTAssertEqual(MediaMetadata.infer(from: "track.m4a").kind, .audio)
|
||||
XCTAssertEqual(MediaMetadata.infer(from: "archive.zip").kind, .attachment)
|
||||
}
|
||||
|
||||
func testEncryptedTransportNameKeepsOriginalExtension() {
|
||||
let photo = MediaMetadata.transportFilename(
|
||||
originalFilename: "holiday.jpg",
|
||||
kind: .photo,
|
||||
duration: nil
|
||||
)
|
||||
let voice = MediaMetadata.transportFilename(
|
||||
originalFilename: "voice.m4a",
|
||||
kind: .voice,
|
||||
duration: 2.5
|
||||
)
|
||||
|
||||
XCTAssertEqual(URL(fileURLWithPath: photo).pathExtension, "jpg")
|
||||
XCTAssertEqual(URL(fileURLWithPath: voice).pathExtension, "m4a")
|
||||
XCTAssertFalse(photo.hasSuffix(".aesgcm"))
|
||||
XCTAssertFalse(voice.hasSuffix(".aesgcm"))
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutMediaMetadataStillDecodes() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "old attachment",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo,
|
||||
kind: .attachment,
|
||||
remoteAttachmentURL: "aesgcm://upload.example.org/file.aesgcm#key",
|
||||
localFilename: "file.pdf"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "mimeType")
|
||||
object.removeValue(forKey: "duration")
|
||||
object.removeValue(forKey: "byteCount")
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertEqual(decoded.localFilename, "file.pdf")
|
||||
XCTAssertNil(decoded.mimeType)
|
||||
XCTAssertNil(decoded.duration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaPickerSelectionPolicyTests: XCTestCase {
|
||||
func testLivePhotoPrefersStillImageButRetainsVideoFallback() {
|
||||
XCTAssertEqual(
|
||||
MediaPickerSelectionPolicy.preferredOrder(
|
||||
supportsImage: true,
|
||||
supportsVideo: true
|
||||
),
|
||||
[.photo, .video]
|
||||
)
|
||||
}
|
||||
|
||||
func testVideoOnlyAssetPrefersMovieButRetainsPhotoFallback() {
|
||||
XCTAssertEqual(
|
||||
MediaPickerSelectionPolicy.preferredOrder(
|
||||
supportsImage: false,
|
||||
supportsVideo: true
|
||||
),
|
||||
[.video, .photo]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaSendActivityTrackerTests: XCTestCase {
|
||||
func testOverlappingOperationsDoNotRestoreStaleBusyState() {
|
||||
var tracker = MediaSendActivityTracker()
|
||||
|
||||
let first = tracker.begin()
|
||||
let second = tracker.begin()
|
||||
XCTAssertTrue(tracker.isActive)
|
||||
|
||||
tracker.end(first)
|
||||
XCTAssertTrue(tracker.isActive)
|
||||
|
||||
tracker.end(second)
|
||||
XCTAssertFalse(tracker.isActive)
|
||||
}
|
||||
|
||||
func testEndingSameOperationTwiceIsSafe() {
|
||||
var tracker = MediaSendActivityTracker()
|
||||
let token = tracker.begin()
|
||||
|
||||
tracker.end(token)
|
||||
tracker.end(token)
|
||||
|
||||
XCTAssertFalse(tracker.isActive)
|
||||
XCTAssertTrue(tracker.activeTokens.isEmpty)
|
||||
}
|
||||
|
||||
func testResetRecoversFromEveryOutstandingOperation() {
|
||||
var tracker = MediaSendActivityTracker()
|
||||
_ = tracker.begin()
|
||||
_ = tracker.begin()
|
||||
|
||||
tracker.reset()
|
||||
|
||||
XCTAssertFalse(tracker.isActive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaTimeFormatterTests: XCTestCase {
|
||||
func testFormatsShortAndLongMediaDurations() {
|
||||
XCTAssertEqual(MediaTimeFormatter.string(0), "0:00")
|
||||
XCTAssertEqual(MediaTimeFormatter.string(65.9), "1:05")
|
||||
XCTAssertEqual(MediaTimeFormatter.string(3_661), "1:01:01")
|
||||
}
|
||||
|
||||
func testInvalidDurationFallsBackToZero() {
|
||||
XCTAssertEqual(MediaTimeFormatter.string(.infinity), "0:00")
|
||||
XCTAssertEqual(MediaTimeFormatter.string(-4), "0:00")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import CoreGraphics
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaViewerDismissGestureTests: XCTestCase {
|
||||
func testLongDownwardSwipeDismissesViewer() {
|
||||
XCTAssertTrue(
|
||||
MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: CGSize(width: 12, height: 130),
|
||||
predictedEndTranslation: CGSize(width: 14, height: 168),
|
||||
containerHeight: 800
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLongUpwardSwipeDismissesViewer() {
|
||||
XCTAssertTrue(
|
||||
MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: CGSize(width: -9, height: -130),
|
||||
predictedEndTranslation: CGSize(width: -12, height: -176),
|
||||
containerHeight: 800
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testFastShortVerticalFlickDismissesViewer() {
|
||||
XCTAssertTrue(
|
||||
MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: CGSize(width: 5, height: 54),
|
||||
predictedEndTranslation: CGSize(width: 9, height: 180),
|
||||
containerHeight: 800
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShortVerticalDragReturnsToViewer() {
|
||||
XCTAssertFalse(
|
||||
MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: CGSize(width: 6, height: 52),
|
||||
predictedEndTranslation: CGSize(width: 8, height: 84),
|
||||
containerHeight: 800
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testHorizontalMediaGestureDoesNotDismissViewer() {
|
||||
XCTAssertFalse(
|
||||
MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: CGSize(width: 190, height: 72),
|
||||
predictedEndTranslation: CGSize(width: 260, height: 110),
|
||||
containerHeight: 800
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageCorrectionTests: XCTestCase {
|
||||
func testOnlyDeliveredOrSentOutgoingTextCanBeEdited() {
|
||||
let editable = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "До исправления",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
var incoming = editable
|
||||
incoming.direction = .incoming
|
||||
var attachment = editable
|
||||
attachment.kind = .attachment
|
||||
var failed = editable
|
||||
failed.delivery = .failed
|
||||
var retracted = editable
|
||||
retracted.retractedAt = Date()
|
||||
|
||||
XCTAssertTrue(editable.canBeEdited)
|
||||
XCTAssertFalse(incoming.canBeEdited)
|
||||
XCTAssertFalse(attachment.canBeEdited)
|
||||
XCTAssertFalse(failed.canBeEdited)
|
||||
XCTAssertFalse(retracted.canBeEdited)
|
||||
}
|
||||
|
||||
func testEditedTimestampRoundTrips() throws {
|
||||
let editedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "После исправления",
|
||||
direction: .outgoing,
|
||||
delivery: .delivered,
|
||||
security: .plaintext,
|
||||
editedAt: editedAt
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.editedAt, editedAt)
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutEditedTimestampStillDecodes() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Старое сообщение",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "editedAt")
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertNil(decoded.editedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageInteractionTests: XCTestCase {
|
||||
func testReplyMetadataRoundTrips() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Ответ",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo,
|
||||
replyToID: "original-id",
|
||||
replyToJID: "bob@example.org",
|
||||
replyPreview: "Исходный текст"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.replyToID, "original-id")
|
||||
XCTAssertEqual(decoded.replyToJID, "bob@example.org")
|
||||
XCTAssertEqual(decoded.replyPreview, "Исходный текст")
|
||||
}
|
||||
|
||||
func testRetractedMessageCannotBeEditedRepliedOrForwarded() {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Удалено",
|
||||
direction: .outgoing,
|
||||
delivery: .delivered,
|
||||
security: .omemo,
|
||||
retractedAt: Date()
|
||||
)
|
||||
|
||||
XCTAssertFalse(message.canBeEdited)
|
||||
XCTAssertFalse(message.canBeRepliedTo)
|
||||
XCTAssertFalse(message.canBeForwarded)
|
||||
XCTAssertFalse(message.canBeRetracted)
|
||||
XCTAssertEqual(message.previewText, "🚫 Сообщение удалено")
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutInteractionFieldsStillDecodes() throws {
|
||||
let original = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Старый архив",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(original)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
for key in [
|
||||
"replyToID", "replyToJID", "replyPreview", "forwardedFrom", "retractedAt",
|
||||
"stanzaID", "senderDisplayName", "isGroupMessage", "callHistory"
|
||||
] {
|
||||
object.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertNil(decoded.replyToID)
|
||||
XCTAssertNil(decoded.retractedAt)
|
||||
XCTAssertEqual(decoded.body, "Старый архив")
|
||||
XCTAssertFalse(decoded.isGroupMessage)
|
||||
}
|
||||
|
||||
func testGroupReplyRequiresRoomAssignedStanzaID() {
|
||||
var message = ChatMessage(
|
||||
conversationID: "team@conference.example.org",
|
||||
senderJID: "team@conference.example.org/alice",
|
||||
body: "Сообщение группы",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo,
|
||||
senderDisplayName: "Alice",
|
||||
isGroupMessage: true
|
||||
)
|
||||
|
||||
XCTAssertFalse(message.canBeRepliedTo)
|
||||
message.stanzaID = "room-stable-id"
|
||||
XCTAssertTrue(message.canBeRepliedTo)
|
||||
XCTAssertEqual(message.replyIdentifier, "room-stable-id")
|
||||
XCTAssertEqual(message.security, .omemo)
|
||||
XCTAssertFalse(message.canBeEdited)
|
||||
XCTAssertFalse(message.canBeRetracted)
|
||||
}
|
||||
|
||||
func testCompletedVideoCallHistoryRoundTripsWithDuration() throws {
|
||||
let metadata = CallHistoryMetadata(isVideo: true, outcome: .completed)
|
||||
let message = ChatMessage(
|
||||
id: "call-alice-123",
|
||||
conversationID: "alice@example.org",
|
||||
senderJID: "me@example.org",
|
||||
body: "Видеозвонок",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .plaintext,
|
||||
kind: .system,
|
||||
duration: 65,
|
||||
callHistory: metadata
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.callHistory, metadata)
|
||||
XCTAssertEqual(decoded.callTitle, "Исходящий видеозвонок")
|
||||
XCTAssertEqual(decoded.callSubtitle, "Длительность 1:05")
|
||||
XCTAssertEqual(decoded.previewText, "🎥 Исходящий видеозвонок")
|
||||
XCTAssertFalse(decoded.canBeRepliedTo)
|
||||
XCTAssertFalse(decoded.canBeForwarded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageReactionTests: XCTestCase {
|
||||
func testReactionUpdateKeepsOneCopyOfEachEmoji() {
|
||||
XCTAssertEqual(
|
||||
MessageReactionPolicy.sanitized(["👍", " 👍 ", "❤️", "a", "not emoji"]),
|
||||
["👍", "❤️"]
|
||||
)
|
||||
}
|
||||
|
||||
func testSummariesCountSendersAndMarkOwnReaction() {
|
||||
let reactions = [
|
||||
MessageReaction(senderJID: "me@example.org/device", emojis: ["👍", "❤️"]),
|
||||
MessageReaction(senderJID: "you@example.org", emojis: ["👍"])
|
||||
]
|
||||
let summaries = MessageReactionPolicy.summaries(
|
||||
reactions: reactions,
|
||||
ownJID: "me@example.org"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
summaries.first(where: { $0.emoji == "👍" }),
|
||||
MessageReactionSummary(emoji: "👍", count: 2, includesOwnReaction: true)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
summaries.first(where: { $0.emoji == "❤️" }),
|
||||
MessageReactionSummary(emoji: "❤️", count: 1, includesOwnReaction: true)
|
||||
)
|
||||
}
|
||||
|
||||
func testOlderArchivesDecodeWithoutReactionField() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "peer@example.org",
|
||||
senderJID: "peer@example.org",
|
||||
body: "hello",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .plaintext
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: encoded) as? [String: Any]
|
||||
)
|
||||
object.removeValue(forKey: "reactions")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
||||
XCTAssertEqual(try JSONDecoder().decode(ChatMessage.self, from: legacyData).reactions, [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageReplyFallbackTests: XCTestCase {
|
||||
func testXEP0428ScalarRangeRemovesUnicodeQuote() {
|
||||
let fallback = MessageReplyFallback.make(
|
||||
author: "алиса@example.org",
|
||||
preview: "Привет 👋\nКак дела?"
|
||||
)
|
||||
let body = fallback.prefix + "Отлично!"
|
||||
|
||||
let parsed = MessageReplyFallback.parse(
|
||||
body: body,
|
||||
fallbackStart: 0,
|
||||
fallbackEnd: fallback.scalarCount
|
||||
)
|
||||
|
||||
XCTAssertEqual(parsed.body, "Отлично!")
|
||||
XCTAssertEqual(parsed.preview, "Привет 👋\nКак дела?")
|
||||
XCTAssertEqual(fallback.scalarCount, fallback.prefix.unicodeScalars.count)
|
||||
}
|
||||
|
||||
func testLegacyConversationsQuoteBecomesReplyPreview() {
|
||||
let parsed = MessageReplyFallback.parse(
|
||||
body: "> Старое сообщение\n> в двух строках\n\nНовый ответ",
|
||||
fallbackStart: nil,
|
||||
fallbackEnd: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(parsed.body, "Новый ответ")
|
||||
XCTAssertEqual(parsed.preview, "Старое сообщение\nв двух строках")
|
||||
}
|
||||
|
||||
func testPlainBodyWithoutQuoteIsUnchanged() {
|
||||
let body = "Обычное сообщение"
|
||||
let parsed = MessageReplyFallback.parse(
|
||||
body: body,
|
||||
fallbackStart: nil,
|
||||
fallbackEnd: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(parsed.body, body)
|
||||
XCTAssertNil(parsed.preview)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import CoreGraphics
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageReplySwipeTests: XCTestCase {
|
||||
func testHorizontalSwipeInEitherDirectionReplies() {
|
||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: 70, height: 8),
|
||||
predictedEndTranslation: CGSize(width: 72, height: 9)
|
||||
))
|
||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: -70, height: 8),
|
||||
predictedEndTranslation: CGSize(width: -72, height: 9)
|
||||
))
|
||||
}
|
||||
|
||||
func testVerticalTimelineScrollDoesNotReply() {
|
||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: 42, height: 80),
|
||||
predictedEndTranslation: CGSize(width: 50, height: 105)
|
||||
))
|
||||
}
|
||||
|
||||
func testDiagonalTimelineScrollDoesNotReply() {
|
||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: 54, height: 44),
|
||||
predictedEndTranslation: CGSize(width: 76, height: 58)
|
||||
))
|
||||
XCTAssertEqual(
|
||||
MessageReplySwipePolicy.offset(for: CGSize(width: 54, height: 44)),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
func testShortSwipeDoesNotReply() {
|
||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: 36, height: 4),
|
||||
predictedEndTranslation: CGSize(width: 48, height: 5)
|
||||
))
|
||||
}
|
||||
|
||||
func testFastHorizontalFlickUsesPredictedEnd() {
|
||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||
translation: CGSize(width: -35, height: 3),
|
||||
predictedEndTranslation: CGSize(width: -90, height: 7)
|
||||
))
|
||||
}
|
||||
|
||||
func testVisualOffsetIsCapped() {
|
||||
XCTAssertEqual(
|
||||
MessageReplySwipePolicy.offset(for: CGSize(width: 500, height: 0)),
|
||||
MessageReplySwipePolicy.maximumOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class NotificationPolicyTests: XCTestCase {
|
||||
func testForegroundMessageFromAnotherConversationIsPresented() {
|
||||
XCTAssertTrue(NotificationPolicy.shouldPresentMessage(
|
||||
inserted: true,
|
||||
isOutgoing: false,
|
||||
appIsActive: true,
|
||||
selectedConversationID: "bob@example.org",
|
||||
conversationID: "alice@example.org"
|
||||
))
|
||||
}
|
||||
|
||||
func testVisibleConversationDoesNotPresentDuplicateBanner() {
|
||||
XCTAssertFalse(NotificationPolicy.shouldPresentMessage(
|
||||
inserted: true,
|
||||
isOutgoing: false,
|
||||
appIsActive: true,
|
||||
selectedConversationID: "alice@example.org",
|
||||
conversationID: "ALICE@example.org"
|
||||
))
|
||||
}
|
||||
|
||||
func testBackgroundIncomingMessageIsPresented() {
|
||||
XCTAssertTrue(NotificationPolicy.shouldPresentMessage(
|
||||
inserted: true,
|
||||
isOutgoing: false,
|
||||
appIsActive: false,
|
||||
selectedConversationID: "alice@example.org",
|
||||
conversationID: "alice@example.org"
|
||||
))
|
||||
}
|
||||
|
||||
func testOutgoingOrDuplicateMessageIsNotPresented() {
|
||||
XCTAssertFalse(NotificationPolicy.shouldPresentMessage(
|
||||
inserted: true,
|
||||
isOutgoing: true,
|
||||
appIsActive: true,
|
||||
selectedConversationID: nil,
|
||||
conversationID: "alice@example.org"
|
||||
))
|
||||
XCTAssertFalse(NotificationPolicy.shouldPresentMessage(
|
||||
inserted: false,
|
||||
isOutgoing: false,
|
||||
appIsActive: true,
|
||||
selectedConversationID: nil,
|
||||
conversationID: "alice@example.org"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class VideoNoteRecordingCompletionPolicyTests: XCTestCase {
|
||||
func testNilErrorKeepsRecording() {
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: nil))
|
||||
}
|
||||
|
||||
func testSuccessfulFinishedFlagKeepsRecordingDespiteError() {
|
||||
let error = NSError(
|
||||
domain: AVFoundationErrorDomain,
|
||||
code: -11_800,
|
||||
userInfo: [AVErrorRecordingSuccessfullyFinishedKey: true]
|
||||
)
|
||||
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error))
|
||||
}
|
||||
|
||||
func testFailedFinishedFlagRejectsRecording() {
|
||||
let error = NSError(
|
||||
domain: AVFoundationErrorDomain,
|
||||
code: -11_800,
|
||||
userInfo: [AVErrorRecordingSuccessfullyFinishedKey: false]
|
||||
)
|
||||
|
||||
XCTAssertFalse(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error))
|
||||
}
|
||||
|
||||
func testNSNumberSuccessfulFinishedFlagKeepsRecording() {
|
||||
let error = NSError(
|
||||
domain: AVFoundationErrorDomain,
|
||||
code: -11_800,
|
||||
userInfo: [AVErrorRecordingSuccessfullyFinishedKey: NSNumber(value: true)]
|
||||
)
|
||||
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error))
|
||||
}
|
||||
|
||||
func testMaximumDurationStillKeepsRecording() {
|
||||
let error = NSError(
|
||||
domain: AVFoundationErrorDomain,
|
||||
code: AVError.Code.maximumDurationReached.rawValue
|
||||
)
|
||||
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error))
|
||||
}
|
||||
|
||||
func testExplicitStopAllowsCompletedRecordingToSend() {
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: true,
|
||||
error: nil,
|
||||
recordedDuration: 3,
|
||||
maximumDuration: 60
|
||||
))
|
||||
}
|
||||
|
||||
func testUnexpectedCameraCompletionDoesNotSendPartialRecording() {
|
||||
XCTAssertFalse(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: false,
|
||||
error: nil,
|
||||
recordedDuration: 3,
|
||||
maximumDuration: 60
|
||||
))
|
||||
}
|
||||
|
||||
func testAutomaticMaximumDurationCompletionMaySend() {
|
||||
let error = NSError(
|
||||
domain: AVFoundationErrorDomain,
|
||||
code: AVError.Code.maximumDurationReached.rawValue
|
||||
)
|
||||
|
||||
XCTAssertTrue(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: false,
|
||||
error: error,
|
||||
recordedDuration: 59.8,
|
||||
maximumDuration: 60
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class VideoNoteRecordingLifecycleTests: XCTestCase {
|
||||
func testOnlyBusyPhasesBlockASecondRecording() {
|
||||
XCTAssertFalse(VideoNoteRecordingLifecycle.idle.isBusy)
|
||||
XCTAssertFalse(VideoNoteRecordingLifecycle.prepared.isBusy)
|
||||
XCTAssertTrue(VideoNoteRecordingLifecycle.starting.isBusy)
|
||||
XCTAssertTrue(VideoNoteRecordingLifecycle.recording.isBusy)
|
||||
XCTAssertTrue(VideoNoteRecordingLifecycle.stopping.isBusy)
|
||||
}
|
||||
|
||||
func testStaleCompletionCannotFinishANewerRecording() {
|
||||
let oldURL = URL(fileURLWithPath: "/tmp/old.mov")
|
||||
let currentURL = URL(fileURLWithPath: "/tmp/current.mov")
|
||||
|
||||
XCTAssertFalse(VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: currentURL,
|
||||
outputURL: oldURL
|
||||
))
|
||||
XCTAssertTrue(VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: currentURL,
|
||||
outputURL: currentURL
|
||||
))
|
||||
}
|
||||
|
||||
func testMacOSTemporaryPathAliasesIdentifyTheSameRecording() {
|
||||
let suppliedURL = URL(fileURLWithPath: "/var/folders/luma/video-note-id.mov")
|
||||
let callbackURL = URL(fileURLWithPath: "/private/var/folders/luma/video-note-id.mov")
|
||||
|
||||
XCTAssertTrue(VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: suppliedURL,
|
||||
outputURL: callbackURL
|
||||
))
|
||||
}
|
||||
|
||||
func testTemporaryPathAliasDoesNotAcceptAnotherRecording() {
|
||||
let currentURL = URL(fileURLWithPath: "/var/folders/luma/current.mov")
|
||||
let oldURL = URL(fileURLWithPath: "/private/var/folders/luma/old.mov")
|
||||
|
||||
XCTAssertFalse(VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: currentURL,
|
||||
outputURL: oldURL
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class VideoNoteStopPolicyTests: XCTestCase {
|
||||
func testEarlyReleaseWaitsForRecordedMedia() {
|
||||
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
|
||||
recordedDuration: 0.25,
|
||||
minimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
remaining,
|
||||
VideoNoteStopPolicy.minimumCaptureDuration - 0.25,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
|
||||
func testStopDoesNotWaitAfterMinimumMediaDurationElapsed() {
|
||||
XCTAssertEqual(
|
||||
VideoNoteStopPolicy.remainingRecordedDuration(
|
||||
recordedDuration: 2,
|
||||
minimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
|
||||
),
|
||||
0,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
|
||||
func testRecordingFloorIsLongerThanLegacyShortClipThreshold() {
|
||||
XCTAssertGreaterThanOrEqual(VideoNoteStopPolicy.minimumCaptureDuration, 1.1)
|
||||
}
|
||||
|
||||
func testRecordedMediaTimeControlsTheFinalStop() {
|
||||
XCTAssertEqual(
|
||||
VideoNoteStopPolicy.remainingRecordedDuration(
|
||||
recordedDuration: 0.35,
|
||||
minimumDuration: 1.2
|
||||
),
|
||||
0.85,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
VideoNoteStopPolicy.remainingRecordedDuration(
|
||||
recordedDuration: 1.3,
|
||||
minimumDuration: 1.2
|
||||
),
|
||||
0,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class WatchVoiceMessageTests: XCTestCase {
|
||||
func testTransferUsesStableXMPPMessageIDAcrossRetries() {
|
||||
let message = WatchVoiceMessage(
|
||||
transferID: "9B4D98BA-7EB0-487A-96A2-608052AFCA2E",
|
||||
jid: "friend@example.org",
|
||||
filename: "voice.m4a",
|
||||
duration: 2.5,
|
||||
data: Data([0x01])
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
message.stableMessageID,
|
||||
"watch-9B4D98BA-7EB0-487A-96A2-608052AFCA2E"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user