Move view tests to common

pull/498/head
Alinson S. Xavier 7 years ago
parent defa2f9431
commit a3bfc05068

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@ -22,17 +22,22 @@ package org.isoron.platform.io
interface Log { interface Log {
fun info(tag: String, msg: String) fun info(tag: String, msg: String)
fun debug(tag: String, msg: String) fun debug(tag: String, msg: String)
fun warn(tag: String, msg: String)
} }
/** /**
* A Log that prints to the standard output. * A Log that prints to the standard output.
*/ */
class StandardLog : Log { class StandardLog : Log {
override fun warn(tag: String, msg: String) {
println(sprintf("W %-20s %s", tag, msg))
}
override fun info(tag: String, msg: String) { override fun info(tag: String, msg: String) {
println("I/$tag $msg") println(sprintf("I %-20s %s", tag, msg))
} }
override fun debug(tag: String, msg: String) { override fun debug(tag: String, msg: String) {
println("D/$tag $msg") println(sprintf("D %-20s %s", tag, msg))
} }
} }

@ -19,18 +19,27 @@
package org.isoron.platform.gui package org.isoron.platform.gui
import kotlinx.coroutines.*
import org.w3c.dom.* import org.w3c.dom.*
import kotlin.browser.* import kotlin.js.*
import kotlin.math.* import kotlin.math.*
class HtmlCanvas(val canvas: HTMLCanvasElement) : Canvas { class JsCanvas(val element: HTMLCanvasElement,
val pixelScale: Double) : Canvas {
val ctx = canvas.getContext("2d") as CanvasRenderingContext2D val ctx = element.getContext("2d") as CanvasRenderingContext2D
var fontSize = 12.0 var fontSize = 12.0
var fontWeight = "" var fontFamily = "NotoRegular"
var fontFamily = "sans-serif"
var align = CanvasTextAlign.CENTER var align = CanvasTextAlign.CENTER
private fun toPixel(x: Double): Double {
return pixelScale * x
}
private fun toDp(x: Int): Double {
return x / pixelScale
}
override fun setColor(color: Color) { override fun setColor(color: Color) {
val c = "rgb(${color.red * 255}, ${color.green * 255}, ${color.blue * 255})" val c = "rgb(${color.red * 255}, ${color.green * 255}, ${color.blue * 255})"
ctx.fillStyle = c; ctx.fillStyle = c;
@ -39,45 +48,54 @@ class HtmlCanvas(val canvas: HTMLCanvasElement) : Canvas {
override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) { override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) {
ctx.beginPath() ctx.beginPath()
ctx.moveTo(x1 + 0.5, y1 + 0.5) ctx.moveTo(toPixel(x1), toPixel(y1))
ctx.lineTo(x2 + 0.5, y2 + 0.5) ctx.lineTo(toPixel(x2), toPixel(y2))
ctx.stroke() ctx.stroke()
} }
override fun drawText(text: String, x: Double, y: Double) { override fun drawText(text: String, x: Double, y: Double) {
ctx.font = "${fontWeight} ${fontSize}px ${fontFamily}" ctx.font = "${fontSize}px ${fontFamily}"
ctx.textAlign = align ctx.textAlign = align
ctx.textBaseline = CanvasTextBaseline.MIDDLE ctx.textBaseline = CanvasTextBaseline.MIDDLE
ctx.fillText(text, x, y) ctx.fillText(text, toPixel(x), toPixel(y + fontSize * 0.05))
} }
override fun fillRect(x: Double, y: Double, width: Double, height: Double) { override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
ctx.fillRect(x - 0.5, y - 0.5, width + 1.0, height + 1.0) ctx.fillRect(toPixel(x),
toPixel(y),
toPixel(width),
toPixel(height))
} }
override fun drawRect(x: Double, y: Double, width: Double, height: Double) { override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
ctx.strokeRect(x - 0.5, y - 0.5, width + 1.0, height + 1.0) ctx.strokeRect(toPixel(x),
toPixel(y),
toPixel(width),
toPixel(height))
} }
override fun getHeight(): Double { override fun getHeight(): Double {
return canvas.height.toDouble() return toDp(element.height)
} }
override fun getWidth(): Double { override fun getWidth(): Double {
return canvas.width.toDouble() return toDp(element.width)
} }
override fun setFont(font: Font) { override fun setFont(font: Font) {
fontWeight = if (font == Font.BOLD) "bold" else "" fontFamily = when(font) {
fontFamily = if (font == Font.FONT_AWESOME) "FontAwesome" else "sans-serif" Font.REGULAR -> "NotoRegular"
Font.BOLD -> "NotoBold"
Font.FONT_AWESOME -> "FontAwesome"
}
} }
override fun setFontSize(size: Double) { override fun setFontSize(size: Double) {
fontSize = size fontSize = size * pixelScale
} }
override fun setStrokeWidth(size: Double) { override fun setStrokeWidth(size: Double) {
ctx.lineWidth = size ctx.lineWidth = size * pixelScale
} }
override fun fillArc(centerX: Double, override fun fillArc(centerX: Double,
@ -85,18 +103,24 @@ class HtmlCanvas(val canvas: HTMLCanvasElement) : Canvas {
radius: Double, radius: Double,
startAngle: Double, startAngle: Double,
swipeAngle: Double) { swipeAngle: Double) {
val x = toPixel(centerX)
val y = toPixel(centerY)
val from = startAngle / 180 * PI val from = startAngle / 180 * PI
val to = (startAngle + swipeAngle) / 180 * PI val to = (startAngle + swipeAngle) / 180 * PI
ctx.beginPath() ctx.beginPath()
ctx.moveTo(centerX, centerY) ctx.moveTo(x, y)
ctx.arc(centerX, centerY, radius, -from, -to, swipeAngle >= 0) ctx.arc(x, y, toPixel(radius), -from, -to, swipeAngle >= 0)
ctx.lineTo(centerX, centerY) ctx.lineTo(x, y)
ctx.fill() ctx.fill()
} }
override fun fillCircle(centerX: Double, centerY: Double, radius: Double) { override fun fillCircle(centerX: Double, centerY: Double, radius: Double) {
ctx.beginPath() ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0.0, 2 * PI) ctx.arc(toPixel(centerX),
toPixel(centerY),
toPixel(radius),
0.0,
2 * PI)
ctx.fill() ctx.fill()
} }
@ -107,4 +131,15 @@ class HtmlCanvas(val canvas: HTMLCanvasElement) : Canvas {
TextAlign.RIGHT -> CanvasTextAlign.RIGHT TextAlign.RIGHT -> CanvasTextAlign.RIGHT
} }
} }
suspend fun loadImage(src: String) {
Promise<Int> { resolve, reject ->
val img = Image()
img.onload = {
ctx.drawImage(img, 0.0, 0.0)
resolve(0)
}
img.src = src
}.await()
}
} }

