Skip to content

Commit

Permalink
Snapshot test for RedwoodView inside other layouts
Browse files Browse the repository at this point in the history
  • Loading branch information
squarejesse committed Oct 31, 2024
1 parent 0140f64 commit e1e1460
Show file tree
Hide file tree
Showing 27 changed files with 327 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class ComposeSnapshotter(
private val paparazzi: Paparazzi,
private val widget: @Composable () -> Unit,
) : Snapshotter {
override fun snapshot(name: String?) {
override fun snapshot(name: String?, scrolling: Boolean) {
paparazzi.snapshot(name, widget)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ package app.cash.redwood.snapshot.testing

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
Expand All @@ -40,6 +44,10 @@ object ComposeUiTestWidgetFactory : TestWidgetFactory<@Composable () -> Unit> {
override fun color(): ColorWidget<@Composable () -> Unit> = ComposeUiColor()

override fun text(): Text<@Composable () -> Unit> = ComposeUiText()

override fun column(): SimpleColumn<@Composable () -> Unit> = ComposeUiColumn()

override fun scrollWrapper(): ScrollWrapper<@Composable () -> Unit> = ComposeUiScrollWrapper()
}

class ComposeUiText : Text<@Composable () -> Unit> {
Expand Down Expand Up @@ -98,3 +106,36 @@ class ComposeUiColor : ColorWidget<@Composable () -> Unit> {
internal fun Dp.toDp(): ComposeDp {
return ComposeDp(toPlatformDp().toFloat())
}

class ComposeUiColumn : SimpleColumn<@Composable () -> Unit> {
private val children = mutableStateListOf<@Composable () -> Unit>()

override var modifier: RedwoodModifier = RedwoodModifier

override val value = @Composable {
Column {
for (child in children) {
child()
}
}
}

override fun add(child: @Composable () -> Unit) {
children.add(child)
}
}

class ComposeUiScrollWrapper : ScrollWrapper<@Composable () -> Unit> {
override var content: (@Composable () -> Unit)? = null

override var modifier: RedwoodModifier = RedwoodModifier

override val value = @Composable {
val state = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(state),
) {
content?.invoke()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,22 @@ class ViewSnapshotter(
private val paparazzi: Paparazzi,
private val view: View,
) : Snapshotter {
override fun snapshot(name: String?) {
override fun snapshot(name: String?, scrolling: Boolean) {
paparazzi.snapshot(view = view, name = name)

if (scrolling) {
var scrollCount = 0
while (view.canScrollVertically(1)) {
view.scrollBy(0, view.height)
scrollCount++

check(scrollCount < 15) {
"This view has been scrolled 15 times! Bad input?"
}

paparazzi.snapshot(view = view, name = "${name.orEmpty()}_$scrollCount")
}
view.scrollTo(0, 0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package app.cash.redwood.snapshot.testing
import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import app.cash.redwood.Modifier
import app.cash.redwood.ui.Density
Expand All @@ -29,6 +31,10 @@ class ViewTestWidgetFactory(
override fun color() = ViewColor(context)

override fun text() = ViewText(context)

override fun column() = ViewSimpleColumn(context)

override fun scrollWrapper() = ViewScrollWrapper(context)
}

class ViewText(context: Context) : Text<View> {
Expand Down Expand Up @@ -82,3 +88,33 @@ class ViewColor(context: Context) : Color<View> {
value.setBackgroundColor(color)
}
}

class ViewSimpleColumn(context: Context) : SimpleColumn<View> {
override val value = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}

override var modifier: Modifier = Modifier

override fun add(child: View) {
value.addView(child)
}
}

class ViewScrollWrapper(context: Context) : ScrollWrapper<View> {
override val value = ScrollView(context)

override var modifier: Modifier = Modifier

override var content: View?
get() = when (value.childCount) {
1 -> value.getChildAt(0)
else -> null
}
set(value) {
this@ViewScrollWrapper.value.removeAllViews()
if (value != null) {
this@ViewScrollWrapper.value.addView(value)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package app.cash.redwood.snapshot.testing
import app.cash.redwood.ui.Dp
import app.cash.redwood.widget.Widget

interface Color<T : Any> : Widget<T> {
interface Color<W : Any> : Widget<W> {
fun width(width: Dp)
fun height(height: Dp)
fun color(color: Int)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2024 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.redwood.snapshot.testing

import app.cash.redwood.widget.Widget

/**
* Wraps a widget in a container that scrolls vertically.
*/
interface ScrollWrapper<W : Any> : Widget<W> {
var content: W?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2024 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.redwood.snapshot.testing

import app.cash.redwood.widget.Widget

/**
* Delegates to the host platform's column-like layout without any other capabilities.
*/
interface SimpleColumn<W : Any> : Widget<W> {
fun add(child: W)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ package app.cash.redwood.snapshot.testing
* changes.
*/
interface Snapshotter {
fun snapshot(name: String? = null)
fun snapshot(
name: String? = null,
scrolling: Boolean = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import app.cash.redwood.ui.Dp
interface TestWidgetFactory<W : Any> {
fun color(): Color<W>
fun text(): Text<W>
fun column(): SimpleColumn<W>
fun scrollWrapper(): ScrollWrapper<W>
}

fun <W : Any> TestWidgetFactory<W>.text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import app.cash.redwood.widget.Widget
*
* The text is centered vertically.
*/
interface Text<T : Any> : Widget<T> {
interface Text<W : Any> : Widget<W> {
val measureCount: Int
fun text(text: String)
fun bgColor(color: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ package app.cash.redwood.snapshot.testing

import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlinx.cinterop.useContents
import platform.CoreGraphics.CGPointMake
import platform.CoreGraphics.CGRectMake
import platform.UIKit.UIColor
import platform.UIKit.UIScrollView
import platform.UIKit.UIView

/** Snapshot the subject on a white background. */
Expand All @@ -27,12 +30,46 @@ class UIViewSnapshotter(
private val subject: UIView,
) : Snapshotter {

override fun snapshot(name: String?) {
override fun snapshot(name: String?, scrolling: Boolean) {
layoutSubject()

// Unfortunately even with animations forced off, UITableView's animation system breaks
// synchronous snapshots. The simplest workaround is to delay snapshots one frame.
callback.verifySnapshot(subject, name, delay = 1.milliseconds.toDouble(DurationUnit.SECONDS))

if (scrolling) {
var scrollCount = 0
val scrollView = findScrollView(subject) ?: return
val contentHeight = scrollView.contentSize.useContents { height }
val frameHeight = scrollView.frame.useContents { size.height }
var offset = 0.0
while (offset + frameHeight < contentHeight) {
offset = minOf(offset + frameHeight, contentHeight - frameHeight)
scrollView.setContentOffset(CGPointMake(0.0, offset), false)
scrollCount++

check(scrollCount < 15) {
"This view has been scrolled 15 times! Bad input?"
}

callback.verifySnapshot(
view = subject,
name = "${name.orEmpty()}_$scrollCount",
delay = 1.milliseconds.toDouble(DurationUnit.SECONDS),
)
}
scrollView.setContentOffset(CGPointMake(0.0, 0.0), false)
}
}

private fun findScrollView(view: UIView): UIScrollView? {
if (view is UIScrollView) return view

for (subview in view.subviews) {
return findScrollView(subview as UIView) ?: continue
}

return null
}

/** Do layout without taking a snapshot. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@ import platform.CoreGraphics.CGSize
import platform.CoreGraphics.CGSizeMake
import platform.UIKit.UIColor
import platform.UIKit.UILabel
import platform.UIKit.UILayoutConstraintAxisVertical
import platform.UIKit.UIScrollView
import platform.UIKit.UIStackView
import platform.UIKit.UIStackViewAlignmentLeading
import platform.UIKit.UIStackViewDistributionFill
import platform.UIKit.UIView

object UIViewTestWidgetFactory : TestWidgetFactory<UIView> {
override fun color() = UIViewColor()

override fun text() = UIViewText()

override fun column() = UIViewSimpleColumn()

override fun scrollWrapper() = UIViewScrollWrapper()
}

fun Int.toUIColor(): UIColor {
Expand Down Expand Up @@ -105,3 +114,38 @@ class UIViewColor : Color<UIView> {
value.invalidateIntrinsicContentSize()
}
}

class UIViewSimpleColumn : SimpleColumn<UIView> {
override var modifier: Modifier = Modifier

override val value = UIStackView(CGRectZero.readValue()).apply {
this.axis = UILayoutConstraintAxisVertical
this.alignment = UIStackViewAlignmentLeading
this.distribution = UIStackViewDistributionFill
}

override fun add(child: UIView) {
value.addArrangedSubview(child)
}
}

class UIViewScrollWrapper : ScrollWrapper<UIView> {
override var modifier: Modifier = Modifier

override val value = UIScrollView(CGRectZero.readValue())

override var content: UIView?
get() = value.subviews().firstOrNull() as UIView?
set(value) {
val scrollView = this@UIViewScrollWrapper.value
(scrollView.subviews.firstOrNull() as UIView?)?.removeFromSuperview()
if (value == null) return

scrollView.addSubview(value)
value.translatesAutoresizingMaskIntoConstraints = false
value.leadingAnchor.constraintEqualToAnchor(scrollView.leadingAnchor).active = true
value.topAnchor.constraintEqualToAnchor(scrollView.topAnchor).active = true
value.trailingAnchor.constraintEqualToAnchor(scrollView.trailingAnchor).active = true
value.bottomAnchor.constraintEqualToAnchor(scrollView.bottomAnchor).active = true
}
}
Loading

0 comments on commit e1e1460

Please sign in to comment.