diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index 1cd3ca7..fa004a7 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -146,6 +146,9 @@ dependencies { testImplementation(libs.robolectric) testImplementation(platform(libs.androidx.compose.bom)) testImplementation("androidx.compose.ui:ui-test-junit4") + // The baselines the phone's screenshots are compared against. A regression is then + // reported the same way on both ends instead of only being seen by whoever looks. + testImplementation(project(":testing")) debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt index fc5d239..b0ff73a 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt @@ -612,7 +612,7 @@ private fun Overlays(state: AndroidAppState) { fun RunningScreen(state: AndroidAppState, jobId: String) { val colors = CubTheme.colors val running = state.running - val elapsed = running?.let { System.currentTimeMillis() - it.startedAtMs } ?: 0 + val elapsed = running?.let { state.now() - it.startedAtMs } ?: 0 if (running == null) { EmptyState( diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt index f17e1ee..c4e5a50 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt @@ -97,6 +97,8 @@ class AndroidAppState( val server: ServerController, val scope: CoroutineScope, val appDirectory: File, + /** Where elapsed times come from; pinned by the screenshot test, untouched by the app. */ + val now: () -> Long = System::currentTimeMillis, ) { var tab by mutableStateOf(Section.CONNECT) @@ -630,7 +632,7 @@ class AndroidAppState( configName = prepared.name, dryRun = dryRun, pairs = prepared.pairs.map { it.name.ifEmpty { it.pairId } }, - startedAtMs = System.currentTimeMillis(), + startedAtMs = now(), ) dismissNotice() open(Route.Running(jobId)) diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt index c47011e..042085c 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt @@ -86,7 +86,7 @@ fun ConnectScreen(state: AndroidAppState) { } val running = state.running val rate = if (running != null) { - val elapsed = System.currentTimeMillis() - running.startedAtMs + val elapsed = state.now() - running.startedAtMs if (elapsed > 0) Format.rate(running.bytes, elapsed) else "0.0 MB/s" } else { "0.0 MB/s" diff --git a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt index b026c01..a060671 100644 --- a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt +++ b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt @@ -9,17 +9,23 @@ import io.github.cubsync.core.model.Endpoint import io.github.cubsync.core.model.FolderPair import io.github.cubsync.core.model.SyncConfig import io.github.cubsync.core.model.SyncDirection +import io.github.cubsync.core.util.UtcClock import io.github.cubsync.service.AppLog import io.github.cubsync.service.EndpointId +import io.github.cubsync.service.Format import io.github.cubsync.service.Section import io.github.cubsync.service.ServerController import io.github.cubsync.service.SettingsStorage import io.github.cubsync.service.Side import io.github.cubsync.service.SyncService import io.github.cubsync.service.ThemeMode +import io.github.cubsync.testing.ScreenshotBaseline +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.FileTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,6 +39,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertTrue /** * Every phone screen, composed off screen and written to a PNG. @@ -53,6 +60,9 @@ class PhoneScreenshotTest { @get:Rule val compose = createAndroidComposeRule() + /** Screens whose rendering no longer matches the baseline, reported together. */ + private val drifted = ArrayList() + @Test fun every_screen_renders() { val fixture = Fixture() @@ -140,9 +150,16 @@ class PhoneScreenshotTest { } finally { fixture.close() } + assertTrue( + drifted.isEmpty(), + "these screens no longer match the baseline:\n\n" + drifted.joinToString("\n\n"), + ) } - /** Draw the whole window into a PNG, without whatever toast happens to be up. */ + /** + * Draw the whole window into a PNG, without whatever toast happens to be up, and + * compare it with the picture this screen is supposed to be. + */ private fun shot(name: String, fixture: Fixture) { fixture.state.dismissNotice() compose.waitForIdle() @@ -160,27 +177,46 @@ class PhoneScreenshotTest { val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) decor.draw(Canvas(bitmap)) + val bytes = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes) + val png = bytes.toByteArray() + val output = File("build/screenshots-phone").apply { mkdirs() } - File(output, "$name.png").outputStream().use { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) - } + File(output, "$name.png").writeBytes(png) + + // Filed per operating system like the desktop's: Robolectric draws with the host's + // text rasteriser, and a picture from Windows is not the picture Linux produces. + ScreenshotBaseline.compare(name, png, category = "phone")?.let { drifted += it } } - /** A state with real folders in it, so a listing and a run have something to show. */ + /** + * A state with real folders in it, so a listing and a run have something to show. + * + * Everything a screen writes as a time is pinned to [NOW]: the run's clock, the log's + * clock, the file timestamps, and the clock "3 分钟前" is measured against. Without + * that the pictures would differ every run, and a baseline that is always different + * proves nothing. + */ private class Fixture { - val directory: Path = Files.createTempDirectory("cubsync-phone-shot-") - val log = AppLog() + // A path that does not change: it is drawn in the pane's header, and a random + // one would make every run a different picture than the last. + val directory: Path = Paths.get(System.getProperty("java.io.tmpdir"), "cubsync-phone-shot") + .also { runCatching { it.toFile().deleteRecursively() } } + .also { Files.createDirectories(it) } + val log = AppLog(now = { NOW }) val service = SyncService( stateDirectory = directory, log = log, settingsStorage = SettingsStorage.memory(), + utcClock = UtcClock { NOW }, ) val server = ServerController(log) val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - val state = AndroidAppState(service, server, scope, directory.toFile()) + val state = AndroidAppState(service, server, scope, directory.toFile(), now = { NOW }) val jobId: String init { + Format.clock = { NOW } val camera = folder("DCIM/Camera") seed(camera, "IMG_20261016_0731.jpg", 2_400) seed(camera, "IMG_20261016_0730.jpg", 1_950) @@ -233,7 +269,7 @@ class PhoneScreenshotTest { configName = "照片库", dryRun = false, pairs = listOf("相机 → 备份", "相册 ↔ DCIM/相册", "视频 ↔ Movies", "文档 ↔ Documents"), - startedAtMs = System.currentTimeMillis() - 42_000, + startedAtMs = NOW - 42_000, currentPair = "视频 ↔ Movies", finishedPairs = 2, bytes = 2_900_000_000L, @@ -247,12 +283,21 @@ class PhoneScreenshotTest { } private fun seed(directory: Path, name: String, length: Int) { - Files.write(directory.resolve(name), ByteArray(length)) + val file = directory.resolve(name) + Files.write(file, ByteArray(length)) + // Fixed, so the 修改时间 column reads the same in every run. + Files.setLastModifiedTime(file, FileTime.fromMillis(NOW - 3_600_000)) } fun close() { runCatching { state.close() } scope.cancel() + Format.clock = System::currentTimeMillis } } + + private companion object { + /** 2026-10-17 20:00, the moment every screenshot is taken at. */ + const val NOW = 1_792_238_400_000L + } } diff --git a/app/android/src/test/resources/screenshots/phone/windows/01-connect.png b/app/android/src/test/resources/screenshots/phone/windows/01-connect.png new file mode 100644 index 0000000..7f7ec2c Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/01-connect.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/02-sync-list.png b/app/android/src/test/resources/screenshots/phone/windows/02-sync-list.png new file mode 100644 index 0000000..5b4ed63 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/02-sync-list.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/03-sync-config.png b/app/android/src/test/resources/screenshots/phone/windows/03-sync-config.png new file mode 100644 index 0000000..de0aad9 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/03-sync-config.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/04-sync-preview.png b/app/android/src/test/resources/screenshots/phone/windows/04-sync-preview.png new file mode 100644 index 0000000..c1c0384 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/04-sync-preview.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/05-running.png b/app/android/src/test/resources/screenshots/phone/windows/05-running.png new file mode 100644 index 0000000..261f41c Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/05-running.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/06-jobs.png b/app/android/src/test/resources/screenshots/phone/windows/06-jobs.png new file mode 100644 index 0000000..9e35137 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/06-jobs.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/07-logs.png b/app/android/src/test/resources/screenshots/phone/windows/07-logs.png new file mode 100644 index 0000000..a306196 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/07-logs.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/08-job-detail.png b/app/android/src/test/resources/screenshots/phone/windows/08-job-detail.png new file mode 100644 index 0000000..4b99730 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/08-job-detail.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/09-settings.png b/app/android/src/test/resources/screenshots/phone/windows/09-settings.png new file mode 100644 index 0000000..eeaf8ce Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/09-settings.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/10-transfer.png b/app/android/src/test/resources/screenshots/phone/windows/10-transfer.png new file mode 100644 index 0000000..ab951a6 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/10-transfer.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/11-drawer.png b/app/android/src/test/resources/screenshots/phone/windows/11-drawer.png new file mode 100644 index 0000000..6958162 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/11-drawer.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/12-transfer-compare.png b/app/android/src/test/resources/screenshots/phone/windows/12-transfer-compare.png new file mode 100644 index 0000000..dbf31af Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/12-transfer-compare.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/13-transfer-card.png b/app/android/src/test/resources/screenshots/phone/windows/13-transfer-card.png new file mode 100644 index 0000000..296c416 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/13-transfer-card.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/14-more-sheet.png b/app/android/src/test/resources/screenshots/phone/windows/14-more-sheet.png new file mode 100644 index 0000000..6a92e2e Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/14-more-sheet.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/15-transfer-compact.png b/app/android/src/test/resources/screenshots/phone/windows/15-transfer-compact.png new file mode 100644 index 0000000..f745730 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/15-transfer-compact.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/16-transfer-thumb.png b/app/android/src/test/resources/screenshots/phone/windows/16-transfer-thumb.png new file mode 100644 index 0000000..2155249 Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/16-transfer-thumb.png differ diff --git a/app/android/src/test/resources/screenshots/phone/windows/17-transfer-viewbar.png b/app/android/src/test/resources/screenshots/phone/windows/17-transfer-viewbar.png new file mode 100644 index 0000000..c85d60b Binary files /dev/null and b/app/android/src/test/resources/screenshots/phone/windows/17-transfer-viewbar.png differ diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 1687aad..0685a40 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { // The screenshot test composes the real screens off screen, so it needs the // same runtime the window uses. testImplementation(compose.desktop.currentOs) + // The baselines those screenshots are compared against — same check the phone runs, + // so the two ends cannot drift apart in how a regression is reported. + testImplementation(project(":testing")) } compose.desktop { diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt index 6bf33b9..45f043f 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt @@ -91,6 +91,8 @@ class AppState( val server: ServerController, val settings: DesktopSettings, private val scope: CoroutineScope, + /** Where elapsed times come from; pinned by the screenshot test, untouched by the app. */ + val now: () -> Long = System::currentTimeMillis, ) { var section by mutableStateOf(Section.CONNECT) @@ -631,7 +633,7 @@ class AppState( configName = prepared.name, dryRun = dryRun, pairCount = prepared.pairs.size, - startedAtMs = System.currentTimeMillis(), + startedAtMs = now(), ) dismissNotice() diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt index f4ff8e3..b57a69a 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt @@ -271,7 +271,7 @@ private fun ChannelsCard(state: AppState, server: ServerState) { } val adbOn = selected.contains(Channels.USB_ADB_NAME) val rate = state.running?.let { - val elapsed = System.currentTimeMillis() - it.startedAtMs + val elapsed = state.now() - it.startedAtMs if (elapsed > 0) Format.rate(it.bytes, elapsed) else DASH } ?: DASH diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt index bf460c2..43a9cb5 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt @@ -446,7 +446,7 @@ private fun HeadActions(state: AppState) { private fun StatusBar(state: AppState, server: ServerState) { val colors = CubTheme.colors val running = state.running - val elapsed = if (running != null) System.currentTimeMillis() - running.startedAtMs else 0 + val elapsed = if (running != null) state.now() - running.startedAtMs else 0 val rate = if (running != null && elapsed > 0) Format.rate(running.bytes, elapsed) else DASH Row( diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt index b599536..5c17db2 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt @@ -757,7 +757,7 @@ private fun TransferBar(state: AppState) { Text( when { progress != null -> progressLine(progress!!) - running != null -> Format.duration(System.currentTimeMillis() - running.startedAtMs) + running != null -> Format.duration(state.now() - running.startedAtMs) else -> "选中文件后点中间的 → / ← 发起传输" }, style = CubType.meta, diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt index 3a01232..e6c5cbd 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt @@ -12,6 +12,7 @@ import io.github.cubsync.core.model.Endpoint import io.github.cubsync.core.model.FolderPair import io.github.cubsync.core.model.SyncConfig import io.github.cubsync.core.model.SyncDirection +import io.github.cubsync.core.util.UtcClock import io.github.cubsync.service.AppLog import io.github.cubsync.service.DisplayMode import io.github.cubsync.service.EndpointId @@ -29,9 +30,22 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import java.nio.file.Files import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.FileTime -/** A folder under the preview's own scratch area, created on first use. */ -private val scratch: Path by lazy { Files.createTempDirectory("cubsync-preview-data-") } +/** + * The preview's own scratch area, at a path that does not change. + * + * A random directory would be drawn in the breadcrumb, and every run would then be a + * different picture — the one thing a baseline cannot be. It is emptied first so a run + * sees what it seeded and nothing a previous run left behind. + */ +private val scratch: Path by lazy { + val dir = Paths.get(System.getProperty("java.io.tmpdir"), "cubsync-preview-data") + runCatching { dir.toFile().deleteRecursively() } + Files.createDirectories(dir) + dir +} internal fun previewFolder(name: String): Path { val path = scratch.resolve(name) @@ -42,7 +56,10 @@ internal fun previewFolder(name: String): Path { /** Writes a file whose length differs from its neighbours', which is what the list shows. */ private fun seedFile(directory: Path, name: String, length: Int) { Files.createDirectories(directory) - Files.write(directory.resolve(name), ByteArray(length)) + val file = directory.resolve(name) + Files.write(file, ByteArray(length)) + // Fixed, so the 修改时间 column reads the same tomorrow as it does today. + Files.setLastModifiedTime(file, FileTime.fromMillis(Preview.NOW - 3_600_000)) } /** @@ -112,12 +129,17 @@ object Preview { displayMenu: Boolean = false, ): AppState { val directory = Files.createTempDirectory("cubsync-preview-") - val log = AppLog() + val log = AppLog(now = { NOW }) val desktop = DesktopSettings.inStateDirectory(directory) - val service = SyncService(stateDirectory = directory, log = log, settingsStorage = desktop) + val service = SyncService( + stateDirectory = directory, + log = log, + settingsStorage = desktop, + utcClock = UtcClock { NOW }, + ) val server = ServerController(log) val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) - val state = AppState(service, server, desktop, scope) + val state = AppState(service, server, desktop, scope, now = { NOW }) seed() seedLog(log) @@ -154,6 +176,15 @@ object Preview { return state } + /** + * 2026-10-17 20:00 — the moment every preview is drawn at. + * + * The window writes times: when a run started, when a file changed, how long ago the + * last one finished. Left to the wall clock those are different on every run, and a + * baseline that never matches is not a baseline. + */ + const val NOW = 1_792_238_400_000L + /** Releases everything the state owns. */ fun dispose(state: AppState) { runCatching { state.close() } @@ -198,7 +229,7 @@ object Preview { /** A run that partly failed, because a run that went perfectly shows fewer states. */ private fun finishedRun(config: SyncConfig): JobRecord { - val now = System.currentTimeMillis() + val now = NOW val first = listOf( JobItem("DCIM/Camera/IMG_20261016_0731.jpg", ActionKind.COPY_LEFT_TO_RIGHT, bytes = 2_400), JobItem("DCIM/Camera/IMG_20261016_0730.jpg", ActionKind.COPY_LEFT_TO_RIGHT, bytes = 1_950), diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt index 7b6bda3..7283000 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt @@ -3,6 +3,8 @@ package io.github.cubsync.desktop import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density import io.github.cubsync.desktop.ui.CubApp +import io.github.cubsync.service.Format +import io.github.cubsync.testing.ScreenshotBaseline import java.nio.file.Files import java.nio.file.Paths import kotlin.test.Test @@ -10,12 +12,17 @@ import kotlin.test.assertTrue import org.junit.Assume.assumeTrue /** - * Every screen, composed off screen and written to a PNG. + * Every screen, composed off screen and written to a PNG, then compared with the picture + * that screen is supposed to be. * * This is the check that a layout actually lays out. A screen that throws while composing * fails this test rather than being discovered by whoever opens the window next, and the * images double as the thing a reviewer looks at when the question is "does it look like * the design" — a question no assertion answers well. + * + * The comparison is what stops a fix in one screen from being a new bug in another: the + * baseline under `src/test/resources/screenshots` was rendered by this test, so any + * difference is a difference the code made. */ class ScreenshotTest { @@ -30,31 +37,40 @@ class ScreenshotTest { Files.createDirectories(output) val written = ArrayList() - - for (shot in Preview.shots) { - val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view, shot.displayMenu) - try { - val scene = ImageComposeScene(width = WIDTH, height = HEIGHT, density = Density(1f)) { - CubTheme(dark = shot.dark) { CubApp(state, Preview.chrome) } - } - val image = try { - scene.render() + val drifted = ArrayList() + // "3 分钟前" is measured against a pinned clock, or the picture changes with the + // passing of the day and the baseline means nothing. + Format.clock = { Preview.NOW } + try { + for (shot in Preview.shots) { + val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view, shot.displayMenu) + try { + val scene = ImageComposeScene(width = WIDTH, height = HEIGHT, density = Density(1f)) { + CubTheme(dark = shot.dark) { CubApp(state, Preview.chrome) } + } + val image = try { + scene.render() + } finally { + scene.close() + } + val data = requireNotNull(image.encodeToData()) { "could not encode ${shot.name}" } + val file = output.resolve("${shot.name}.png") + Files.write(file, data.bytes) + written += file.toString() + ScreenshotBaseline.compare(shot.name, data.bytes)?.let { drifted += it } } finally { - scene.close() + Preview.dispose(state) } - val data = requireNotNull(image.encodeToData()) { "could not encode ${shot.name}" } - val file = output.resolve("${shot.name}.png") - Files.write(file, data.bytes) - written += file.toString() - } finally { - Preview.dispose(state) } + } finally { + Format.clock = System::currentTimeMillis } assertTrue(written.size == Preview.shots.size, "expected ${Preview.shots.size} images, wrote ${written.size}") for (path in written) { assertTrue(Files.size(Paths.get(path)) > 4_000, "$path is suspiciously small") } + assertTrue(drifted.isEmpty(), drifted.joinToString("\n\n")) } private fun graphicsAvailable(): Boolean = runCatching { diff --git a/app/desktop/src/test/resources/screenshots/windows/00-transfer.png b/app/desktop/src/test/resources/screenshots/windows/00-transfer.png new file mode 100644 index 0000000..1c16c24 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/00-transfer.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/01-connect.png b/app/desktop/src/test/resources/screenshots/windows/01-connect.png new file mode 100644 index 0000000..fe03f8d Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/01-connect.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/02-sync.png b/app/desktop/src/test/resources/screenshots/windows/02-sync.png new file mode 100644 index 0000000..30506e7 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/02-sync.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/03-jobs.png b/app/desktop/src/test/resources/screenshots/windows/03-jobs.png new file mode 100644 index 0000000..4266db9 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/03-jobs.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/04-job-detail.png b/app/desktop/src/test/resources/screenshots/windows/04-job-detail.png new file mode 100644 index 0000000..d4280fe Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/04-job-detail.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/05-logs.png b/app/desktop/src/test/resources/screenshots/windows/05-logs.png new file mode 100644 index 0000000..a1b6ee3 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/05-logs.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/06-settings.png b/app/desktop/src/test/resources/screenshots/windows/06-settings.png new file mode 100644 index 0000000..6b8b3c0 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/06-settings.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/07-transfer-light.png b/app/desktop/src/test/resources/screenshots/windows/07-transfer-light.png new file mode 100644 index 0000000..d19705f Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/07-transfer-light.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/08-transfer-compare.png b/app/desktop/src/test/resources/screenshots/windows/08-transfer-compare.png new file mode 100644 index 0000000..91572a2 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/08-transfer-compare.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/09-transfer-card.png b/app/desktop/src/test/resources/screenshots/windows/09-transfer-card.png new file mode 100644 index 0000000..4f505aa Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/09-transfer-card.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/10-transfer-viewmenu.png b/app/desktop/src/test/resources/screenshots/windows/10-transfer-viewmenu.png new file mode 100644 index 0000000..fcfec3b Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/10-transfer-viewmenu.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/11-transfer-compact.png b/app/desktop/src/test/resources/screenshots/windows/11-transfer-compact.png new file mode 100644 index 0000000..47ba7b6 Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/11-transfer-compact.png differ diff --git a/app/desktop/src/test/resources/screenshots/windows/12-transfer-thumb.png b/app/desktop/src/test/resources/screenshots/windows/12-transfer-thumb.png new file mode 100644 index 0000000..da887cd Binary files /dev/null and b/app/desktop/src/test/resources/screenshots/windows/12-transfer-thumb.png differ diff --git a/docs/TESTING.md b/docs/TESTING.md index 93b31e4..b3c35ed 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -77,6 +77,42 @@ that manifest is debug-only on purpose. **CLI tests** (`cli/src/test`) cover argument parsing, the quirks of flags that take no value, and the summary format. +## Screenshot baselines + +Rendering a screen proves it composes. Comparing it with the picture that screen is +supposed to be proves something stronger: that a change somewhere else did not +quietly move it. Both screenshot tests do the second as well as the first. + +The baselines are in the repository, drawn by the tests themselves: + +| End | Baselines | +|---|---| +| Desktop | `app/desktop/src/test/resources/screenshots//` | +| Phone | `app/android/src/test/resources/screenshots/phone//` | + +Both are filed per operating system: text goes through the host's rasteriser, and a +run on Linux is not the same picture as a run on Windows. A machine with no baseline +for its own system records one and passes, so CI on Linux neither verifies nor lies +about the Windows baselines. The comparison is only as good as the baseline that +belongs to the machine running it. + +A screen fails when more than 0.1% of its pixels differ by more than 10 per channel. +The failure names the screen, the share that moved, and the region it moved in, and +writes three images to `build/screenshots-diff//`: the baseline, this run, and +a diff with the changed pixels in red. That is enough to see which control moved +without opening the window. + +`CUBSYNC_SCREENSHOTS=update` accepts the current rendering as the new baseline: + +``` +CUBSYNC_SCREENSHOTS=update ./gradlew :app:desktop:test --tests "*ScreenshotTest*" +CUBSYNC_SCREENSHOTS=update ./gradlew :app:android:testDebugUnitTest --tests "*PhoneScreenshotTest*" +``` + +Run it when the change was meant, and commit the images with the code that changed +them. A baseline that does not exist yet is written rather than failed, so a screen +that gains coverage is not a red build on somebody else's machine. + ## End-to-end acceptance The acceptance for the first milestone is a real transfer between two processes, diff --git a/service/src/main/kotlin/io/github/cubsync/service/AppLog.kt b/service/src/main/kotlin/io/github/cubsync/service/AppLog.kt index 981769d..b4a2ad0 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/AppLog.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/AppLog.kt @@ -28,7 +28,16 @@ data class LogEntry( * CLI can print it and the desktop app can render it without either knowing how the * other works. */ -class AppLog(private val limit: Int = DEFAULT_LIMIT) { +class AppLog( + private val limit: Int = DEFAULT_LIMIT, + /** + * Where a line's timestamp comes from. + * + * A parameter rather than a call to the wall clock so a screenshot of the log can be + * the same picture tomorrow; the application never passes one. + */ + private val now: () -> Long = System::currentTimeMillis, +) { private val lock = Any() private val buffer = ArrayDeque(minOf(limit, 256)) @@ -49,7 +58,7 @@ class AppLog(private val limit: Int = DEFAULT_LIMIT) { fun write(level: LogLevel, area: String, message: String, detail: String = ""): LogEntry { val entry = synchronized(lock) { - val created = LogEntry(++sequence, System.currentTimeMillis(), level, area, message, detail) + val created = LogEntry(++sequence, now(), level, area, message, detail) buffer.addLast(created) while (buffer.size > limit) { buffer.removeFirst() diff --git a/service/src/main/kotlin/io/github/cubsync/service/Format.kt b/service/src/main/kotlin/io/github/cubsync/service/Format.kt index a5476a1..ac3031a 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Format.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Format.kt @@ -23,6 +23,15 @@ object Format { private val time = DateTimeFormatter.ofPattern("HH:mm:ss") private val dateTime = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss") + /** + * The clock a relative time is measured against. + * + * "3 分钟前" is the one thing on a screen that is different every time it is drawn. + * The screenshot tests pin it so that a picture taken on Tuesday can still be the + * baseline on Friday; the application never touches it. + */ + var clock: () -> Long = System::currentTimeMillis + fun bytes(value: Long): String { if (value < 0) return "-" if (value < 1024) return "$value B" @@ -52,7 +61,7 @@ object Format { if (epochMs <= 0) "-" else dateTime.format(Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault())) /** A rough "how long ago", which is what a person reads in a list. */ - fun ago(epochMs: Long, now: Long = System.currentTimeMillis()): String { + fun ago(epochMs: Long, now: Long = clock()): String { if (epochMs <= 0) return "从未" val seconds = (now - epochMs) / 1000 return when { diff --git a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt index 58795a8..a40fced 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt @@ -10,6 +10,8 @@ import io.github.cubsync.core.engine.PairOutcome import io.github.cubsync.core.model.FolderPair import io.github.cubsync.core.model.SyncConfig import io.github.cubsync.core.model.SyncDirection +import io.github.cubsync.core.util.UtcClock +import io.github.cubsync.core.util.systemUtcClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -86,6 +88,13 @@ class SyncService( * SharedPreferences — while everything that reads it does not. */ settingsStorage: SettingsStorage = SettingsStorage.memory(), + /** + * Where a run's timestamps come from. + * + * Handed to the engine rather than left to the wall clock, so a screenshot test can + * decide what "now" is and a picture of a finished run stays the same picture. + */ + val utcClock: UtcClock = systemUtcClock(), ) { /** The settings screen's contents, and the browsing preferences derived from them. */ @@ -117,7 +126,7 @@ class SyncService( suspend fun run( config: SyncConfig, - jobId: String = newJobId(), + jobId: String = newJobId(Instant.ofEpochMilli(utcClock.nowMs())), observer: SyncObserver = SyncObserver.NONE, pool: BackendPool? = browsePool, /** @@ -159,6 +168,7 @@ class SyncService( // stopped", so the engine has to read it rather than always continuing. resumeEnabled = settings.resumeEnabled, gate = gate, + utcClock = utcClock, ) val result = runner.run(config, jobId) log.write( diff --git a/testing/src/jvmMain/kotlin/io/github/cubsync/testing/ScreenshotBaseline.kt b/testing/src/jvmMain/kotlin/io/github/cubsync/testing/ScreenshotBaseline.kt new file mode 100644 index 0000000..193ba4a --- /dev/null +++ b/testing/src/jvmMain/kotlin/io/github/cubsync/testing/ScreenshotBaseline.kt @@ -0,0 +1,132 @@ +package io.github.cubsync.testing + +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import javax.imageio.ImageIO + +/** + * A screenshot kept in the repository, and a check that the screen still looks like it. + * + * The point is not "does this look like the design" — a person answers that better. The + * point is that changing the transfer screen must not quietly repaint the jobs screen, + * which is how a round of fixes becomes a round of new bugs. Every image here was + * rendered by the tests themselves, so a difference means the code changed, not that a + * designer did. + * + * Baselines live under `src/test/resources/screenshots`, filed per operating system: + * both ends draw text through the platform's rasteriser, and a run on Linux is not the + * same picture as a run on Windows. A machine with no baseline for its system records + * one instead of failing, so the check is honest about what it did or did not verify. + * + * Set `CUBSYNC_SCREENSHOTS=update` to accept the current rendering as the new baseline. + * A missing baseline is written rather than failed, so a screen that gains coverage is + * not a red build on somebody else's machine. + */ +object ScreenshotBaseline { + + /** How far a channel may drift before the pixel counts as changed; anti-aliasing, not a redesign. */ + private const val CHANNEL_TOLERANCE = 10 + + /** Share of pixels allowed to differ. One in a thousand is a smudge; a moved control is more. */ + private const val CHANGED_SHARE = 0.001 + + private val baselineRoot: Path = Paths.get("src", "test", "resources", "screenshots") + private val reportRoot: Path = Paths.get("build", "screenshots-diff") + + private val updating: Boolean + get() = System.getenv("CUBSYNC_SCREENSHOTS")?.equals("update", ignoreCase = true) == true + + /** + * Compares the rendering against the baseline, or records it. + * + * Returns a failure message, or null when the screen matches. The test that calls this + * asserts on the message, so a regression reads as "transfer.png: 4.2% of pixels moved" + * plus the three images it left behind, rather than as a number nobody can act on. + */ + fun compare( + name: String, + png: ByteArray, + category: String? = null, + perOperatingSystem: Boolean = true, + ): String? { + val dir = (category?.let { baselineRoot.resolve(it) } ?: baselineRoot) + .let { if (perOperatingSystem) it.resolve(platform()) else it } + val baseline = dir.resolve("$name.png") + Files.createDirectories(dir) + + if (updating || !Files.exists(baseline)) { + Files.write(baseline, png) + println("[screenshots] recorded $name -> $baseline") + return null + } + + val expected = decode(Files.readAllBytes(baseline)) + ?: return "screenshots/$name: the baseline is not a readable PNG" + val actual = decode(png) + ?: return "screenshots/$name: the rendering could not be decoded" + + if (expected.width != actual.width || expected.height != actual.height) { + return "screenshots/$name: size is ${actual.width}x${actual.height}, baseline is " + + "${expected.width}x${expected.height} — the window or the density changed" + } + + var changed = 0 + var minX = Int.MAX_VALUE + var minY = Int.MAX_VALUE + var maxX = -1 + var maxY = -1 + val diff = BufferedImage(actual.width, actual.height, BufferedImage.TYPE_INT_RGB) + for (y in 0 until actual.height) { + for (x in 0 until actual.width) { + val a = actual.getRGB(x, y) + val e = expected.getRGB(x, y) + val delta = maxOf( + kotlin.math.abs((a shr 16 and 0xFF) - (e shr 16 and 0xFF)), + kotlin.math.abs((a shr 8 and 0xFF) - (e shr 8 and 0xFF)), + kotlin.math.abs((a and 0xFF) - (e and 0xFF)), + ) + if (delta > CHANNEL_TOLERANCE) { + changed++ + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + diff.setRGB(x, y, 0xFF0000) + } else { + val grey = (a shr 16 and 0xFF) * 3 / 10 + diff.setRGB(x, y, (grey shl 16) or (grey shl 8) or grey) + } + } + } + + val total = actual.width * actual.height + val share = changed.toDouble() / total + if (share <= CHANGED_SHARE) return null + + val out = reportRoot.resolve(name).also { Files.createDirectories(it) } + Files.write(out.resolve("actual.png"), png) + Files.copy(baseline, out.resolve("baseline.png"), java.nio.file.StandardCopyOption.REPLACE_EXISTING) + ImageIO.write(diff, "png", out.resolve("diff.png").toFile()) + return "screenshots/$name: ${format(share)} of pixels moved " + + "($changed of $total, region ${maxX - minX + 1}x${maxY - minY + 1} at $minX,$minY) — " + + "compare baseline.png / actual.png / diff.png in $out; " + + "run with CUBSYNC_SCREENSHOTS=update if the change was meant" + } + + private fun format(share: Double): String = String.format("%.2f%%", share * 100) + + private fun platform(): String { + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("win") -> "windows" + os.contains("mac") -> "macos" + else -> "linux" + } + } + + private fun decode(png: ByteArray): BufferedImage? = + runCatching { ImageIO.read(ByteArrayInputStream(png)) }.getOrNull() +}