Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -53,6 +60,9 @@ class PhoneScreenshotTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()

/** Screens whose rendering no longer matches the baseline, reported together. */
private val drifted = ArrayList<String>()

@Test
fun every_screen_renders() {
val fixture = Fixture()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -631,7 +633,7 @@ class AppState(
configName = prepared.name,
dryRun = dryRun,
pairCount = prepared.pairs.size,
startedAtMs = System.currentTimeMillis(),
startedAtMs = now(),
)
dismissNotice()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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))
}

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading