Реплай: исправлен отступ от бара до текста (6pt → 8pt, Telegram parity)
This commit is contained in:
304
RosettaTests/ReadEligibilityTests.swift
Normal file
304
RosettaTests/ReadEligibilityTests.swift
Normal file
@@ -0,0 +1,304 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
/// Tests for the read eligibility lifecycle fix:
|
||||
/// readEligibleDialogs must be cleared on background entry to prevent
|
||||
/// stale eligibility from marking messages as read during background sync
|
||||
/// or premature foreground resume.
|
||||
@MainActor
|
||||
final class ReadEligibilityTests: XCTestCase {
|
||||
private var ctx: DBTestContext!
|
||||
private let peer = "02peer_read_test"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
ctx = DBTestContext()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Clean up dialog state
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: false)
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
ctx.teardown()
|
||||
ctx = nil
|
||||
}
|
||||
|
||||
// MARK: - clearAllReadEligibility
|
||||
|
||||
func testClearAllReadEligibility_removesAllEntries() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Set up two dialogs as active + read-eligible
|
||||
let peer2 = "02peer_read_test_2"
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogActive(peer2, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer2, isEligible: true)
|
||||
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer2))
|
||||
|
||||
// Clear all
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer2))
|
||||
|
||||
// Active state is preserved (only eligibility cleared)
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogActive(peer))
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogActive(peer2))
|
||||
|
||||
// Cleanup
|
||||
MessageRepository.shared.setDialogActive(peer2, isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - Stale eligibility → messages inserted as read (the bug)
|
||||
|
||||
func testStaleEligibility_marksIncomingAsReadOnInsert() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Simulate: user opens chat, scrolls to bottom → read-eligible
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Simulate: message arrives while eligibility is stale (e.g., background sync)
|
||||
try await ctx.runScenario(FixtureScenario(name: "stale read", events: [
|
||||
.incoming(opponent: peer, messageId: "stale-1", timestamp: 1000, text: "hello"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
// BUG: message is marked read because readEligibleDialogs had stale entry
|
||||
XCTAssertEqual(snapshot.messages.first?.read, true,
|
||||
"With stale eligibility, incoming message is incorrectly marked as read")
|
||||
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0,
|
||||
"With stale eligibility, unread count is 0 (should be 1)")
|
||||
}
|
||||
|
||||
func testClearedEligibility_keepsIncomingAsUnread() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Simulate: user opens chat, scrolls to bottom → read-eligible
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Simulate: app enters background → eligibility cleared (THE FIX)
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
// Simulate: message arrives during background sync
|
||||
try await ctx.runScenario(FixtureScenario(name: "cleared read", events: [
|
||||
.incoming(opponent: peer, messageId: "cleared-1", timestamp: 2000, text: "world"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
// FIXED: message stays unread because eligibility was cleared
|
||||
XCTAssertEqual(snapshot.messages.first?.read, false,
|
||||
"After clearing eligibility, incoming message must stay unread")
|
||||
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1,
|
||||
"After clearing eligibility, unread count must be 1")
|
||||
}
|
||||
|
||||
// MARK: - Re-enabling eligibility after foreground resume
|
||||
|
||||
func testReEnableEligibility_afterClear_marksAsRead() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Simulate: user opens chat → active + eligible
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Background → clear
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
// Message arrives while cleared → stays unread
|
||||
try await ctx.runScenario(FixtureScenario(name: "pre-reenable", events: [
|
||||
.incoming(opponent: peer, messageId: "re-1", timestamp: 3000, text: "hey"),
|
||||
]))
|
||||
|
||||
let snapshot1 = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot1.messages.first?.read, false)
|
||||
XCTAssertEqual(snapshot1.dialogs.first?.unreadCount, 1)
|
||||
|
||||
// Foreground → ChatDetailView re-enables eligibility + marks as read
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account)
|
||||
DialogRepository.shared.markAsRead(opponentKey: peer)
|
||||
|
||||
let snapshot2 = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot2.messages.first?.read, true,
|
||||
"After re-enabling eligibility and marking, message must be read")
|
||||
XCTAssertEqual(snapshot2.dialogs.first?.unreadCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - Notification tap scenario (Chat A open, tap notification for Chat B)
|
||||
|
||||
func testNotificationTap_differentChat_doesNotMarkOriginalAsRead() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
let chatA = peer
|
||||
let chatB = "02peer_chat_b"
|
||||
|
||||
// User is in Chat A, scrolled to bottom
|
||||
MessageRepository.shared.setDialogActive(chatA, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(chatA, isEligible: true)
|
||||
|
||||
// App goes to background → eligibility cleared
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
// Messages arrive for Chat A during background
|
||||
try await ctx.runScenario(FixtureScenario(name: "chatA bg", events: [
|
||||
.incoming(opponent: chatA, messageId: "a-1", timestamp: 4000, text: "from A"),
|
||||
]))
|
||||
|
||||
// Messages arrive for Chat B during background
|
||||
try await ctx.runScenario(FixtureScenario(name: "chatB bg", events: [
|
||||
.incoming(opponent: chatB, messageId: "b-1", timestamp: 4001, text: "from B"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
|
||||
// Chat A messages must be unread (user didn't view them)
|
||||
let chatAMsg = snapshot.messages.first(where: { $0.messageId == "a-1" })
|
||||
XCTAssertEqual(chatAMsg?.read, false, "Chat A message must be unread after background")
|
||||
|
||||
let chatADialog = snapshot.dialogs.first(where: { $0.opponentKey == chatA })
|
||||
XCTAssertEqual(chatADialog?.unreadCount, 1, "Chat A must have 1 unread")
|
||||
|
||||
// Chat B messages must also be unread
|
||||
let chatBMsg = snapshot.messages.first(where: { $0.messageId == "b-1" })
|
||||
XCTAssertEqual(chatBMsg?.read, false, "Chat B message must be unread")
|
||||
|
||||
let chatBDialog = snapshot.dialogs.first(where: { $0.opponentKey == chatB })
|
||||
XCTAssertEqual(chatBDialog?.unreadCount, 1, "Chat B must have 1 unread")
|
||||
|
||||
// Now simulate: user taps notification for Chat B → Chat B becomes active + eligible
|
||||
MessageRepository.shared.setDialogActive(chatA, isActive: false)
|
||||
MessageRepository.shared.setDialogActive(chatB, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(chatB, isEligible: true)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: chatB, myPublicKey: ctx.account)
|
||||
DialogRepository.shared.markAsRead(opponentKey: chatB)
|
||||
|
||||
let snapshot2 = try ctx.normalizedSnapshot()
|
||||
|
||||
// Chat A still unread
|
||||
let chatAMsg2 = snapshot2.messages.first(where: { $0.messageId == "a-1" })
|
||||
XCTAssertEqual(chatAMsg2?.read, false, "Chat A must STILL be unread after tapping Chat B notification")
|
||||
|
||||
let chatADialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatA })
|
||||
XCTAssertEqual(chatADialog2?.unreadCount, 1, "Chat A must STILL have 1 unread")
|
||||
|
||||
// Chat B is now read
|
||||
let chatBMsg2 = snapshot2.messages.first(where: { $0.messageId == "b-1" })
|
||||
XCTAssertEqual(chatBMsg2?.read, true, "Chat B message must be read after opening")
|
||||
|
||||
let chatBDialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatB })
|
||||
XCTAssertEqual(chatBDialog2?.unreadCount, 0, "Chat B must have 0 unread after opening")
|
||||
|
||||
// Cleanup
|
||||
MessageRepository.shared.setDialogActive(chatB, isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - setDialogActive(false) also clears eligibility
|
||||
|
||||
func testSetDialogActiveOff_clearsEligibility() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: false)
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogActive(peer))
|
||||
}
|
||||
|
||||
// MARK: - Eligibility requires active dialog
|
||||
|
||||
func testSetDialogReadEligible_requiresActiveDialog() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Try to set eligible without active → should be ignored
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer),
|
||||
"Eligibility must not be set for inactive dialogs")
|
||||
|
||||
// Now activate and set eligible
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
}
|
||||
|
||||
// MARK: - Multiple messages during background
|
||||
|
||||
func testMultipleMessagesWhileBackgrounded_allStayUnread() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// User in chat, at bottom
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Background → clear
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
// Multiple messages arrive during background
|
||||
try await ctx.runScenario(FixtureScenario(name: "batch bg", events: [
|
||||
.incoming(opponent: peer, messageId: "batch-1", timestamp: 5000, text: "msg 1"),
|
||||
.incoming(opponent: peer, messageId: "batch-2", timestamp: 5001, text: "msg 2"),
|
||||
.incoming(opponent: peer, messageId: "batch-3", timestamp: 5002, text: "msg 3"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let messages = snapshot.messages.filter { $0.messageId.hasPrefix("batch-") }
|
||||
|
||||
XCTAssertEqual(messages.count, 3)
|
||||
for msg in messages {
|
||||
XCTAssertEqual(msg.read, false, "Message \(msg.messageId) must be unread")
|
||||
}
|
||||
|
||||
let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
|
||||
XCTAssertEqual(dialog?.unreadCount, 3, "All 3 messages must be unread")
|
||||
}
|
||||
|
||||
// MARK: - Return to same chat after background
|
||||
|
||||
func testReturnToSameChat_afterReEnable_marksAllRead() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Background → clear
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
// Messages during background
|
||||
try await ctx.runScenario(FixtureScenario(name: "return same", events: [
|
||||
.incoming(opponent: peer, messageId: "ret-1", timestamp: 6000, text: "a"),
|
||||
.incoming(opponent: peer, messageId: "ret-2", timestamp: 6001, text: "b"),
|
||||
]))
|
||||
|
||||
// Foreground → user returns to same chat → re-enable eligibility + mark
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account)
|
||||
DialogRepository.shared.markAsRead(opponentKey: peer)
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let messages = snapshot.messages.filter { $0.messageId.hasPrefix("ret-") }
|
||||
|
||||
for msg in messages {
|
||||
XCTAssertEqual(msg.read, true, "Message \(msg.messageId) must be read after re-enable")
|
||||
}
|
||||
|
||||
let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
|
||||
XCTAssertEqual(dialog?.unreadCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - clearAllReadEligibility is idempotent
|
||||
|
||||
func testClearAllReadEligibility_idempotent() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
// Call on empty set — no crash
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user