app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit ae2677b73b3c4d3f134027b35dfffe6862dc85ec
parent 36e08b79fbfb53d106532e78f3cb0bf781b8ed02
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 16:26:52 +0000

test: lock HarvestCircle visual regressions

- add deterministic light and dark macOS reference surfaces
- provide a governed golden refresh command and host guard
- lock screenshot digests into the owned design-source baseline
- reject superseded adapters, Material imports, and missing design modules

Diffstat:
MMakefile | 7+++++--
Mapp/shared/build.gradle.kts | 7+++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png | 0
Aapp/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png | 0
Mconfig/design/source_baseline_v1.toml | 4+++-
Mtools/xtask/src/lib.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
7 files changed, 250 insertions(+), 7 deletions(-)

diff --git a/Makefile b/Makefile @@ -17,14 +17,17 @@ else override BUILD_RUNNER := endif -.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean +.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean help: - @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check notarization-check release-check clean + @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check notarization-check release-check clean design-source-check: doctor HARVESTCIRCLE_BUILD_MODE=$(BUILD_MODE) $(BUILD_RUNNER) $(CARGO) run --manifest-path $(XTASK_MANIFEST) --locked -- design-source-audit +design-goldens-update: doctor + $(BUILD_RUNNER) $(GRADLE) --no-daemon -Pharvestcircle.updateMacosGoldens=true :app:shared:desktopTest --tests org.harvestcircle.ui.shell.HarvestCircleMacGoldenTest + doctor: @printf '%s\n' "harvestcircle.build.mode=$(BUILD_MODE)" $(BUILD_RUNNER) java -version diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts @@ -1,3 +1,10 @@ plugins { id("org.harvestcircle.build.kmp-shared") } + +tasks.named<Test>("desktopTest") { + systemProperty("harvestcircle.projectDir", rootProject.layout.projectDirectory.asFile.absolutePath) + if (providers.gradleProperty("harvestcircle.updateMacosGoldens").orNull == "true") { + systemProperty("harvestcircle.updateMacosGoldens", "true") + } +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt @@ -0,0 +1,160 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBanner +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBannerTone +import org.harvestcircle.designsystem.component.navigation.HarvestCircleNavigationItem +import org.harvestcircle.designsystem.layout.HarvestCirclePane +import org.harvestcircle.designsystem.layout.HarvestCircleSidebar +import org.harvestcircle.designsystem.layout.HarvestCircleSidebarSectionHeader +import org.harvestcircle.designsystem.layout.HarvestCircleToolbar +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.theme.HarvestCircleDesignTheme +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.theme.HarvestCircleThemeConfig +import org.harvestcircle.designsystem.theme.HarvestCircleThemeMode +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleMacGoldenTest { + @Test + fun macosAarch64LightAndDarkReferenceSurfacesMatch() { + val updating = System.getProperty("harvestcircle.updateMacosGoldens") == "true" + if (!isMacosAarch64()) { + check(!updating) { "HarvestCircle design goldens may only be updated on macos-aarch64" } + return + } + + listOf(HarvestCircleThemeMode.Light, HarvestCircleThemeMode.Dark).forEach { mode -> + val actual = captureReferenceSurface(mode) + val resourceName = "goldens/macos-aarch64/design-surface-${mode.name.lowercase()}.png" + if (updating) { + val projectDir = assertNotNull(System.getProperty("harvestcircle.projectDir")) + val output = File(projectDir, "app/shared/src/desktopTest/resources/$resourceName") + output.parentFile.mkdirs() + check(ImageIO.write(actual, "png", output)) + } else { + val stream = + assertNotNull( + javaClass.classLoader.getResourceAsStream(resourceName), + "Missing golden resource: $resourceName", + ) + val expected = assertNotNull(stream.use { ImageIO.read(it) }, "Unreadable golden resource: $resourceName") + assertEquals(expected.width, actual.width, "$resourceName width") + assertEquals(expected.height, actual.height, "$resourceName height") + assertContentEquals(expected.argb(), actual.argb(), resourceName) + } + } + } +} + +@OptIn(ExperimentalTestApi::class) +private fun captureReferenceSurface(mode: HarvestCircleThemeMode): BufferedImage { + lateinit var captured: ImageBitmap + runComposeUiTest { + setContent { + HarvestCircleDesignTheme(HarvestCircleThemeConfig(mode = mode)) { + Row(Modifier.size(width = 760.dp, height = 420.dp).testTag("golden-surface")) { + HarvestCircleSidebar(Modifier.width(180.dp)) { + HarvestCircleSidebarSectionHeader("HarvestCircle") + HarvestCircleNavigationItem(true, {}, "Today") + HarvestCircleNavigationItem(false, {}, "Network") + HarvestCircleNavigationItem(false, {}, "Settings") + Spacer(Modifier.weight(1f)) + HarvestCircleText( + "Local-first ยท Nostr", + modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.md), + role = HarvestCircleTextRole.LabelSmall, + tone = HarvestCircleContentTone.Muted, + ) + } + Column(Modifier.weight(1f).fillMaxHeight()) { + HarvestCircleToolbar { + HarvestCircleText("Today", role = HarvestCircleTextRole.SectionTitle) + Spacer(Modifier.weight(1f)) + HarvestCircleBadge("Signed out") + } + HarvestCirclePane( + modifier = Modifier.fillMaxSize(), + role = org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole.Canvas, + ) { + Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { + HarvestCircleText("Coordinate local food", role = HarvestCircleTextRole.PageTitle) + HarvestCircleText("Clear, signed terms for farms and nearby buyers.") + HarvestCircleBanner( + message = "No managed HarvestCircle service is configured.", + tone = HarvestCircleBannerTone.Info, + title = "Local runtime", + ) + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { + HarvestCircleLabeledButton("Open a Nostr reference", "Open a Nostr reference", {}) + HarvestCircleLabeledButton( + "Explore circles", + "Explore circles", + {}, + enabled = false, + variant = HarvestCircleButtonVariant.Secondary, + ) + } + HarvestCircleText( + "Not available in this build.", + role = HarvestCircleTextRole.BodySmall, + tone = HarvestCircleContentTone.Secondary, + ) + } + } + } + } + } + } + waitForIdle() + captured = onNodeWithTag("golden-surface").captureToImage() + } + return captured.toBufferedImage() +} + +private fun ImageBitmap.toBufferedImage(): BufferedImage { + val pixels = toPixelMap() + return BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also { image -> + for (y in 0 until height) { + for (x in 0 until width) { + image.setRGB(x, y, pixels[x, y].toArgb()) + } + } + } +} + +private fun BufferedImage.argb(): IntArray = getRGB(0, 0, width, height, null, 0, width) + +private fun isMacosAarch64(): Boolean = + System.getProperty("os.name").equals("Mac OS X", ignoreCase = true) && + System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64") diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png Binary files differ. diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png Binary files differ. diff --git a/config/design/source_baseline_v1.toml b/config/design/source_baseline_v1.toml @@ -7,8 +7,10 @@ snapshot_sha256 = "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eeb snapshot_file_count = 91 source_license = "GPL-3.0-only" golden_host = "macos-aarch64" -golden_status = "pending" +golden_status = "verified" golden_source_snapshot_sha256 = "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eebb93" +golden_light_sha256 = "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20" +golden_dark_sha256 = "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7" [[mapping]] source = "core/designsystem/src/commonMain" diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -419,6 +419,10 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St "app/shared/src/commonMain/kotlin/org/harvestcircle/product/SurfaceRegistry.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt", + "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt", + "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt", + "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt", + "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt", @@ -636,6 +640,28 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St .filter(|character| !character.is_whitespace()) .collect::<String>(); for (shape, diagnostic) in [ + ("funShellText(", "superseded shell text adapter"), + ("funShellButton(", "superseded shell button adapter"), + ("funShellTextField(", "superseded shell field adapter"), + ( + "enumclassShellTextRole", + "superseded shell text-role adapter", + ), + ( + "enumclassShellButtonKind", + "superseded shell button-kind adapter", + ), + ] { + if compact.contains(shape) { + findings.push(format!("{path}: {diagnostic}")); + } + } + if source.contains("androidx.compose.material") { + findings.push(format!( + "{path}: Material component dependency is forbidden" + )); + } + for (shape, diagnostic) in [ ( "dataobjectConfirmIdentityRemoval", "retired parameterless confirmation source shape", @@ -708,8 +734,6 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St )); } if is_production_compose(path, &source) - && path - != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt" && !path.starts_with( "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/", ) @@ -755,6 +779,16 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St } } } + for forbidden in [ + "app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt", + "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt", + ] { + if inventory.paths.iter().any(|path| path == forbidden) { + findings.push(format!( + "{forbidden}: superseded product-shell authority returned" + )); + } + } } fn is_forbidden_documentation_or_workflow_path(path: &str) -> bool { @@ -875,7 +909,7 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St "snapshot_file_count = 91", "source_license = \"GPL-3.0-only\"", "golden_host = \"macos-aarch64\"", - "golden_status = \"pending\"", + "golden_status = \"verified\"", ]; for scalar in required_scalars { if source.lines().filter(|line| line.trim() == scalar).count() != 1 { @@ -893,6 +927,33 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St findings.push(format!("{PATH}: {key} must match the governed snapshot")); } } + for (path, key, sha256) in [ + ( + "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png", + "golden_light_sha256", + "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20", + ), + ( + "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png", + "golden_dark_sha256", + "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7", + ), + ] { + let authority = format!("{key} = \"{sha256}\""); + if source + .lines() + .filter(|line| line.trim() == authority) + .count() + != 1 + { + findings.push(format!("{PATH}: {key} must match the governed golden")); + } + if !inventory.paths.iter().any(|candidate| candidate == path) + || sha256_file(&root.join(path)).as_deref() != Some(sha256) + { + findings.push(format!("{path}: macOS golden is missing or changed")); + } + } let mappings = design_source_mappings(&source, PATH, findings); let required = [ ( @@ -1539,7 +1600,7 @@ mod tests { write( &root, path, - "import androidx.compose.runtime.Composable\n@Composable fun Moved() { Color(0xFF000000); BasicText(\"bypass\"); BasicTextField(\"\", {}) }\n", + "import androidx.compose.runtime.Composable\nimport androidx.compose.material.Button\n@Composable fun ShellButton() { Color(0xFF000000); BasicText(\"bypass\"); BasicTextField(\"\", {}) }\n", ); let inventory = Inventory::load(&root).expect("moved UI inventory"); let mut findings = Vec::new(); @@ -1558,6 +1619,16 @@ mod tests { assert!(findings.iter().any(|finding| { finding.contains("BasicTextField bypasses the shell primitive adapter") })); + assert!( + findings + .iter() + .any(|finding| finding.contains("Material component dependency is forbidden")) + ); + assert!( + findings + .iter() + .any(|finding| finding.contains("superseded shell button adapter")) + ); fs::remove_dir_all(root).expect("remove fixture"); }