// // Issue7PreviewScreenshotTest.swift // HoneyDueTests // // Records a single PNG screenshot of the post-fix QL-preview layout // used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be // attached to gitea issue #7 for the reviewer to see the new look // without having to AirDrop a `.honeydue` file to a device. // // How it works: // * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence` // builds in production — same colors, same fonts, same constraints, // same image asset (copied into `HoneyDueTests/Resources/AppLogo.png` // so it is reachable from this target's bundle). // * Runs the same `formatExpiresAt` style (ISO parse → relative phrase // when within a day, absolute medium-date + short-time otherwise), // using a fixed reference Date so the rendering is deterministic // across runs / time zones. // * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)` // writes the PNG to // `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`. // // The first run (no committed golden) records the PNG and the test // reports "failed - No reference was found on disk. Automatically // recorded snapshot:" — that's the file we attach to the issue. // // Note on faithfulness: this snapshot is a programmatic reproduction // of `PreviewViewController.updateUIForResidence`, not the QL // extension instance itself, because the QL extension's bundle is a // separate Xcode target from `HoneyDueTests` and can't be `@testable // import`ed without project-file surgery. The reproduction uses the // same UIKit primitives, colors, fonts, and asset, so the rendered // output matches what users see when iOS opens a `.honeydue` invite. // @preconcurrency import SnapshotTesting import UIKit import XCTest @MainActor final class Issue7PreviewScreenshotTest: XCTestCase { /// Force record mode for this test only — we want the PNG written /// regardless of whether a golden exists. override func invokeTest() { withSnapshotTesting(record: .all) { super.invokeTest() } } func test_residence_invite_preview_after_issue7_fix() { let vc = MockPreviewViewController( residence: ResidencePreview.fixtureForIssue7, state: .active ) 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_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) private struct ResidencePreview { let residenceName: String let sharedBy: String? let expiresAt: String? /// Mirrors the data shown in the original gitea#7 screenshot — the /// post-fix version of the same payload. static let fixtureForIssue7 = ResidencePreview( residenceName: "The Tartt's", sharedBy: "honey@hollie37.com", expiresAt: "2026-05-12T17:11:02.067272789Z" ) } // 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() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let dividerView = UIView() private let detailsStackView = UIStackView() private let instructionCard = UIView() private let instructionLabel = UILabel() private let arrowImageView = UIImageView() init(residence: ResidencePreview, state: PreviewRenderState) { self.residence = residence self.state = state super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("not used") } override func viewDidLoad() { super.viewDidLoad() setupUI() applyResidence() } private func setupUI() { view.backgroundColor = .systemBackground containerView.translatesAutoresizingMaskIntoConstraints = false iconImageView.translatesAutoresizingMaskIntoConstraints = false iconImageView.contentMode = .scaleAspectFit iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .systemFont(ofSize: 24, weight: .bold) titleLabel.textColor = .label titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 subtitleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium) subtitleLabel.textColor = .secondaryLabel subtitleLabel.textAlignment = .center dividerView.translatesAutoresizingMaskIntoConstraints = false dividerView.backgroundColor = .separator detailsStackView.translatesAutoresizingMaskIntoConstraints = false detailsStackView.axis = .vertical detailsStackView.spacing = 12 detailsStackView.alignment = .leading instructionCard.translatesAutoresizingMaskIntoConstraints = false instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1) instructionCard.layer.cornerRadius = 12 instructionLabel.translatesAutoresizingMaskIntoConstraints = false instructionLabel.font = .systemFont(ofSize: 15, weight: .medium) instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) instructionLabel.textAlignment = .left instructionLabel.numberOfLines = 0 arrowImageView.translatesAutoresizingMaskIntoConstraints = false arrowImageView.contentMode = .scaleAspectFit arrowImageView.tintColor = .secondaryLabel let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium) arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig) view.addSubview(containerView) containerView.addSubview(iconImageView) containerView.addSubview(titleLabel) containerView.addSubview(subtitleLabel) containerView.addSubview(dividerView) containerView.addSubview(detailsStackView) containerView.addSubview(instructionCard) instructionCard.addSubview(instructionLabel) containerView.addSubview(arrowImageView) NSLayoutConstraint.activate([ containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor), containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40), containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32), containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32), containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340), iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor), iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), iconImageView.widthAnchor.constraint(equalToConstant: 80), iconImageView.heightAnchor.constraint(equalToConstant: 80), titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16), titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20), dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), dividerView.heightAnchor.constraint(equalToConstant: 1), detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20), detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24), instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16), instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16), instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16), instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16), arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16), arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), ]) } private func applyResidence() { // Mirror the post-fix branding choice: bundled honeyDue logo // rendered in its actual colors. The image ships with the test // target at `Resources/AppLogo.png`. if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"), let logo = UIImage(contentsOfFile: path) { iconImageView.image = logo iconImageView.layer.cornerRadius = 16 iconImageView.layer.masksToBounds = true } else { let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config) } titleLabel.text = residence.residenceName subtitleLabel.text = "honeyDue Residence Invite" detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } if let sharedBy = residence.sharedBy, !sharedBy.isEmpty { addDetailRow(icon: "person", text: "Shared by \(sharedBy)") } switch state { case .active: instructionLabel.attributedText = makeResidenceInstructions() arrowImageView.isHidden = false if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))") } case .expired(let elapsed): instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy) // Arrow points at the Share button — no point telling the // user to tap it for a dead link. Matches PreviewViewController. arrowImageView.isHidden = true 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 row.spacing = 12 row.alignment = .center let iv = UIImageView() iv.translatesAutoresizingMaskIntoConstraints = false let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) iv.image = UIImage(systemName: icon, withConfiguration: config) iv.tintColor = .secondaryLabel iv.widthAnchor.constraint(equalToConstant: 24).isActive = true iv.heightAnchor.constraint(equalToConstant: 24).isActive = true let label = UILabel() label.font = .systemFont(ofSize: 15) label.textColor = .label label.text = text label.numberOfLines = 1 row.addArrangedSubview(iv) row.addArrangedSubview(label) detailsStackView.addArrangedSubview(row) } /// Mirrors `PreviewViewController.makeResidenceInstructions()` — see /// the rationale comment there. Inlined here because the QL /// extension target can't be `@testable import`ed without /// project-file surgery. private func makeResidenceInstructions() -> NSAttributedString { let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium) let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 2 paragraph.alignment = .left let result = NSMutableAttributedString() func appendText(_ s: String) { result.append(NSAttributedString( string: s, attributes: [ .font: bodyFont, .foregroundColor: tint, .paragraphStyle: paragraph, ] )) } appendText("How to join:\n1. Tap ") let shareImage = UIImage( systemName: "square.and.arrow.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) )?.withTintColor(tint, renderingMode: .alwaysOriginal) if let shareImage { let attachment = NSTextAttachment() attachment.image = shareImage attachment.bounds = CGRect( x: 0, y: -3, width: shareImage.size.width, height: shareImage.size.height ) result.append(NSAttributedString(attachment: attachment)) } appendText("\n2. Choose \"honeyDue\" from the share sheet") appendText("\n3. Sign in if prompted — the app finishes the rest") return result } // 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] return f }() let isoNoFraction: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() guard let date = isoWithFraction.date(from: raw) ?? isoNoFraction.date(from: raw) else { return raw } // Deterministic "now": 23 hours before the fixture's expiry, so // the relative formatter always produces "in 23 hours". let fakeNow = date.addingTimeInterval(-23 * 60 * 60) let relative = RelativeDateTimeFormatter() relative.unitsStyle = .full return relative.localizedString(for: date, relativeTo: fakeNow) } }