fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.
Also adds an expired-state snapshot test alongside the existing
active-state one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,8 +49,10 @@ final class Issue7PreviewScreenshotTest: XCTestCase {
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_after_issue7_fix() {
|
||||
let vc = MockPreviewViewController(residence: ResidencePreview.fixtureForIssue7)
|
||||
// Force dark mode to match the gitea#7 screenshot exactly.
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .active
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
@@ -67,6 +69,33 @@ final class Issue7PreviewScreenshotTest: XCTestCase {
|
||||
named: "issue7_residence_invite_preview_dark"
|
||||
)
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_expired_state() {
|
||||
// Same residence + sender, but expiry already 1 hour in the
|
||||
// past. Verifies the expired branch: the instruction card
|
||||
// swaps to "ask the sender for a new link" and the detail row
|
||||
// reads "Expired 1 hour ago" instead of the future-tense
|
||||
// "Expires in …" phrasing.
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
of: vc,
|
||||
as: .image(
|
||||
on: .iPhone13,
|
||||
precision: 1.0,
|
||||
perceptualPrecision: 1.0,
|
||||
traits: .init(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: .dark),
|
||||
UITraitCollection(displayScale: 2.0),
|
||||
])
|
||||
),
|
||||
named: "issue7_residence_invite_preview_expired_dark"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
|
||||
@@ -87,10 +116,20 @@ private struct ResidencePreview {
|
||||
|
||||
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
|
||||
|
||||
/// Renderer state for the screenshot fixture. Active = link still
|
||||
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
|
||||
/// ago. Both render with deterministic data so the recorded PNG is
|
||||
/// stable across runs.
|
||||
private enum PreviewRenderState {
|
||||
case active
|
||||
case expired(elapsedSecondsSinceExpiry: TimeInterval)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockPreviewViewController: UIViewController {
|
||||
|
||||
private let residence: ResidencePreview
|
||||
private let state: PreviewRenderState
|
||||
|
||||
private let containerView = UIView()
|
||||
private let iconImageView = UIImageView()
|
||||
@@ -102,8 +141,9 @@ private final class MockPreviewViewController: UIViewController {
|
||||
private let instructionLabel = UILabel()
|
||||
private let arrowImageView = UIImageView()
|
||||
|
||||
init(residence: ResidencePreview) {
|
||||
init(residence: ResidencePreview, state: PreviewRenderState) {
|
||||
self.residence = residence
|
||||
self.state = state
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@@ -228,18 +268,68 @@ private final class MockPreviewViewController: UIViewController {
|
||||
|
||||
titleLabel.text = residence.residenceName
|
||||
subtitleLabel.text = "honeyDue Residence Invite"
|
||||
instructionLabel.attributedText = makeResidenceInstructions()
|
||||
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
||||
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
||||
}
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
let formatted = formatExpiresAt(expiresAt)
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
||||
|
||||
switch state {
|
||||
case .active:
|
||||
instructionLabel.attributedText = makeResidenceInstructions()
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
|
||||
}
|
||||
case .expired(let elapsed):
|
||||
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
|
||||
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
|
||||
}
|
||||
}
|
||||
|
||||
private func relativePhrase(secondsAgo: TimeInterval) -> String {
|
||||
// Deterministic relative phrase — we set "now" to be exactly
|
||||
// `secondsAgo` after the (fake) expiry, so the formatter says
|
||||
// "1 hour ago" instead of whatever the real clock would give.
|
||||
let fakeNow = Date()
|
||||
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .full
|
||||
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
|
||||
}
|
||||
|
||||
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
|
||||
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(NSAttributedString(
|
||||
string: "This invite has expired.\n",
|
||||
attributes: [
|
||||
.font: titleFont,
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
let body = if let s = sharedBy, !s.isEmpty {
|
||||
"Ask \(s) to send a new link."
|
||||
} else {
|
||||
"Ask the sender to share a new link."
|
||||
}
|
||||
result.append(NSAttributedString(
|
||||
string: body,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
return result
|
||||
}
|
||||
|
||||
private func addDetailRow(icon: String, text: String) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
@@ -313,9 +403,11 @@ private final class MockPreviewViewController: UIViewController {
|
||||
return result
|
||||
}
|
||||
|
||||
// Mirrors PreviewViewController.formatExpiresAt with a fixed "now"
|
||||
// so the rendering is identical regardless of when the test runs.
|
||||
private func formatExpiresAt(_ raw: String) -> String {
|
||||
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
|
||||
// "now" so the rendering is identical regardless of when the test
|
||||
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
|
||||
// instead — see the active/expired switch in `applyResidence`.
|
||||
private func formatActiveExpiry(_ raw: String) -> String {
|
||||
let isoWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Reference in New Issue
Block a user