ios, android, desktop: previews render the same in every snapshot run (#7938)

* ios, android, desktop: previews draw fixed sample data instead of calling the core

* desktop: database error preview passes the keychain setting

* ios: snapshot fixtures use fixed parsed markdown instead of the core

* ios: snapshot fixtures: raw string delimiters past the # in markdown JSON

* desktop: snapshot test sets the app bar height as the app does

* docs: plan to replace preview-only parameters with test measures

* ios, desktop: previews run master's code; badges and version views split

Preview-only parameters are removed. BadgesCheckOrderView, BadgesHowLongView and VersionView are split into a loader and a layout, which their previews draw. Snapshot tests set a fixed QR picture: the environment value fixedQRCode on iOS, LocalFixedQRCode on desktop.

* ios: snapshot fixtures build parsed markdown with initializers

The table held the JSON of the Linux core, which the iOS app does not decode. The values are those the core returns for each text.

* ci: references of fixed QR codes and version; ios: call overlay preview as on master

iOS references from https://github.com/evgeny-simplex/simplex-chat/actions/runs/40944118370, desktop references from https://github.com/evgeny-simplex/simplex-chat/actions/runs/40932566864. ActiveCallOverlay_Previews, its scene and its references are restored as on master.

* ios, desktop: snapshot tests render every preview, no exclusion lists

BadgesChooseBadgeView, ServersSummaryView and TerminalView are split into a loader and a layout. AddChannelView, AddGroupView, GroupProfileView and UserProfileView layouts take the name and description validators, so desktop previews render without the core library. NewChatView's preview opens the Connect tab, UserProfilesView's preview has its own users. AppearanceSettings, MigrateFromDevice and the duplicate UserProfileLayoutEditOn previews are deleted.

* ios, desktop: previews show short links; tests draw the fixed QR code with each app's generator

The snapshot tests draw the fixed QR picture for a short address link with generateImage on iOS and qrCodeBitmap on desktop, in place of committed images. Previews of group links, addresses, a pending connection and QR codes pass short links, as the apps show them. The fixture markdown table equals the output of master's parser.

* ios, desktop: excluded previews and their views as on master

* ci: references of short-link QR codes
This commit is contained in:
Evgeny @ SimpleX Chat authored and GitHub committed 2026-10-30 06:41:40 +00:00
1 parent b6b985f95c
commit 17c9ad1c61
36 files changed
+529 -44

No files matched your search

@@ -11,6 +11,18 @@ import StoreKit
import SimpleXChat
struct BadgesCheckOrderView: View {
let level: BadgeLevel
let period: BadgePeriod
var startDate = Date()
var priceList: BadgePriceList? = nil
var body: some View {
BadgesCheckOrderLayout(level: level, period: period, startDate: startDate, priceList: priceList)
.task { await BadgeStore.shared.load() }
}
}
struct BadgesCheckOrderLayout: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var store = BadgeStore.shared
@@ -104,7 +116,6 @@ struct BadgesCheckOrderView: View {
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
.alert(item: $alert) { $0.alert }
}
@@ -217,7 +228,7 @@ struct RestorePurchasesButton: View {
struct BadgesCheckOrderView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesCheckOrderView(
BadgesCheckOrderLayout(
level: .supporter,
period: .oneMonth,
startDate: previewDate,
@@ -69,6 +69,17 @@ enum BadgePeriod: String, CaseIterable, Identifiable {
}
struct BadgesHowLongView: View {
let level: BadgeLevel
var startDate = Date()
var priceList: BadgePriceList? = nil
var body: some View {
BadgesHowLongLayout(level: level, startDate: startDate, priceList: priceList)
.task { await BadgeStore.shared.load() }
}
}
struct BadgesHowLongLayout: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
// store.forSale reads the active profile's badge
@@ -142,7 +153,6 @@ struct BadgesHowLongView: View {
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
}
private func periodCard(_ period: BadgePeriod) -> some View {
@@ -239,7 +249,7 @@ struct BadgeBillingFooter: View {
struct BadgesHowLongView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesHowLongView(
BadgesHowLongLayout(
level: .supporter,
startDate: previewDate,
priceList: BadgePriceList(oneMonth: .price("$7"), monthly: .price("$7"), annual: .price("$42"), monthlyAmount: 7, annualAmount: 42, subscriptionsForSale: true, subscribedBefore: false)
@@ -241,9 +241,9 @@ struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View {
@State var groupLink: GroupLink? = GroupLink(
userContactLinkId: 1,
connLinkContact: CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil),
shortLinkDataSet: false,
shortLinkLargeDataSet: false,
connLinkContact: CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: "https://smp6.simplex.im/g#cL3iklYlt_Tws1B_1aGylbT_3HowPMQhuCFoBiCjZ1M"),
shortLinkDataSet: true,
shortLinkLargeDataSet: true,
groupLinkId: "abc",
acceptMemberRole: .member
)
+16 -4
View File
@@ -46,19 +46,31 @@ struct SimpleXLinkQRCode: View {
private let smallQRRatio: CGFloat = 0.63
extension EnvironmentValues {
struct FixedQRCode: EnvironmentKey {
static let defaultValue: UIImage? = nil
}
var fixedQRCode: UIImage? {
get { self[FixedQRCode.self] }
set { self[FixedQRCode.self] = newValue }
}
}
struct QRCode: View {
let uri: String
var small: Bool = false
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var onShare: (() -> Void)? = nil
@Environment(\.fixedQRCode) private var fixedQRCode
@State private var image: UIImage? = nil
@State private var makeScreenshotFunc: () -> Void = {}
@State private var width: CGFloat = .infinity
var body: some View {
ZStack {
if let image = image {
if let image = fixedQRCode ?? image {
qrCodeImage(image).frame(width: width, height: width)
GeometryReader { g in
let w = g.size.width * (small ? smallQRRatio : 1)
@@ -90,7 +102,7 @@ struct QRCode: View {
}
}
.onTapGesture(perform: makeScreenshotFunc)
.task { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") }
.task { if fixedQRCode == nil { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") } }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -103,7 +115,7 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled)
}
private func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? {
func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
@@ -131,6 +143,6 @@ extension View {
struct QRCode_Previews: PreviewProvider {
static var previews: some View {
QRCode(uri: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
QRCode(uri: "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw", small: true)
}
}
@@ -1027,7 +1027,7 @@ struct SetSimplexDomainView: View {
struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil))
chatModel.userAddress = UserContactLink(CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"))
return Group {
UserAddressView()
@@ -10,9 +10,26 @@ import SwiftUI
import SimpleXChat
struct VersionView: View {
@EnvironmentObject var theme: AppTheme
@State var versionInfo: CoreVersionInfo?
var body: some View {
VersionLayout(appVersion: appVersion, appBuild: appBuild, versionInfo: versionInfo)
.onAppear {
do {
versionInfo = try apiGetVersion()
} catch let error {
logger.error("apiGetVersion error: \(responseError(error))")
}
}
}
}
struct VersionLayout: View {
@EnvironmentObject var theme: AppTheme
let appVersion: String?
let appBuild: String?
let versionInfo: CoreVersionInfo?
var body: some View {
List {
Section {
@@ -37,18 +54,11 @@ struct VersionView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
do {
versionInfo = try apiGetVersion()
} catch let error {
logger.error("apiGetVersion error: \(responseError(error))")
}
}
}
}
struct VersionView_Previews: PreviewProvider {
static var previews: some View {
VersionView()
VersionLayout(appVersion: "7.1", appBuild: "363", versionInfo: CoreVersionInfo(version: "7.1.0.14", simplexmqVersion: "7.1.0.14", simplexmqCommit: "c0c416daf052eb0215ae432ae665522a7c96c0a6"))
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+101 -4
View File
@@ -5,6 +5,12 @@ import UIKit
@testable import SimpleXChat
final class SnapshotTests: XCTestCase {
private var fixedQRCode: UIImage?
override func setUp() async throws {
fixedQRCode = await generateImage(previewQRLink, tintColor: UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1), errorLevel: "M")
}
@MainActor
func testPreviews() throws {
NSTimeZone.default = try XCTUnwrap(TimeZone(identifier: "UTC"))
@@ -21,7 +27,7 @@ final class SnapshotTests: XCTestCase {
for scene in previewScenes() where scene.allSizes || size == "large" {
let themeName = dark ? "dark" : "light"
let file = "\(scene.name)-\(themeName)-\(size).png"
let image = try renderScene(scene, theme.toAppTheme(), dark: dark, in: windowScene)
let image = try renderScene(scene, theme.toAppTheme(), dark: dark, fixedQRCode: fixedQRCode, in: windowScene)
try XCTUnwrap(image.pngData()).write(to: dir.appendingPathComponent(file))
index.append([
"scene": scene.name,
@@ -305,13 +311,16 @@ private func snapshotTheme(_ dark: Bool) -> ThemeManager.ActiveTheme {
)
}
private let previewQRLink = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"
@MainActor
private func renderScene(_ scene: SnapshotScene, _ theme: AppTheme, dark: Bool, in windowScene: UIWindowScene) throws -> UIImage {
private func renderScene(_ scene: SnapshotScene, _ theme: AppTheme, dark: Bool, fixedQRCode: UIImage?, in windowScene: UIWindowScene) throws -> UIImage {
let screen = windowScene.screen
let width = screen.bounds.width
let root = VStack(spacing: 0) { scene.view }
.environmentObject(ChatModel.shared)
.environmentObject(theme)
.environment(\.fixedQRCode, fixedQRCode)
.frame(width: width, height: scene.height)
let host = UIHostingController(rootView: AnyView(root))
if #available(iOS 16.4, *) {
@@ -548,6 +557,94 @@ private final class SnapshotFixtures {
let team: GroupInfo
let news: GroupInfo
let contact: Contact
private let markdown: [String: ParsedMarkdown] = [
"https://simplex.chat": ParsedMarkdown(formattedText: [
FormattedText(text: "https://simplex.chat", format: .uri)
]),
"---": ParsedMarkdown(
formattedText: [
FormattedText(text: "---", format: .markup(token: .ruleMark))
],
markdownBlocks: [
.rule(mark: SegmentRange(from: 0, to: 1))
]
),
"Hi! Did you see the **new release**?": ParsedMarkdown(formattedText: [
FormattedText(text: "Hi! Did you see the "),
FormattedText(text: "new release", format: .bold(singleStar: false)),
FormattedText(text: "?")
]),
"Yes, *great* changes 🎉": ParsedMarkdown(formattedText: [
FormattedText(text: "Yes, "),
FormattedText(text: "great", format: .bold(singleStar: true)),
FormattedText(text: " changes 🎉")
]),
"# Release notes\n- terminal layout\n- `code` blocks\n\n> quoted line": ParsedMarkdown(
formattedText: [
FormattedText(text: "# ", format: .markup(token: .headingMark(level: 1))),
FormattedText(text: "Release notes"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "- ", format: .markup(token: .listMark(number: nil))),
FormattedText(text: "terminal layout"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "- ", format: .markup(token: .listMark(number: nil))),
FormattedText(text: "code", format: .snippet),
FormattedText(text: " blocks"),
FormattedText(text: "\n\n", format: .markup(token: .paragraphBreak(lineBreaks: 2))),
FormattedText(text: "> ", format: .markup(token: .quoteMark)),
FormattedText(text: "quoted line")
],
markdownBlocks: [
.heading(level: 1, content: SegmentRange(from: 1, to: 2)),
.list(items: [
ListEntry(number: nil, content: SegmentRange(from: 4, to: 5), items: []),
ListEntry(number: nil, content: SegmentRange(from: 7, to: 9), items: [])
]),
.quote(content: SegmentRange(from: 11, to: 12))
]
),
"Plan for today:\n1. review\n2. **release**": ParsedMarkdown(
formattedText: [
FormattedText(text: "Plan for today:"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "1. ", format: .markup(token: .listMark(number: "1."))),
FormattedText(text: "review"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "2. ", format: .markup(token: .listMark(number: "2."))),
FormattedText(text: "release", format: .bold(singleStar: false))
],
markdownBlocks: [
.paragraph(content: SegmentRange(from: 0, to: 1)),
.list(items: [
ListEntry(number: "1.", content: SegmentRange(from: 3, to: 4), items: []),
ListEntry(number: "2.", content: SegmentRange(from: 6, to: 7), items: [])
])
]
),
"**Version 7.1** is out": ParsedMarkdown(formattedText: [
FormattedText(text: "Version 7.1", format: .bold(singleStar: false)),
FormattedText(text: " is out")
]),
"## What's new\n- terminal layout\n- faster sync": ParsedMarkdown(
formattedText: [
FormattedText(text: "## ", format: .markup(token: .headingMark(level: 2))),
FormattedText(text: "What's new"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "- ", format: .markup(token: .listMark(number: nil))),
FormattedText(text: "terminal layout"),
FormattedText(text: "\n", format: .markup(token: .lineBreak)),
FormattedText(text: "- ", format: .markup(token: .listMark(number: nil))),
FormattedText(text: "faster sync")
],
markdownBlocks: [
.heading(level: 2, content: SegmentRange(from: 1, to: 2)),
.list(items: [
ListEntry(number: nil, content: SegmentRange(from: 4, to: 5), items: []),
ListEntry(number: nil, content: SegmentRange(from: 7, to: 8), items: [])
])
]
)
]
init() {
alice = Self.member(2, "alice", .admin, image: imageBase64(UIColor(red: 0.85, green: 0.45, blue: 0.3, alpha: 1), CGSize(width: 64, height: 64)))
@@ -594,7 +691,7 @@ private final class SnapshotFixtures {
}
private func msg(_ dir: CIDirection, _ text: String, _ minutes: Double, quote: CIQuote? = nil, reactions: [CIReactionCount] = [], content: MsgContent? = nil, file: CIFile? = nil) -> ChatItem {
let parsed = text.isEmpty ? nil : parseSimpleXMarkdownText(text)
let parsed = markdown[text]
let mc = content ?? .text(text)
return ChatItem(
chatDir: dir,
@@ -613,7 +710,7 @@ private final class SnapshotFixtures {
}
private func quote(_ dir: CIDirection?, _ text: String, image: String? = nil) -> CIQuote {
let parsed = text.isEmpty ? nil : parseSimpleXMarkdownText(text)
let parsed = markdown[text]
return CIQuote(
chatDir: dir,
itemId: 1,
@@ -212,7 +212,7 @@ internal fun PreviewContactConnectionInfoView() {
SimpleXTheme {
ContactConnectionInfoLayout(
chatModel = ChatModel,
connLink = CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null),
connLink = CreatedConnLink("https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D", "https://smp4.simplex.im/i#Wu_NH3_F5YphQrIMSCSfWhvqz7tjpZsu/A2dCDGGVkh0-doRPIZVTM-TflHes2uYMVRgA_tK_uCs"),
showShortLink = remember { mutableStateOf(true) },
contactConnection = PendingContactConnection.getSampleData(),
focusAlias = false,
@@ -60,6 +60,8 @@ fun SimpleXLinkQRCode(
)
}
val LocalFixedQRCode = staticCompositionLocalOf<ImageBitmap?> { null }
@Composable
fun QRCode(
connReq: String,
@@ -73,8 +75,9 @@ fun QRCode(
val scope = rememberCoroutineScope()
val logoSize = if (small) 0.21f else 0.16f
val errorLevel = if (small) QrCode.ErrorLevel.M else QrCode.ErrorLevel.L
val fixedQRCode = LocalFixedQRCode.current
val qr = remember(connReq, tintColor, withLogo) {
qrCodeBitmap(connReq, 1024, errorLevel).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
(fixedQRCode ?: qrCodeBitmap(connReq, 1024, errorLevel)).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
.let { if (withLogo) it.addLogo(logoSize) else it }
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -127,6 +130,6 @@ expect fun ImageBitmap.replaceColor(from: Int, to: Int): ImageBitmap
@Composable
fun PreviewQRCode() {
SimpleXTheme {
QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
QRCode(connReq = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw", small = true)
}
}
@@ -839,9 +839,9 @@ fun PreviewUserAddressLayoutAddressCreated() {
UserAddressLayout(
user = User.sampleData,
userAddress = UserContactLinkRec(
CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null),
shortLinkDataSet = false,
shortLinkLargeDataSet = false,
CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"),
shortLinkDataSet = true,
shortLinkLargeDataSet = true,
addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null)
),
createAddress = {},
@@ -1,6 +1,5 @@
package chat.simplex.common
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -306,9 +305,3 @@ class DialogState<T> {
fun onResult(result: T) = onResult!!.complete(result)
}
@Preview
@Composable
fun AppPreview() {
AppScreen()
}
@@ -8,10 +8,11 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowState
import chat.simplex.common.AppPreview
import boofcv.alg.fiducial.qrcode.QrCode
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.simplexWindowState
@@ -43,6 +44,7 @@ import kotlin.test.assertEquals
private const val SCENE_WIDTH = 402
private const val SCENE_MAX_HEIGHT = 874
private const val PREVIEW_QR_LINK = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"
private val chatItemFontScales = listOf(1f, 1.4f, 1.5f)
@@ -112,7 +114,6 @@ private val previewScenes = listOf(
SnapshotScene("UserAddressView.PreviewUserAddressLayoutAddressCreated") { PreviewUserAddressLayoutAddressCreated() },
SnapshotScene("AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout") { PreviewAdvancedNetworkSettingsLayout() },
SnapshotScene("NetworkAndServers.PreviewNetworkAndServersLayout") { PreviewNetworkAndServersLayout() },
SnapshotScene("DesktopApp.AppPreview") { AppPreview() },
)
private val previewsCallingCore = listOf(
@@ -139,12 +140,29 @@ class SnapshotTest {
val theme = appPrefs.currentTheme.get()!!
val themeOverrides = appPrefs.themeOverrides.get()
val fontScale = appPrefs.fontScale.get()
val densityScale = appPrefs.densityScale.get()
val oneHandUI = appPrefs.oneHandUI.get()
val chatBottomBar = appPrefs.chatBottomBar.get()
val chatItemRoundness = appPrefs.chatItemRoundness.get()
val chatItemTail = appPrefs.chatItemTail.get()
val privacyMediaBlurRadius = appPrefs.privacyMediaBlurRadius.get()
val appearanceBarsBlurRadius = appPrefs.appearanceBarsBlurRadius.get()
val inAppBarsAlpha = appPrefs.inAppBarsAlpha.get()
val appBarMaxHeightPx = AppBarHandler.appBarMaxHeightPx
val index = mutableListOf<Snapshot>()
val failures = mutableListOf<String>()
try {
Locale.setDefault(Locale.US)
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
simplexWindowState.windowState = WindowState(size = DpSize(SCENE_WIDTH.dp, SCENE_MAX_HEIGHT.dp))
appPrefs.densityScale.set(1f)
appPrefs.oneHandUI.set(true)
appPrefs.chatBottomBar.set(true)
appPrefs.chatItemRoundness.set(0.75f)
appPrefs.chatItemTail.set(true)
appPrefs.privacyMediaBlurRadius.set(0)
appPrefs.appearanceBarsBlurRadius.set(if (appPrefs.deviceSupportsBlur) 50 else 0)
appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha)
appPrefs.themeOverrides.set(listOf(ThemeOverrides(base = DefaultTheme.LIGHT), ThemeOverrides(base = DefaultTheme.DARK)))
for (base in listOf(DefaultTheme.LIGHT, DefaultTheme.DARK)) {
ThemeManager.applyTheme(base.themeName)
@@ -170,6 +188,15 @@ class SnapshotTest {
TimeZone.setDefault(timeZone)
appPrefs.themeOverrides.set(themeOverrides)
appPrefs.fontScale.set(fontScale)
appPrefs.densityScale.set(densityScale)
appPrefs.oneHandUI.set(oneHandUI)
appPrefs.chatBottomBar.set(chatBottomBar)
appPrefs.chatItemRoundness.set(chatItemRoundness)
appPrefs.chatItemTail.set(chatItemTail)
appPrefs.privacyMediaBlurRadius.set(privacyMediaBlurRadius)
appPrefs.appearanceBarsBlurRadius.set(appearanceBarsBlurRadius)
appPrefs.inAppBarsAlpha.set(inAppBarsAlpha)
AppBarHandler.appBarMaxHeightPx = appBarMaxHeightPx
ThemeManager.applyTheme(theme)
}
assertEquals(emptyList(), failures)
@@ -178,9 +205,11 @@ class SnapshotTest {
private fun renderSnapshot(content: @Composable () -> Unit): Image {
var height = 0
val fixedQRCode = qrCodeBitmap(PREVIEW_QR_LINK, 1024, QrCode.ErrorLevel.M)
val frame = renderScene(width = SCENE_WIDTH, height = SCENE_MAX_HEIGHT, frames = 4) {
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
Box(Modifier.width(SCENE_WIDTH.dp).background(CurrentColors.collectAsState().value.colors.background).onSizeChanged { height = it.height }) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(), content = content)
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(), LocalFixedQRCode provides fixedQRCode, content = content)
}
}
return Surface.makeRasterN32Premul(SCENE_WIDTH, height).run {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 147 KiB

+320
View File
@@ -0,0 +1,320 @@
# Pure previews: rework
Date: 2026-10-29.
Pull request: https://github.com/simplex-chat/simplex-chat/pull/7938. Branch: ep/pure-previews.
The branch had 109 preview-only parameters, each marked `// do not pass`, in 65 files. All are removed. Previews run master's code, except three views split into a loader and a layout, and one environment value for the QR picture.
Rules, from the owner:
```
1 previews keep master's core calls that send no network request: database reads, parsing, validation
2 a preview whose rendering in CI can send a network request draws a layout split cleanly from its view, or its scene is excluded
3 VersionView is split, so that its scene does not depend on the release
4 QR codes: one environment value on iOS, one CompositionLocal on Kotlin, set by the snapshot tests
5 camera: master's code
```
Paths:
```
iOS apps/ios/Shared/
Kotlin apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/
```
Line numbers of app code are those of master at 3bdecd63f. Core lines are those of `src/Simplex/Chat/Library/Commands.hs` and `src/Simplex/Chat/Remote.hs` at the same commit; agent lines are those of simplexmq c0c416daf, the commit `cabal.project` pins.
## 1. Outcomes
```
(a) CI the parameter goes; the preview runs master's code; the scene is rendered on master and compared on every ios-sim or desktop-linux run
(a) CL the parameter goes; the preview draws previewDate (2025-01-15 10:15 UTC); from 2026-01-01 the clock-dependent text is "1/15/25" and "Wed, 15 Jan 2025", as in master's references
(a) X the parameter goes; the scene stays excluded, as on master
(a) S1 the parameter goes; the snapshot test sets the QR picture of seam S1
(a) SIM the parameter goes; the simulator build of CodeScanner draws a fixed text and requests no camera access
(b) the parameter goes; the preview draws the layout of a loader/layout split (section 3)
```
Calls kept in previews, as on master:
```
db core API reading or writing the CI database, without network (section 4)
fn pure core function: markdown parse, name validation, JSON length, server address parse
agent agent API reading agent memory
store StoreKit read without network: canMakePayments, storefront country code
keychain keychain read
files file read or delete
defaults UserDefaults write
paste pasteboard read
camera camera authorization status, and the permission request of ScannerInView
global write to a shared singleton
```
## 2. Parameters
| # | File | Parameter | Outcome | Measure | Calls kept |
|---|---|---|---|---|---|
| 1 | Model/ChatModel.swift | `ChatModel.init(migrationState:)` | (a) CI | previews call `ChatModel()`, as master's 12 preview sites | files |
| 2 | Views/Badges/BadgesCheckOrderView.swift | `priceList` | (b) | `BadgesCheckOrderLayout(priceList:)` | — |
| 3 | Views/Badges/BadgesCheckOrderView.swift | `previewCanBuy` | (b) | the layout reads `store.canBuy` | — |
| 4 | Views/Badges/BadgesCheckOrderView.swift | `previewCanMakePayments` | (b) | the layout reads `AppStore.canMakePayments` | store |
| 5 | Views/Badges/BadgesChooseBadgeView.swift | `previewMonthlyPrices` | (a) X | master | — |
| 6 | Views/Badges/BadgesChooseBadgeView.swift | `previewBrowserAllowed` | (a) X | master | — |
| 7 | Views/Badges/BadgesHowLongView.swift | `priceList` | (b) | `BadgesHowLongLayout(priceList:)` | — |
| 8 | Views/Badges/BadgesRedeemCodeView.swift | `previewCameraStatus` | (a) CI | ScannerInView draws "Tap to scan" | camera |
| 9 | Views/Call/IncomingCallView.swift | `previewInvitation` | (a) CI | master's preview sets `CallController.shared.activeCallInvitation` | global |
| 10 | Views/Chat/ChatInfoView.swift | `ChatInfoView.previewContactInfo` | (a) CI | `apiContactInfo`, `apiGetContactCode` for the sample contact | db |
| 11 | Views/Chat/ChatInfoView.swift | `ChatInfoView.previewChatItemTTL` | (a) CI | `ChatModel.shared.chatItemTTL` of the CI database | — |
| 12 | Views/Chat/ChatInfoView.swift | `ChatTTLOption.previewChatItemTTL` | (a) CI | as row 11 | — |
| 13 | Views/Chat/ChatItemForwardingView.swift | `previewChats` | (a) CI | `ChatModel.shared.chats` of the CI profile | — |
| 14 | Views/Chat/ChatItemForwardingView.swift | `previewHasSimplexLink` | (a) CI | `hasSimplexLink("hello")` | fn |
| 15 | Views/Chat/ChatView.swift | `previewAvailableContent` | (a) CI | `initChatView` for the sample contact | db |
| 16 | Views/Chat/Group/AddGroupMembersView.swift | `AddGroupMembersView.previewContacts` | (a) CI | `filterMembersToAdd`: contacts of `ChatModel.shared.chats` | — |
| 17 | Views/Chat/Group/AddGroupMembersView.swift | `AddGroupMembersViewCommon.previewContacts` | (a) CI | as row 16 | — |
| 18 | Views/Chat/Group/ChannelRelaysView.swift | `previewMembers` | (a) CI | `loadGroupMembers`, `apiGetGroupRelays` for the sample group | db |
| 19 | Views/Chat/Group/GroupChatInfoView.swift | `previewGroupLink` | (a) CI | `apiGetGroupLink` for the sample group | db |
| 20 | Views/Chat/Group/GroupChatInfoView.swift | `previewChatItemTTL` | (a) CI | as row 11 | — |
| 21 | Views/Chat/Group/GroupChatInfoView.swift | `sendReceiptsUserDefault` | (a) CI | `onAppear` reads `User.sampleData`, which the test sets as `ChatModel.shared.currentUser` | — |
| 22 | Views/Chat/Group/GroupLinkView.swift | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 23 | Views/Chat/Group/GroupLinkView.swift | `shouldCreate` | (a) CI | `apiCreateGroupLink` for group 1 fails on the CI database | db |
| 24 | Views/Chat/Group/GroupMemberInfoView.swift | `previewMemberInfo` | (a) CI | `apiGroupMemberInfo`, `apiGetGroupMemberCode`, `apiContactInfo` for the sample member | db |
| 25 | Views/Chat/Group/GroupProfileView.swift | `previewShortDescrLength` | (a) CI | `chatJsonLength` of the sample group's short description; GroupWelcomeView_Previews as well | fn |
| 26 | Views/Chat/Group/MemberSupportView.swift | `previewMembers` | (a) CI | `loadGroupMembers` for the sample group | db |
| 27 | Views/Chat/Group/SecondaryChatView.swift | `previewAvailableContent` | (a) CI | `initChatView` of ChatView for the sample group scope | db |
| 28 | Views/Chat/ScanCodeView.swift | `previewCameraStatus` | (a) SIM | `CodeScannerView` | — |
| 29 | Views/Chat/VerifyCodeView.swift | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 30 | Views/ChatList/ChatListView.swift | `badgeModel` | (a) CI | `BadgeModel.shared` of the CI profile | — |
| 31 | Views/ChatList/ChatListView.swift | `previewSubsTotal` | (a) CI | `getAgentSubsTotal` of the CI profile | agent |
| 32 | Views/ChatList/ChatListView.swift | `previewInUS` | (a) CI | `isInUS` of the simulator's storefront | store |
| 33 | Views/ChatList/ChatListView.swift | `previewNoShownBadge` | (a) CI | as row 30 | — |
| 34 | Views/ChatList/ChatListView.swift | `chatTagsModel` | (a) CI | `ChatTagsModel.shared` of the CI profile | — |
| 35 | Views/ChatList/ChatListView.swift | `SubsStatusIndicator.previewSubsTotal` | (a) CI | as row 31 | agent |
| 36 | Views/ChatList/ServersSummaryView.swift | `previewServersSummary` | (a) X | master | — |
| 37 | Views/ChatList/ServersSummaryView.swift | `previewServerAddresses` | (a) X | master | — |
| 38 | Views/ChatList/UserPicker.swift | `previewUsers` | (a) CI | `listUsersAsync` of the CI database | db |
| 39 | Views/Database/DatabaseEncryptionView.swift | `initialRandomDBPassphrase` | (a) CI | `initialRandomDBPassphraseGroupDefault` of the CI app | — |
| 40 | Views/Database/DatabaseEncryptionView.swift | `storedKey` | (a) CI | `kcDatabasePassword` of the CI app | keychain |
| 41 | Views/Database/DatabaseEncryptionView.swift | `previewDatabasePassword` | (a) CI | as row 40 | keychain |
| 42 | Views/Database/DatabaseErrorView.swift | `storedDBKey` | (a) CI | as row 40 | keychain |
| 43 | Views/Database/DatabaseErrorView.swift | `previewShowRestoreDbButton` | (a) CI | `shouldShowRestoreDbButton`: `encryptionStartedDefault` of the CI app is false | — |
| 44 | Views/Database/DatabaseView.swift | `previewUserName` | (a) CI | `User.sampleData`, which the test sets | — |
| 45 | Views/Database/DatabaseView.swift | `previewAppFilesCountAndSize` | (a) CI | the CI app's files folder | files |
| 46 | Views/LocalAuth/SetAppPasscodeView.swift | `previewProhibitedPasscode` | (a) CI | `kcSelfDestructPassword` of the CI app | keychain |
| 47 | Views/Migration/MigrateFromDevice.swift | `previewMigrationState` | (a) X | master | — |
| 48 | Views/Migration/MigrateToDevice.swift | `pasteboardHasStrings` | (a) CI | the CI simulator's pasteboard, empty after the smoke test | paste |
| 49 | Views/Migration/MigrateToDevice.swift | `previewCameraStatus` | (a) CI | ScannerInView draws "Tap to scan" | camera |
| 50 | Views/NewChat/AddChannelView.swift | `previewValidName` | (a) CI | `mkValidName("")` | fn |
| 51 | Views/NewChat/AddChannelView.swift | `previewHasRelays` | (a) CI | `checkHasRelays` of the CI profile | db |
| 52 | Views/NewChat/AddGroupView.swift | `previewValidName` | (a) CI | `mkValidName("")` | fn |
| 53 | Views/NewChat/NewChatView.swift | `NewChatView.previewQRImage` | (a) X | master | — |
| 54 | Views/NewChat/NewChatView.swift | `connLinkInvitation` | (a) X | master | — |
| 55 | Views/NewChat/NewChatView.swift | `InviteView.previewQRImage` | (a) X | master | — |
| 56 | Views/NewChat/NewChatView.swift | `ScannerInView.previewCameraStatus` | (a) CI | rows 8, 49, 67 | camera |
| 57 | Views/NewChat/QRCode.swift | `MutableQRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 58 | Views/NewChat/QRCode.swift | `SimpleXCreatedLinkQRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 59 | Views/NewChat/QRCode.swift | `SimpleXLinkQRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 60 | Views/NewChat/QRCode.swift | `QRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 61 | Views/Onboarding/CreateProfile.swift | `previewValidName` | (a) CI | `mkValidName("")` | fn |
| 62 | Views/Onboarding/CreateSimpleXAddress.swift | `previewQRImage` | (a) S1 | master's preview draws the address QR code only when `ChatModel.shared.userAddress` is set; it is nil for the CI profile | — |
| 63 | Views/Onboarding/OnboardingView.swift | `previewVersionSaved` | (a) CI | `setLastVersionDefault` | defaults |
| 64 | Views/Onboarding/SimpleXInfo.swift | `previewVersionSaved` | (a) CI | as row 63 | defaults |
| 65 | Views/Onboarding/WhatsNewView.swift | `previewInUS` | (a) CI | as row 32 | store |
| 66 | Views/RemoteAccess/ConnectDesktopView.swift | `previewRemoteCtrls` | (a) CI | `setLocalDeviceName`, `listRemoteCtrls` of the CI database | db |
| 67 | Views/RemoteAccess/ConnectDesktopView.swift | `previewCameraStatus` | (a) CI | ScannerInView draws "Tap to scan" | camera |
| 68 | Views/TerminalView.swift | `authorized` | (a) X | master's parameter; master | — |
| 69 | Views/TerminalView.swift | `showing` | (a) X | master | — |
| 70 | Views/UserSettings/CallSettings.swift | `previewInChina` | (a) CI | `CallController.isInChina` of the simulator's storefront | store |
| 71 | Views/UserSettings/NetworkAndServers/NetworkAndServers.swift | `justOpened` | (a) CI | `getUserServers`, `validateServers_` of the CI database | db |
| 72 | Views/UserSettings/NetworkAndServers/NewServerView.swift | `previewServerAddress` | (a) CI | `parseServerAddress("")` | fn |
| 73 | Views/UserSettings/NetworkAndServers/ProtocolServerView.swift | `previewServerAddress` | (a) CI | `parseServerAddress` of the sample server | fn |
| 74 | Views/UserSettings/NetworkAndServers/ProtocolServerView.swift | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 75 | Views/UserSettings/NetworkAndServers/ProtocolServersView.swift | `YourServersView.previewServerAddresses` | (a) CI | `parseServerAddress` of the sample servers | fn |
| 76 | Views/UserSettings/NetworkAndServers/ProtocolServersView.swift | `ProtocolServerViewLink.previewServerAddress` | (a) CI | as row 75 | fn |
| 77 | Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift | `previewCameraStatus` | (a) SIM | `CodeScannerView` | — |
| 78 | Views/UserSettings/NotificationsView.swift | `previewNtfToken` | (a) CI | `apiGetNtfToken` of the CI database | db |
| 79 | Views/UserSettings/PrivacySettings.swift | `previewUserName` | (a) CI | as row 44 | — |
| 80 | Views/UserSettings/SettingsView.swift | `previewInUS` | (a) CI | as row 32 | store |
| 81 | Views/UserSettings/UserAddressView.swift | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 82 | Views/UserSettings/VersionView.swift | `previewVersionInfo` | (b) | `VersionLayout(appVersion:appBuild:versionInfo:)` | — |
| 83 | model/ChatModel.kt | `getTimestampDateText.previewNow` | (a) CL | DateSeparator text | — |
| 84 | model/ChatModel.kt | `getTimestampText.previewNow` | (a) CL | chat list time | — |
| 85 | views/chat/ChatView.kt | `ChatLayout.previewNow` | (a) CL | as row 83 | — |
| 86 | views/chat/ChatView.kt | `ChatItemsList.previewNow` | (a) CL | as row 83 | — |
| 87 | views/chat/ChatView.kt | `DateSeparator.previewNow` | (a) CL | as row 83 | — |
| 88 | views/chat/group/GroupMemberInfoView.kt | `previewContactConnectionStats` | (a) CI | `apiContactInfo` in `withBGApi` throws "Controller is not initialized" in the test JVM | db |
| 89 | views/chat/group/GroupProfileView.kt | `previewShortDescrFits` | (a) X | `previewsCallingCore`, as master | — |
| 90 | views/chatlist/ChatPreviewView.kt | `previewNow` | (a) CL | as row 84 | — |
| 91 | views/chatlist/ContactRequestView.kt | `previewNow` | (a) CL | as row 84 | — |
| 92 | views/database/DatabaseErrorView.kt | `previewStoredDBKey` | (a) CI | `ksDatabasePassword.get()` returns null with the CI test's settings | — |
| 93 | views/database/DatabaseErrorView.kt | `previewShowRestoreDbButton` | (a) CI | `shouldShowRestoreDbButton` returns false: `encryptionStartedAt` is null in the CI test's settings | — |
| 94 | views/database/DatabaseErrorView.kt | `previewUseKeychain` | (a) CI | `storeDBPassphrase` default, true | — |
| 95 | views/newchat/AddChannelView.kt | `AddChannelView.previewNameValid` | (a) X | `previewsCallingCore`, as master | — |
| 96 | views/newchat/AddChannelView.kt | `AddChannelView.previewHasRelays` | (a) X | as row 95 | — |
| 97 | views/newchat/AddChannelView.kt | `ProfileStepView.previewNameValid` | (a) X | as row 95 | — |
| 98 | views/newchat/AddChannelView.kt | `ProfileStepView.previewHasRelays` | (a) X | as row 95 | — |
| 99 | views/newchat/AddGroupView.kt | `previewNameValid` | (a) X | as row 95 | — |
| 100 | views/newchat/ContactConnectionInfoView.kt | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 101 | views/newchat/NewChatSheet.kt | `NewChatSheet.previewConnectTarget` | (a) CI | `chatParseMarkdown` in a `LaunchedEffect` throws in the test JVM, which does not load the core library; the scene renders as in master's reference | fn |
| 102 | views/newchat/NewChatSheet.kt | `NewChatSheetLayout.previewConnectTarget` | (a) CI | as row 101 | fn |
| 103 | views/newchat/NewChatSheet.kt | `ContactsSearchBar.previewConnectTarget` | (a) CI | as row 101 | fn |
| 104 | views/newchat/QRCode.kt | `SimpleXCreatedLinkQRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 105 | views/newchat/QRCode.kt | `SimpleXLinkQRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 106 | views/newchat/QRCode.kt | `QRCode.previewImage` | (a) S1 | fixed QR picture | — |
| 107 | views/onboarding/SimpleXInfo.kt | `previewMigrationState` | (a) CI | `chatModel.migrationState` is null with the CI test's settings | files |
| 108 | views/usersettings/UserAddressView.kt | `previewQRImage` | (a) S1 | fixed QR picture | — |
| 109 | views/usersettings/UserProfileView.kt | `previewShortDescrFits` | (a) X | as row 89; EditOff and EditOn | — |
Counts:
```
(a) 104
CI 64
CL 7
X 17
S1 14
SIM 2
(b) 5
(c) 0
```
## 3. Splits
Each loader keeps its view's name and parameters, so its callers are unchanged. The loader runs the call it ran before, at the same moment: the view's `.task` or `.onAppear` moves from the end of the layout's modifiers to the layout in the loader's body. State stays where it was: the loader owns the loaded value, the layout owns its UI state. The preview draws the layout with fixed values.
```
BadgesCheckOrderView Views/Badges/BadgesCheckOrderView.swift
loader BadgesCheckOrderView(level, period, startDate, priceList): .task { await BadgeStore.shared.load() }
layout BadgesCheckOrderLayout(level, period, startDate, priceList)
moved .task { await store.load() } (:107)
preview BadgesCheckOrderLayout
full lines 10 added, 1 removed, 1 changed
BadgesHowLongView Views/Badges/BadgesHowLongView.swift
loader BadgesHowLongView(level, startDate, priceList): .task { await BadgeStore.shared.load() }
layout BadgesHowLongLayout(level, startDate, priceList)
moved .task { await store.load() } (:145)
preview BadgesHowLongLayout
full lines 9 added, 1 removed, 1 changed
VersionView Views/UserSettings/VersionView.swift
loader VersionView: @State versionInfo, .onAppear { versionInfo = try apiGetVersion() }
layout VersionLayout(appVersion, appBuild, versionInfo)
moved .onAppear (:40-46), @EnvironmentObject theme (:13) into the layout
preview VersionLayout(appVersion: "7.1", appBuild: "363", versionInfo: CoreVersionInfo(version: "7.1.0.14", ...))
full lines 16 added, 8 removed, 1 changed
```
`store` of both badge layouts is `BadgeStore.shared` (`@ObservedObject private var store = BadgeStore.shared`), the object the loaders call.
## 4. Network evidence
Previews that kept a core, agent or StoreKit call:
| Rows | Scene | Code path | Network in CI |
|---|---|---|---|
| 2, 3, 4 | BadgesCheckOrderView | BadgesCheckOrderView.swift:107 `store.load()` → BadgeStore.swift:219 `Product.products(for:)`, :228 `subscriptionOriginalIds()` | StoreKit: yes; split |
| 7 | BadgesHowLongView | BadgesHowLongView.swift:145 `store.load()` → BadgeStore.swift:219 | StoreKit: yes; split |
| 10 | ChatInfoView | ChatInfoView.swift:305-326: :313 `apiContactInfo` → Commands.hs:1956-1964 (`getContact` :1958, agent `getConnectionServers` :1963 → Agent.hs:3128-3131 store read, :3141-3180 agent memory); :314 `apiGetContactCode` → Commands.hs:2040-2053, :3838-3839 → Agent.hs:3133-3138 store read | no |
| 15 | ChatView | ChatView.swift:333-338 → `initChatView` :743-803: :750 `apiContactInfo` (row 10) for the sample contact; :791 → :805-818 `apiGetChatContentTypes` → Commands.hs:738-739 store read; :772-790, with :783 `apiGetUpdatedGroupLinkData`, needs a group with `useRelays`; :797-801 needs an unread chat | no |
| 18 | ChannelRelaysView | ChannelRelaysView.swift:45-55: `loadGroupMembers` → ChatModel.swift:592-602 `apiListMembers` → Commands.hs:3293-3294 store read; :49 `apiGetGroupRelays` → Commands.hs:2790-2795 store read | no |
| 19 | GroupChatInfoView | GroupChatInfoView.swift:298-312: :305 `apiGetGroupLink` → Commands.hs:3392-3395 store read | no |
| 23 | GroupLinkView | GroupLinkView.swift:164-169 → :179-196 `apiCreateGroupLink(1)` → Commands.hs:3364-3377: `getGroupInfo` :3365 fails before `createConnection` :3373; the CI database has no group: smoke-test.sh:93-126 creates only the profile | no |
| 24 | GroupMemberInfoView | GroupMemberInfoView.swift:293-328: :301 `apiGroupMemberInfo` → Commands.hs:1986-1989 store read and agent memory; :303 `apiGetGroupMemberCode` → Commands.hs:2054-2078 store read; :320 `apiContactInfo` (row 10) | no |
| 26 | MemberSupportView | MemberSupportView.swift:22-26 `loadGroupMembers` (row 18) | no |
| 27 | SecondaryChatView | ChatView `initChatView` for a group: `GroupInfo.sampleData.useRelays` is false (SimpleXChat/ChatTypes.swift:3103), so :772-790 is skipped; :791 (row 15) | no |
| 31, 35 | ChatListView | ChatListView.swift:736-760 `startTask` :753 `getAgentSubsTotal` → Commands.hs:3760-3764 → Agent/Client.hs:2627-2645 agent memory | no |
| 38 | UserPicker | UserPicker.swift:100-116: :106 `listUsersAsync` → Commands.hs:517 store read | no |
| 51 | AddChannelView | AddChannelView.swift:112-113 → `checkHasRelays` :272-278: :273 `getUserServers` → Commands.hs:1816-1817 store read | no |
| 66 | ConnectDesktopView | ConnectDesktopView.swift:87-104: :88 `setLocalDeviceName` → Commands.hs:3704 variable write; :89 `listRemoteCtrls` → Commands.hs:3723 → Remote.hs:606-611 store read; :92 `disconnectDesktop` needs a desktop session; :94 `findKnownDesktop` needs `useMulticast` (:29-31), which needs a linked desktop | no |
| 71 | NetworkServersView | NetworkAndServers.swift:136-155: :140 `getUserServers` (row 51); :144 `validateServers_` → Commands.hs:1833-1834, :4322-4326 store read | no |
| 78 | NotificationsView | NotificationsView.swift:83-85 `apiGetNtfToken` → Commands.hs:1681 → Agent.hs:3332-3336 store read | no |
| 88 | GroupMemberInfoView.kt | GroupMemberInfoView.kt:502-509 `withBGApi { apiContactInfo }` → model/SimpleXAPI.kt:989 throws "Controller is not initialized" | no |
| — | ActiveCallOverlay | `WebRTCClient` of the preview: WebRTCClient.swift:25 `getChatCtrl()` → SimpleXChat/API.swift:19-22 reads the controller pointer; :82-89 sets two flags of `RTCAudioSession.sharedInstance()` | no |
## 5. QR picture (S1)
iOS, `Views/NewChat/QRCode.swift`:
```swift
extension EnvironmentValues {
struct FixedQRCode: EnvironmentKey {
static let defaultValue: UIImage? = nil
}
var fixedQRCode: UIImage? {
get { self[FixedQRCode.self] }
set { self[FixedQRCode.self] = newValue }
}
}
```
```
QRCode added @Environment(\.fixedQRCode) private var fixedQRCode
QRCode changed if let image = fixedQRCode ?? image {
QRCode changed .task { if fixedQRCode == nil { image = await generateImage(...) } }
```
Kotlin, `views/newchat/QRCode.kt`:
```
added val LocalFixedQRCode = staticCompositionLocalOf<ImageBitmap?> { null }
added val fixedQRCode = LocalFixedQRCode.current (QRCode, before remember)
changed (fixedQRCode ?: qrCodeBitmap(connReq, 1024, errorLevel)).replaceColor(...) (QRCode, in remember)
```
Snapshot tests:
```
link https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw, the address of tests/MarkdownTests.hs:324
iOS setUp: fixedQRCode = await generateImage(link, tintColor: QRCode's default tint, errorLevel: "M")
iOS renderScene root: .environment(\.fixedQRCode, fixedQRCode)
iOS QRCode.swift: generateImage is internal, so the test calls it
Kotlin renderSnapshot root: LocalFixedQRCode provides qrCodeBitmap(link, 1024, QrCode.ErrorLevel.M), in the theme of the render
```
The views add the logo, and on desktop the tint, to the picture, as to a generated one. Short links are drawn `small`, with error level M, in both apps.
Previews with links pass the short form of a preset server, as the core returns it (shortenShortLink): host only, without the key hash.
```
iOS GroupLinkView_Previews connShortLink https://smp6.simplex.im/g#cL3iklYlt_Tws1B_1aGylbT_3HowPMQhuCFoBiCjZ1M; shortLinkDataSet, shortLinkLargeDataSet true
iOS UserAddressView_Previews connShortLink the address link
iOS QRCode_Previews QRCode(uri: the address link, small: true)
Kotlin PreviewUserAddressLayoutAddressCreated connShortLink the address link; shortLinkDataSet, shortLinkLargeDataSet true
Kotlin PreviewContactConnectionInfoView connFullLink the invitation of master's QRCode_Previews; connShortLink https://smp4.simplex.im/i#Wu_NH3_F5YphQrIMSCSfWhvqz7tjpZsu/A2dCDGGVkh0-doRPIZVTM-TflHes2uYMVRgA_tK_uCs
Kotlin PreviewQRCode QRCode(connReq = the address link, small = true)
```
Each short link decodes as AConnShortLink and encodes back to the same text.
## 6. Other branch changes
```
ActiveCallView.swift, ActiveCallOverlay_Previews master
DesktopApp.kt, AppPreview deleted keep
SnapshotTests.swift, ActiveCallOverlay scene master
SnapshotTests.swift, SnapshotFixtures markdown table keep, values built with initializers
SnapshotTest.kt, setting pins and appBarMaxHeightPx keep
SnapshotTest.kt, AppPreview scene removed keep
check-previews.py, EXCLUDED master
SnapshotTest.kt, previewsCallingCore master
every other file master
```
## 7. App code
Full lines by `git diff -w --word-diff`, blank lines aside:
```
iOS Views/Badges/BadgesCheckOrderView.swift 10 added, 1 removed, 1 changed
iOS Views/Badges/BadgesHowLongView.swift 9 added, 1 removed, 1 changed
iOS Views/UserSettings/VersionView.swift 16 added, 8 removed, 1 changed
iOS Views/NewChat/QRCode.swift 10 added, 4 changed: 1 of them the preview
iOS Views/Chat/Group/GroupLinkView.swift 3 changed: preview
iOS Views/UserSettings/UserAddressView.swift 1 changed: preview
Kotlin views/newchat/QRCode.kt 2 added, 2 changed: 1 of them the preview
Kotlin views/newchat/ContactConnectionInfoView.kt 1 changed: preview
Kotlin views/usersettings/UserAddressView.kt 3 changed: preview
Kotlin desktopMain DesktopApp.kt 6 removed: preview and its import
```
+2 -2
View File
@@ -31,11 +31,11 @@ Queueing for a runner adds from under a minute to 20 minutes. Results are artifa
## Snapshots
`:common:desktopTest` runs `SnapshotTest` (`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt`). It renders each preview listed in `previewScenes` with Compose's `ImageComposeScene`, 402 dp wide at density 1, one pixel per dp, as tall as the preview's content up to 874 dp, so a preview that fills any height is 874 pixels tall. It renders the light and dark themes without a wallpaper, over the theme's background, and keeps the fourth frame, 3 s of frame time after the first. The scene's coroutine context sets the animation duration scale (`MotionDurationScale`) to 0, so each animation ends at its first frame and a spinner is drawn the same in every run. As the app does for its screens, the test provides an `AppBarHandler` (`LocalAppBarHandler`) and the window state (`simplexWindowState`, sized as the scene). Each preview is rendered at the default font size; the chat item previews (package `views.chat.item`) also at 1.4 and 1.5, the two largest values of the desktop app's font size setting.
`:common:desktopTest` runs `SnapshotTest` (`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt`). It renders each preview listed in `previewScenes` with Compose's `ImageComposeScene`, 402 dp wide at density 1, one pixel per dp, as tall as the preview's content up to 874 dp, so a preview that fills any height is 874 pixels tall. It renders the light and dark themes without a wallpaper, over the theme's background, and keeps the fourth frame, 3 s of frame time after the first. The scene's coroutine context sets the animation duration scale (`MotionDurationScale`) to 0, so each animation ends at its first frame and a spinner is drawn the same in every run. As the app does for its screens, the test provides an `AppBarHandler` (`LocalAppBarHandler`), the window state (`simplexWindowState`, sized as the scene) and the app bar height in pixels (`AppBarHandler.appBarMaxHeightPx`, which `AppScreen` sets), and restores the height when it finishes. Each preview is rendered at the default font size; the chat item previews (package `views.chat.item`) also at 1.4 and 1.5, the two largest values of the desktop app's font size setting.
`everyPreviewIsRendered` reads the Kotlin sources of `commonMain` and `desktopMain` without comments, and fails when a `@Preview` function is missing from both `previewScenes` and `previewsCallingCore`, or when either names a function absent from the sources; a scene is named `<file>.<function>`. `previewsCallingCore` lists the previews whose layouts validate names or descriptions with the core library (`chatValidName`, `chatJsonLength`), which the test JVM does not load, so they are not rendered. The previews of `androidMain` and of the Android app module are left out too, since they use Android APIs.
The test runs in UTC and en_US (the JVM's default `TimeZone` and `Locale`), and replaces the theme overrides with ones without a wallpaper. It sets the app's theme and font size for each render, and restores these settings and defaults when it finishes. Before each render it clears the terminal's items, which previews that send commands to the core add with the time they are sent, so that `PreviewTerminalLayout` shows an empty terminal. Previews show `previewDate` (`ChatItemView.kt`) in place of the current time.
The test runs in UTC and en_US (the JVM's default `TimeZone` and `Locale`), and replaces the theme overrides with ones without a wallpaper. It sets the app's theme and font size for each render, sets the density scale, the one-hand UI, the chat toolbar position, the message shape, the media blur and the bars' blur and opacity to their defaults, and restores these settings and defaults when it finishes. Before each render it clears the terminal's items, which previews that send commands to the core add with the time they are sent, so that `PreviewTerminalLayout` shows an empty terminal. Previews show `previewDate` (`ChatItemView.kt`) in place of the current time. The test provides `LocalFixedQRCode` (`QRCode.kt`) with the bitmap that `qrCodeBitmap` draws for a short address link in the theme of the render, so every QR code draws this bitmap.
The images go to `SNAPSHOT_DIR`, otherwise to `apps/multiplatform/common/build/snapshots`, as PNG files named `<file>.<function>-<light|dark>-font-<100|140|150>.png`, with `index-desktop.json` listing each file's scene, theme, font scale, and pixel width and height. The app job sets `SNAPSHOT_DIR` to `snapshots/` of the artifact `desktop-snapshots`, then the step "Compare snapshots" runs `scripts/ci/snapshots-compare.py` with the references in `apps/multiplatform/common/src/desktopTest/snapshots`, as the iOS workflow does ([iOS](../ios-sim/README.md), "Snapshots"): a missing, extra or resized render, or a pixel beyond the tolerance, fails the step, and the diff images go to `snapshots-diff/` of the same artifact.
+1 -1
View File
@@ -46,7 +46,7 @@ At the end of the app job's smoke test, `smoke-test.sh` runs `snapshots.sh <deri
`previewScenes()` lists every `PreviewProvider` in `apps/ios` except those in `EXCLUDED` of `apps/ios/SimpleX Snapshots/check-previews.py`, whose renders differ between runs and can be made identical only by a change of the app's code. The iOS job of `build.yml` runs `check-previews.py`, which fails when a `PreviewProvider` is neither listed nor excluded, or when a file has a `#Preview`, since the test renders previews by their type.
The test runs in UTC (`NSTimeZone.default`) and en_US (`-testLanguage en -testRegion US`), so the renders do not depend on the runner's time zone or locale; a preview that shows the current date or time differs between runs, so the previews and the sample contact use `previewDate` (`SimpleXChat/ChatTypes.swift`) in place of the current time. The keyboard autocorrects the profile name typed in the smoke test differently between runs, so the test sets the app's current user to the sample user.
The test runs in UTC (`NSTimeZone.default`) and en_US (`-testLanguage en -testRegion US`), so the renders do not depend on the runner's time zone or locale; a preview that shows the current date or time differs between runs, so the previews and the sample contact use `previewDate` (`SimpleXChat/ChatTypes.swift`) in place of the current time. The test sets the environment value `fixedQRCode` (`QRCode.swift`) to the image that `generateImage` draws for a short address link, so every QR code draws this image. The keyboard autocorrects the profile name typed in the smoke test differently between runs, so the test sets the app's current user to the sample user.
The images go to `snapshots/` of the artifact `ios-sim-smoke-test` as 8-bit sRGB PNG files without alpha (`preferredRange = .standard`, `opaque`), named `<preview>-<light|dark>-<size>.png`, with `index-<size>.json` listing each file's scene, theme, size, pixel width and height, scale and the content size category the app saw.