@ -24,6 +24,9 @@ import org.w3c.xhr.*
import kotlin.js.* import kotlin.js.*
class JsFileStorage { class JsFileStorage {
private val TAG = "JsFileStorage"
private val log = StandardLog()
private val indexedDB = eval("indexedDB") private val indexedDB = eval("indexedDB")
private var db: dynamic = null private var db: dynamic = null
@ -31,16 +34,16 @@ class JsFileStorage {
private val OS_NAME = "Files" private val OS_NAME = "Files"
suspend fun init() { suspend fun init() {
console.log("Initializing JsFileStorage...") log.info(TAG, "Initializing")
Promise<Int> { resolve, reject -> Promise<Int> { resolve, reject ->
val req = indexedDB.open(DB_NAME, 2) val req = indexedDB.open(DB_NAME, 2)
req.onerror = { reject(Exception("could not open IndexedDB")) } req.onerror = { reject(Exception("could not open IndexedDB")) }
req.onupgradeneeded = { req.onupgradeneeded = {
console.log("Creating document store for JsFileStorage...") log.info(TAG, "Creating document store")
req.result.createObjectStore(OS_NAME) req.result.createObjectStore(OS_NAME)
} }
req.onsuccess = { req.onsuccess = {
console.log("JsFileStorage is ready.") log.info(TAG, "Ready")
db = req.result db = req.result
resolve(0) resolve(0)
} }

@ -20,5 +20,5 @@
package org.isoron.platform.io package org.isoron.platform.io
actual fun sprintf(format: String, vararg args: Any?): String { actual fun sprintf(format: String, vararg args: Any?): String {
TODO() return js("vsprintf")(format, args)
} }

@ -19,6 +19,7 @@
package org.isoron.platform.gui package org.isoron.platform.gui
import kotlinx.coroutines.*
import org.isoron.platform.io.* import org.isoron.platform.io.*
import java.awt.* import java.awt.*
import java.awt.RenderingHints.* import java.awt.RenderingHints.*
@ -26,14 +27,6 @@ import java.awt.font.*
import java.awt.image.* import java.awt.image.*
import kotlin.math.* import kotlin.math.*
fun createFont(path: String): java.awt.Font {
val file = JavaFileOpener().openResourceFile(path) as JavaResourceFile
return java.awt.Font.createFont(0, file.stream())
}
private val NOTO_REGULAR_FONT = createFont("fonts/NotoSans-Regular.ttf")
private val NOTO_BOLD_FONT = createFont("fonts/NotoSans-Bold.ttf")
private val FONT_AWESOME_FONT = createFont("fonts/FontAwesome.ttf")
class JavaCanvas(val image: BufferedImage, class JavaCanvas(val image: BufferedImage,
val pixelScale: Double = 2.0) : Canvas { val pixelScale: Double = 2.0) : Canvas {
@ -46,6 +39,10 @@ class JavaCanvas(val image: BufferedImage,
val heightPx = image.height val heightPx = image.height
val g2d = image.createGraphics() val g2d = image.createGraphics()
private val NOTO_REGULAR_FONT = createFont("fonts/NotoSans-Regular.ttf")
private val NOTO_BOLD_FONT = createFont("fonts/NotoSans-Bold.ttf")
private val FONT_AWESOME_FONT = createFont("fonts/FontAwesome.ttf")
init { init {
g2d.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); g2d.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON); g2d.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON);
@ -73,6 +70,7 @@ class JavaCanvas(val image: BufferedImage,
} }
override fun drawText(text: String, x: Double, y: Double) { override fun drawText(text: String, x: Double, y: Double) {
updateFont()
val bounds = g2d.font.getStringBounds(text, frc) val bounds = g2d.font.getStringBounds(text, frc)
val bWidth = bounds.width.roundToInt() val bWidth = bounds.width.roundToInt()
val bHeight = bounds.height.roundToInt() val bHeight = bounds.height.roundToInt()
@ -122,7 +120,7 @@ class JavaCanvas(val image: BufferedImage,
} }
override fun setStrokeWidth(size: Double) { override fun setStrokeWidth(size: Double) {
g2d.setStroke(BasicStroke(size.toFloat())) g2d.stroke = BasicStroke((size * pixelScale).toFloat())
} }
private fun updateFont() { private fun updateFont() {
@ -158,4 +156,10 @@ class JavaCanvas(val image: BufferedImage,
override fun setTextAlign(align: TextAlign) { override fun setTextAlign(align: TextAlign) {
this.textAlign = align this.textAlign = align
} }
private fun createFont(path: String) = runBlocking<java.awt.Font> {
val file = JavaFileOpener().openResourceFile(path) as JavaResourceFile
if (!file.exists()) throw RuntimeException("File not found: ${file.path}")
java.awt.Font.createFont(0, file.stream())
}
} }

@ -22,7 +22,7 @@ package org.isoron.platform.io
import java.io.* import java.io.*
import java.nio.file.* import java.nio.file.*
class JavaResourceFile(private val path: String) : ResourceFile { class JavaResourceFile(val path: String) : ResourceFile {
private val javaPath: Path private val javaPath: Path
get() { get() {
val mainPath = Paths.get("assets/main/$path") val mainPath = Paths.get("assets/main/$path")
@ -41,7 +41,9 @@ class JavaResourceFile(private val path: String) : ResourceFile {
override suspend fun copyTo(dest: UserFile) { override suspend fun copyTo(dest: UserFile) {
if (dest.exists()) dest.delete() if (dest.exists()) dest.delete()
Files.copy(javaPath, (dest as JavaUserFile).path) val destPath = (dest as JavaUserFile).path
destPath.toFile().parentFile?.mkdirs()
Files.copy(javaPath, destPath)
} }
fun stream(): InputStream { fun stream(): InputStream {

@ -29,7 +29,8 @@ enum class Locale {
interface CanvasHelper { interface CanvasHelper {
fun createCanvas(width: Int, height: Int): Canvas fun createCanvas(width: Int, height: Int): Canvas
fun exportCanvas(canvas: Canvas, filename: String) suspend fun exportCanvas(canvas: Canvas, filename: String)
suspend fun compare(imageFile: ResourceFile, canvas: Canvas): Double
} }
expect object DependencyResolver { expect object DependencyResolver {

@ -20,12 +20,13 @@
package org.isoron.platform.gui package org.isoron.platform.gui
import org.isoron.* import org.isoron.*
import org.isoron.uhabits.*
import kotlin.test.* import kotlin.test.*
class CanvasTest { class CanvasTest: BaseViewTest() {
@Test @Test
fun run() { fun run() = asyncTest{
if (!DependencyResolver.supportsCanvasTests) return if (!DependencyResolver.supportsCanvasTests) return@asyncTest
val helper = DependencyResolver.getCanvasHelper() val helper = DependencyResolver.getCanvasHelper()
val canvas = helper.createCanvas(500, 400) val canvas = helper.createCanvas(500, 400)
@ -67,6 +68,6 @@ class CanvasTest {
canvas.setFont(Font.FONT_AWESOME) canvas.setFont(Font.FONT_AWESOME)
canvas.drawText(FontAwesome.CHECK, 250.0, 300.0) canvas.drawText(FontAwesome.CHECK, 250.0, 300.0)
helper.exportCanvas(canvas, "CanvasTest.png") assertRenders("components/CanvasTest.png", canvas)
} }
} }

@ -40,7 +40,7 @@ class FilesTest() {
assertEquals("Hello World!", lines[0]) assertEquals("Hello World!", lines[0])
assertEquals("This is a resource.", lines[1]) assertEquals("This is a resource.", lines[1])
val helloCopy = fileOpener.openUserFile("hello-copy.txt") val helloCopy = fileOpener.openUserFile("copies/hello.txt")
hello.copyTo(helloCopy) hello.copyTo(helloCopy)
lines = helloCopy.lines() lines = helloCopy.lines()
assertEquals("Hello World!", lines[0]) assertEquals("Hello World!", lines[0])

@ -0,0 +1,37 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.io
import kotlin.test.*
class StringsTest {
@Test
fun testSprintf() {
assertEquals(" 5", sprintf("%3d", 5))
assertEquals("005", sprintf("%03d", 5))
assertEquals("005", sprintf("%03d", 5))
assertEquals(" 45", sprintf("%3d", 45))
assertEquals("145", sprintf("%3d", 145))
assertEquals(" 9 9", sprintf("%3d%3d", 9, 9))
assertEquals(" 13.42", sprintf("%8.2f", 13.419187263))
assertEquals("00013.42", sprintf("%08.2f", 13.419187263))
assertEquals("13.42 ", sprintf("%-8.2f", 13.419187263))
}
}

@ -0,0 +1,63 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import org.isoron.*
import org.isoron.platform.gui.*
import org.isoron.uhabits.components.*
import kotlin.test.*
var SIMILARITY_THRESHOLD = 5.0
open class BaseViewTest {
var theme = LightTheme()
suspend fun assertRenders(width: Int,
height: Int,
expectedPath: String,
component: Component) {
val helper = DependencyResolver.getCanvasHelper()
val canvas = helper.createCanvas(width, height)
component.draw(canvas)
assertRenders(expectedPath, canvas)
}
suspend fun assertRenders(expectedPath: String,
canvas: Canvas) {
val helper = DependencyResolver.getCanvasHelper()
val fileOpener = DependencyResolver.getFileOpener()
val expectedFile = fileOpener.openResourceFile(expectedPath)
val actualPath = "/failed/${expectedPath}"
if (expectedFile.exists()) {
val d = helper.compare(expectedFile, canvas)
if (d >= SIMILARITY_THRESHOLD) {
helper.exportCanvas(canvas, actualPath)
val expectedCopy = expectedPath.replace(".png", ".expected.png")
expectedFile.copyTo(fileOpener.openUserFile("/failed/$expectedCopy"))
fail("Images differ (distance=${d}). Actual rendered saved to ${actualPath}.")
}
} else {
helper.exportCanvas(canvas, actualPath)
fail("Expected file is missing. Actual render saved to $actualPath")
}
}
}

@ -19,32 +19,30 @@
package org.isoron.uhabits.components package org.isoron.uhabits.components
import org.isoron.*
import org.isoron.platform.time.* import org.isoron.platform.time.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import org.junit.* import kotlin.test.*
import java.util.*
class CalendarChartTest : BaseViewTest() { class CalendarChartTest : BaseViewTest() {
val base = "components/CalendarChart" val base = "components/CalendarChart"
@Test @Test
fun testDraw() { fun testDraw() = asyncTest {
val fmt = DependencyResolver.getDateFormatter(Locale.US)
val component = CalendarChart(LocalDate(2015, 1, 25), val component = CalendarChart(LocalDate(2015, 1, 25),
theme.color(4), theme.color(4),
theme, theme,
JavaLocalDateFormatter(Locale.US)) fmt)
component.series = listOf(1.0, // today component.series = listOf(1.0, // today
0.2, 0.5, 0.7, 0.0, 0.3, 0.4, 0.6, 0.2, 0.5, 0.7, 0.0, 0.3, 0.4, 0.6,
0.6, 0.0, 0.3, 0.6, 0.5, 0.8, 0.0, 0.6, 0.0, 0.3, 0.6, 0.5, 0.8, 0.0,
0.0, 0.0, 0.0, 0.6, 0.5, 0.7, 0.7, 0.0, 0.0, 0.0, 0.6, 0.5, 0.7, 0.7,
0.5, 0.5, 0.8, 0.9, 1.0, 1.0, 1.0, 0.5, 0.5, 0.8, 0.9, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.2) 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.2)
assertRenders(800, 400, "$base/base.png", component) assertRenders(400, 200, "$base/base.png", component)
component.scrollPosition = 2 component.scrollPosition = 2
assertRenders(800, 400, "$base/scroll.png", component) assertRenders(400, 200, "$base/scroll.png", component)
component.dateFormatter = JavaLocalDateFormatter(Locale.JAPAN)
assertRenders(800, 400, "$base/base-jp.png", component)
} }
} }

@ -19,27 +19,28 @@
package org.isoron.uhabits.components package org.isoron.uhabits.components
import org.isoron.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import org.junit.* import kotlin.test.*
class CheckmarkButtonTest : BaseViewTest() { class CheckmarkButtonTest : BaseViewTest() {
val base = "components/CheckmarkButton" val base = "components/CheckmarkButton"
@Test @Test
fun testDrawExplicit() { fun testDrawExplicit() = asyncTest {
val component = CheckmarkButton(2, theme.color(8), theme) val component = CheckmarkButton(2, theme.color(8), theme)
assertRenders(96, 96, "$base/explicit.png", component) assertRenders(48, 48, "$base/explicit.png", component)
} }
@Test @Test
fun testDrawImplicit() { fun testDrawImplicit() = asyncTest {
val component = CheckmarkButton(1, theme.color(8), theme) val component = CheckmarkButton(1, theme.color(8), theme)
assertRenders(96, 96, "$base/implicit.png", component) assertRenders(48, 48, "$base/implicit.png", component)
} }
@Test @Test
fun testDrawUnchecked() { fun testDrawUnchecked() = asyncTest {
val component = CheckmarkButton(0, theme.color(8), theme) val component = CheckmarkButton(0, theme.color(8), theme)
assertRenders(96, 96, "$base/unchecked.png", component) assertRenders(48, 48, "$base/unchecked.png", component)
} }
} }

@ -19,20 +19,16 @@
package org.isoron.uhabits.components package org.isoron.uhabits.components
import org.isoron.*
import org.isoron.platform.time.* import org.isoron.platform.time.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import org.junit.* import kotlin.test.*
import java.util.*
class HabitListHeaderTest : BaseViewTest() { class HabitListHeaderTest : BaseViewTest() {
@Test @Test
fun testDraw() { fun testDraw() = asyncTest {
val header = HabitListHeader(LocalDate(2019, 3, 25), val fmt = DependencyResolver.getDateFormatter(Locale.US)
5, val header = HabitListHeader(LocalDate(2019, 3, 25), 5, theme, fmt)
theme, assertRenders(600, 48, "components/HabitListHeader/light.png", header)
JavaLocalDateFormatter(Locale.US))
assertRenders(1200, 96,
"components/HabitListHeader/light.png",
header)
} }
} }

@ -0,0 +1,64 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.components
import org.isoron.*
import org.isoron.uhabits.*
import kotlin.test.*
class NumberButtonTest : BaseViewTest() {
val base = "components/NumberButton"
@Test
fun testFormatValue() = asyncTest{
assertEquals("0.12", 0.1235.toShortString())
assertEquals("0.1", 0.1000.toShortString())
assertEquals("5", 5.0.toShortString())
assertEquals("5.25", 5.25.toShortString())
assertEquals("12.3", 12.3456.toShortString())
assertEquals("123", 123.123.toShortString())
assertEquals("321", 321.2.toShortString())
assertEquals("4.3k", 4321.2.toShortString())
assertEquals("54.3k", 54321.2.toShortString())
assertEquals("654k", 654321.2.toShortString())
assertEquals("7.7M", 7654321.2.toShortString())
assertEquals("87.7M", 87654321.2.toShortString())
assertEquals("988M", 987654321.2.toShortString())
assertEquals("2.0G", 1987654321.2.toShortString())
}
@Test
fun testRenderAbove() = asyncTest {
val btn = NumberButton(theme.color(8), 500.0, 100.0, "steps", theme)
assertRenders(48, 48, "$base/render_above.png", btn)
}
@Test
fun testRenderBelow() = asyncTest {
val btn = NumberButton(theme.color(8), 99.0, 100.0, "steps", theme)
assertRenders(48, 48, "$base/render_below.png", btn)
}
@Test
fun testRenderZero() = asyncTest {
val btn = NumberButton(theme.color(8), 0.0, 100.0, "steps", theme)
assertRenders(48, 48, "$base/render_zero.png", btn)
}
}

@ -19,20 +19,21 @@
package org.isoron.uhabits.components package org.isoron.uhabits.components
import org.isoron.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import org.junit.* import kotlin.test.*
class RingTest : BaseViewTest() { class RingTest : BaseViewTest() {
val base = "components/Ring" val base = "components/Ring"
@Test @Test
fun testDraw() { fun testDraw() = asyncTest {
val component = Ring(theme.color(8), val component = Ring(theme.color(8),
percentage = 0.30, percentage = 0.30,
thickness = 5.0, thickness = 5.0,
radius = 30.0, radius = 30.0,
theme = theme, theme = theme,
label = true) label = true)
assertRenders(120, 120, "$base/draw1.png", component) assertRenders(60, 60, "$base/draw1.png", component)
} }
} }

@ -23,8 +23,10 @@ import org.isoron.platform.gui.*
import org.isoron.platform.io.* import org.isoron.platform.io.*
import org.isoron.platform.time.* import org.isoron.platform.time.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import org.khronos.webgl.*
import org.w3c.dom.* import org.w3c.dom.*
import kotlin.browser.* import kotlin.browser.*
import kotlin.math.*
actual object DependencyResolver { actual object DependencyResolver {
actual val supportsDatabaseTests = true actual val supportsDatabaseTests = true
@ -52,7 +54,7 @@ actual object DependencyResolver {
} }
actual fun getDateFormatter(locale: Locale): LocalDateFormatter { actual fun getDateFormatter(locale: Locale): LocalDateFormatter {
return when(locale) { return when (locale) {
Locale.US -> JsDateFormatter("en-US") Locale.US -> JsDateFormatter("en-US")
Locale.JAPAN -> JsDateFormatter("ja-JP") Locale.JAPAN -> JsDateFormatter("ja-JP")
} }
@ -60,14 +62,65 @@ actual object DependencyResolver {
} }
class JsCanvasHelper : CanvasHelper { class JsCanvasHelper : CanvasHelper {
override suspend fun compare(imageFile: ResourceFile,
canvas: Canvas): Double {
canvas as JsCanvas
imageFile as JsResourceFile
val width = canvas.element.width
val height = canvas.element.height
val expectedCanvasElement = document.createElement("canvas") as HTMLCanvasElement
expectedCanvasElement.width = width
expectedCanvasElement.height = height
expectedCanvasElement.style.width = canvas.element.style.width
expectedCanvasElement.style.height = canvas.element.style.height
expectedCanvasElement.className = "canvasTest"
document.body?.appendChild(expectedCanvasElement)
val expectedCanvas = JsCanvas(expectedCanvasElement, 1.0)
expectedCanvas.loadImage("../assets/${imageFile.filename}")
val actualData = canvas.ctx.getImageData(0.0,
0.0,
width.toDouble(),
height.toDouble()).data
val expectedData = expectedCanvas.ctx.getImageData(0.0,
0.0,
width.toDouble(),
height.toDouble()).data
var distance = 0.0;
for (x in 0 until width) {
for (y in 0 until height) {
val k = (y * width + x) * 4
distance += abs(actualData[k] - expectedData[k])
distance += abs(actualData[k + 1] - expectedData[k + 1])
distance += abs(actualData[k + 2] - expectedData[k + 2])
distance += abs(actualData[k + 3] - expectedData[k + 3])
}
}
val adjustedDistance = distance / 255.0 / 4 / 1000
if (adjustedDistance > SIMILARITY_THRESHOLD) {
expectedCanvasElement.style.display = "block"
canvas.element.style.display = "block"
}
return adjustedDistance
}
override fun createCanvas(width: Int, height: Int): Canvas { override fun createCanvas(width: Int, height: Int): Canvas {
val canvasElement = document.getElementById("canvas") as HTMLCanvasElement val canvasElement = document.createElement("canvas") as HTMLCanvasElement
canvasElement.width = width * 2
canvasElement.height = height * 2
canvasElement.style.width = "${width}px" canvasElement.style.width = "${width}px"
canvasElement.style.height = "${height}px" canvasElement.style.height = "${height}px"
return HtmlCanvas(canvasElement) canvasElement.className = "canvasTest"
document.body?.appendChild(canvasElement)
return JsCanvas(canvasElement, 2.0)
} }
override fun exportCanvas(canvas: Canvas, filename: String) { override suspend fun exportCanvas(canvas: Canvas, filename: String) {
// do nothing // do nothing
} }
} }

@ -25,6 +25,8 @@ import org.isoron.platform.time.*
import org.isoron.uhabits.* import org.isoron.uhabits.*
import java.awt.image.* import java.awt.image.*
import java.io.* import java.io.*
import java.lang.Math.*
import java.nio.file.*
import javax.imageio.* import javax.imageio.*
actual object DependencyResolver { actual object DependencyResolver {
@ -47,7 +49,7 @@ actual object DependencyResolver {
} }
actual fun getDateFormatter(locale: Locale): LocalDateFormatter { actual fun getDateFormatter(locale: Locale): LocalDateFormatter {
return when(locale) { return when (locale) {
Locale.US -> JavaLocalDateFormatter(java.util.Locale.US) Locale.US -> JavaLocalDateFormatter(java.util.Locale.US)
Locale.JAPAN -> JavaLocalDateFormatter(java.util.Locale.JAPAN) Locale.JAPAN -> JavaLocalDateFormatter(java.util.Locale.JAPAN)
} }
@ -55,13 +57,42 @@ actual object DependencyResolver {
} }
class JavaCanvasHelper : CanvasHelper { class JavaCanvasHelper : CanvasHelper {
override suspend fun compare(imageFile: ResourceFile, canvas: Canvas): Double {
val actual = (canvas as JavaCanvas).image
val expected = ImageIO.read((imageFile as JavaResourceFile).stream())
return compare(expected, actual)
}
private fun compare(expected: BufferedImage,
actual: BufferedImage): Double {
if (actual.width != expected.width) return Double.POSITIVE_INFINITY
if (actual.height != expected.height) return Double.POSITIVE_INFINITY
var distance = 0.0;
for (x in 0 until actual.width) {
for (y in 0 until actual.height) {
val p1 = Color(actual.getRGB(x, y))
val p2 = Color(expected.getRGB(x, y))
distance += abs(p1.red - p2.red)
distance += abs(p1.green - p2.green)
distance += abs(p1.blue - p2.blue)
}
}
return distance / 4.0
}
override fun createCanvas(width: Int, height: Int): Canvas { override fun createCanvas(width: Int, height: Int): Canvas {
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) val widthPx = width * 2
return JavaCanvas(image, pixelScale = 1.0) val heightPx = height * 2
val image = BufferedImage(widthPx, heightPx, BufferedImage.TYPE_INT_ARGB)
return JavaCanvas(image, pixelScale = 2.0)
} }
override fun exportCanvas(canvas: Canvas, filename: String) { override suspend fun exportCanvas(canvas: Canvas, filename: String) {
val javaCanvas = canvas as JavaCanvas val file = File("/tmp/$filename")
ImageIO.write(javaCanvas.image, "png", File("/tmp/$filename")) file.parentFile.mkdirs()
ImageIO.write((canvas as JavaCanvas).image, "png", file)
} }
} }

@ -1,85 +0,0 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import kotlinx.coroutines.*
import org.isoron.platform.gui.*
import org.isoron.platform.io.*
import org.isoron.uhabits.components.*
import java.awt.image.*
import java.io.*
import javax.imageio.*
import kotlin.math.*
open class BaseViewTest {
val theme = LightTheme()
private fun distance(actual: BufferedImage,
expected: BufferedImage): Double {
if (actual.width != expected.width) return Double.POSITIVE_INFINITY
if (actual.height != expected.height) return Double.POSITIVE_INFINITY
var distance = 0.0;
for (x in 0 until actual.width) {
for (y in 0 until actual.height) {
val p1 = Color(actual.getRGB(x, y))
val p2 = Color(expected.getRGB(x, y))
distance += abs(p1.red - p2.red)
distance += abs(p1.green - p2.green)
distance += abs(p1.blue - p2.blue)
}
}
return 255 * distance / (actual.width * actual.height)
}
fun assertRenders(width: Int,
height: Int,
expectedPath: String,
component: Component,
threshold: Double = 1e-3) {
val actual = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val canvas = JavaCanvas(actual)
val expectedFile: JavaResourceFile
val actualPath = "/tmp/${expectedPath}"
component.draw(canvas)
expectedFile = JavaFileOpener().openResourceFile(expectedPath) as JavaResourceFile
runBlocking<Unit> {
if (expectedFile.exists()) {
val expected = ImageIO.read(expectedFile.stream())
val d = distance(actual, expected)
if (d >= threshold) {
File(actualPath).parentFile.mkdirs()
ImageIO.write(actual, "png", File(actualPath))
ImageIO.write(expected, "png", File(actualPath.replace(".png", ".expected.png")))
//fail("Images differ (distance=${d}). Actual rendered saved to ${actualPath}.")
}
} else {
File(actualPath).parentFile.mkdirs()
ImageIO.write(actual, "png", File(actualPath))
//fail("Expected file is missing. Actual render saved to $actualPath")
}
}
}
}

@ -1,65 +0,0 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.components
import org.hamcrest.CoreMatchers.*
import org.isoron.uhabits.*
import org.junit.*
import org.junit.Assert.*
class NumberButtonTest : BaseViewTest() {
val base = "components/NumberButton/"
@Test
fun testFormatValue() {
assertThat(0.1235.toShortString(), equalTo("0.12"))
assertThat(0.1000.toShortString(), equalTo("0.1"))
assertThat(5.0.toShortString(), equalTo("5"))
assertThat(5.25.toShortString(), equalTo("5.25"))
assertThat(12.3456.toShortString(), equalTo("12.3"))
assertThat(123.123.toShortString(), equalTo("123"))
assertThat(321.2.toShortString(), equalTo("321"))
assertThat(4321.2.toShortString(), equalTo("4.3k"))
assertThat(54321.2.toShortString(), equalTo("54.3k"))
assertThat(654321.2.toShortString(), equalTo("654k"))
assertThat(7654321.2.toShortString(), equalTo("7.7M"))
assertThat(87654321.2.toShortString(), equalTo("87.7M"))
assertThat(987654321.2.toShortString(), equalTo("988M"))
assertThat(1987654321.2.toShortString(), equalTo("2.0G"))
}
@Test
fun testRenderAbove() {
val btn = NumberButton(theme.color(8), 500.0, 100.0, "steps", theme)
assertRenders(96, 96, "$base/render_above.png", btn)
}
@Test
fun testRenderBelow() {
val btn = NumberButton(theme.color(8), 99.0, 100.0, "steps", theme)
assertRenders(96, 96, "$base/render_below.png", btn)
}
@Test
fun testRenderZero() {
val btn = NumberButton(theme.color(8), 0.0, 100.0, "steps", theme)
assertRenders(96, 96, "$base/render_zero.png", btn)
}
}

@ -20,6 +20,7 @@ $(test_bundle): src/test/index.js core
cp node_modules/mocha/mocha.css build/lib cp node_modules/mocha/mocha.css build/lib
cp node_modules/mocha/mocha.js build/lib cp node_modules/mocha/mocha.js build/lib
cp node_modules/sql.js/js/sql.js build/lib cp node_modules/sql.js/js/sql.js build/lib
cp node_modules/sprintf-js/dist/sprintf.min.js build/lib
serve: serve:
npx serve build/ npx serve build/

@ -315,6 +315,14 @@
"dev": true, "dev": true,
"requires": { "requires": {
"sprintf-js": "~1.0.2" "sprintf-js": "~1.0.2"
},
"dependencies": {
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true
}
} }
}, },
"arr-diff": { "arr-diff": {
@ -5111,10 +5119,9 @@
} }
}, },
"sprintf-js": { "sprintf-js": {
"version": "1.0.3", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
"dev": true
}, },
"sql.js": { "sql.js": {
"version": "0.5.0", "version": "0.5.0",

@ -21,6 +21,7 @@
"kotlin": "^1.3.21", "kotlin": "^1.3.21",
"kotlin-test": "^1.3.21", "kotlin-test": "^1.3.21",
"kotlinx-coroutines-core": "^1.1.1", "kotlinx-coroutines-core": "^1.1.1",
"sprintf-js": "^1.1.2",
"sql.js": "^0.5.0" "sql.js": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {

@ -3,20 +3,47 @@
<head> <head>
<title>Mocha Tests</title> <title>Mocha Tests</title>
<link rel="stylesheet" href="../lib/mocha.css"> <link rel="stylesheet" href="../lib/mocha.css">
<script src="../lib/sprintf.min.js"></script>
<script src="../lib/sql.js"></script> <script src="../lib/sql.js"></script>
<script src="../lib/mocha.js"></script>
<style> <style>
@font-face { @font-face {
font-family: "FontAwesome"; font-family: "FontAwesome";
src: url(../assets/fonts/FontAwesome.ttf) format("truetype"); src: url(../assets/fonts/FontAwesome.ttf) format("truetype");
} }
@font-face {
font-family: "NotoRegular";
src: url(../assets/fonts/NotoSans-Regular.ttf) format("truetype");
}
@font-face {
font-family: "NotoBold";
src: url(../assets/fonts/NotoSans-Bold.ttf) format("truetype");
}
.canvasTest {
border: 1px solid #000;
display: none;
margin: 10px auto;
}
</style> </style>
</head> </head>
<body> <body>
<canvas id="canvas" width=500 height=400 style="display: none"></canvas>
<!-- Preload fonts for canvas. See https://stackoverflow.com/questions/2756575/ -->
<span style="font-family: FontAwesome">&nbsp;</span>
<span style="font-family: NotoRegular">&nbsp;</span>
<span style="font-family: NotoBold">&nbsp;</span>
<div id="mocha"></div> <div id="mocha"></div>
<script src="../lib/mocha.js"></script>
<script>mocha.setup('bdd')</script> <script>
<script src="../test.js"></script> mocha.setup('bdd');
<script>mocha.run();</script> testElement = document.createElement("script");
testElement.src = "../test.js";
testElement.onload = function() {
mocha.run();
}
document.body.appendChild(testElement);
</script>
</body> </body>
</html> </html>

Loading…
Cancel
Save