diff --git a/.gitignore b/.gitignore index c2db49228..b5365d8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build/ *.iml art/ +*.actual.png diff --git a/app/src/androidTest/assets/pull_failed b/app/src/androidTest/assets/pull_failed new file mode 100755 index 000000000..8a3238df7 --- /dev/null +++ b/app/src/androidTest/assets/pull_failed @@ -0,0 +1,5 @@ +#!/bin/bash +P=/sdcard/Android/data/org.isoron.uhabits/cache/Failed/ + +adb pull $P Failed/ +adb shell rm -r $P diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png new file mode 100644 index 000000000..6f744b1fe Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png new file mode 100644 index 000000000..c3190d58c Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png b/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png new file mode 100644 index 000000000..3aad412d5 Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png new file mode 100644 index 000000000..92dfa2efe Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/checked.png b/app/src/androidTest/assets/views/CheckmarkView/checked.png new file mode 100644 index 000000000..7884c804f Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/checked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png b/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png new file mode 100644 index 000000000..3096be180 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/large_size.png b/app/src/androidTest/assets/views/CheckmarkView/large_size.png new file mode 100644 index 000000000..79152fb18 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/large_size.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/unchecked.png b/app/src/androidTest/assets/views/CheckmarkView/unchecked.png new file mode 100644 index 000000000..b0d90c5c0 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/unchecked.png differ diff --git a/app/src/androidTest/assets/views/RingView/render.png b/app/src/androidTest/assets/views/RingView/render.png new file mode 100644 index 000000000..c77355c7c Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/render.png differ diff --git a/app/src/androidTest/assets/views/RingView/renderDifferentParams.png b/app/src/androidTest/assets/views/RingView/renderDifferentParams.png new file mode 100644 index 000000000..02dfdf803 Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/renderDifferentParams.png differ diff --git a/app/src/androidTest/assets/views/RingView/renderLongLabel.png b/app/src/androidTest/assets/views/RingView/renderLongLabel.png new file mode 100644 index 000000000..48b9998ab Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/renderLongLabel.png differ diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java new file mode 100644 index 000000000..1ac8894c8 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.models.HabitFixtures; +import org.isoron.uhabits.views.CheckmarkView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CheckmarkViewTest extends ViewTest +{ + private CheckmarkView view; + private Habit habit; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createNonDailyHabit(); + view = new CheckmarkView(targetContext); + view.setHabit(habit); + measureView(dpToPixels(100), dpToPixels(200), view); + } + + @Test + public void render_checked() throws IOException + { + assertRenders(view, "CheckmarkView/checked.png"); + } + + @Test + public void render_unchecked() throws IOException + { + habit.repetitions.toggle(DateHelper.getStartOfToday()); + view.refreshData(); + + assertRenders(view, "CheckmarkView/unchecked.png"); + } + + @Test + public void render_implicitlyChecked() throws IOException + { + long today = DateHelper.getStartOfToday(); + long day = DateHelper.millisecondsInOneDay; + habit.repetitions.toggle(today); + habit.repetitions.toggle(today - day); + habit.repetitions.toggle(today - 2 * day); + view.refreshData(); + + assertRenders(view, "CheckmarkView/implicitly_checked.png"); + } + + @Test + public void render_largeSize() throws IOException + { + measureView(dpToPixels(300), dpToPixels(300), view); + assertRenders(view, "CheckmarkView/large_size.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java new file mode 100644 index 000000000..9ac6c1b5b --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.views.RingView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RingViewTest extends ViewTest +{ + private RingView view; + + @Before + public void setup() + { + super.setup(); + + view = new RingView(targetContext); + view.setLabel("Hello world"); + view.setPercentage(0.6f); + view.setColor(ColorHelper.palette[0]); + view.setMaxDiameter(dpToPixels(100)); + } + + @Test + public void render_base() throws IOException + { + measureView(dpToPixels(100), dpToPixels(100), view); + assertRenders(view, "RingView/render.png"); + } + + @Test + public void render_withLongLabel() throws IOException + { + view.setLabel("The quick brown fox jumps over the lazy fox"); + + measureView(dpToPixels(100), dpToPixels(100), view); + assertRenders(view, "RingView/renderLongLabel.png"); + } + + @Test + public void render_withDifferentParams() throws IOException + { + view.setLabel("Habit Strength"); + view.setPercentage(0.25f); + view.setMaxDiameter(dpToPixels(50)); + view.setColor(ColorHelper.palette[5]); + + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "RingView/renderDifferentParams.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java new file mode 100644 index 000000000..8047435c6 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.test.InstrumentationRegistry; +import android.view.View; + +import org.isoron.uhabits.helpers.DialogHelper; +import org.junit.Before; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static junit.framework.Assert.fail; + +public class ViewTest +{ + protected static final double SIMILARITY_CUTOFF = 0.02; + public static final int HISTOGRAM_BIN_SIZE = 8; + + protected Context testContext; + protected Context targetContext; + + @Before + public void setup() + { + targetContext = InstrumentationRegistry.getTargetContext(); + testContext = InstrumentationRegistry.getContext(); + } + + protected void measureView(int width, int height, View view) + { + int specWidth = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); + int specHeight = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); + + view.measure(specWidth, specHeight); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + protected void assertRenders(View view, String expectedImagePath) throws IOException + { + StringBuilder errorMessage = new StringBuilder(); + expectedImagePath = getVersionedViewAssetPath(expectedImagePath); + + view.setDrawingCacheEnabled(true); + view.buildDrawingCache(); + Bitmap actual = view.getDrawingCache(); + Bitmap expected = getBitmapFromAssets(expectedImagePath); + + int width = actual.getWidth(); + int height = actual.getHeight(); + Bitmap scaledExpected = Bitmap.createScaledBitmap(expected, width, height, true); + + double distance; + boolean similarEnough = true; + + if ((distance = compareHistograms(getHistogram(actual), getHistogram(scaledExpected))) > SIMILARITY_CUTOFF) + { + similarEnough = false; + errorMessage.append(String.format( + "Rendered image has wrong histogram (distance=%f). ", + distance)); + } + + if(!similarEnough) + { + saveBitmap(expectedImagePath, ".scaledExpected", scaledExpected); + String path = saveBitmap(expectedImagePath, ".actual", actual); + errorMessage.append(String.format("Actual rendered image " + "saved to %s", path)); + fail(errorMessage.toString()); + } + + actual.recycle(); + expected.recycle(); + scaledExpected.recycle(); + } + + private Bitmap getBitmapFromAssets(String path) throws IOException + { + InputStream stream = testContext.getAssets().open(path); + return BitmapFactory.decodeStream(stream); + } + + private String getVersionedViewAssetPath(String path) + { + String result = null; + + if (android.os.Build.VERSION.SDK_INT >= 21) + { + try + { + String vpath = "views-v21/" + path; + testContext.getAssets().open(vpath); + result = vpath; + } + catch (IOException e) + { + // ignored + } + } + + if(result == null) + result = "views/" + path; + + return result; + } + + private String saveBitmap(String filename, String suffix, Bitmap bitmap) + throws IOException + { + String absolutePath = String.format("%s/Failed/%s", targetContext.getExternalCacheDir(), + filename.replaceAll("\\.png$", suffix + ".png")); + new File(absolutePath).getParentFile().mkdirs(); + FileOutputStream out = new FileOutputStream(absolutePath); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + + return absolutePath; + } + + private int[][] getHistogram(Bitmap bitmap) + { + int histogram[][] = new int[4][256 / HISTOGRAM_BIN_SIZE]; + + for(int x = 0; x < bitmap.getWidth(); x++) + { + for(int y = 0; y < bitmap.getHeight(); y++) + { + int color = bitmap.getPixel(x, y); + int[] argb = new int[]{ + (color >> 24) & 0xff, //alpha + (color >> 16) & 0xff, //red + (color >> 8) & 0xff, //green + (color ) & 0xff //blue + }; + + histogram[0][argb[0] / HISTOGRAM_BIN_SIZE]++; + histogram[1][argb[1] / HISTOGRAM_BIN_SIZE]++; + histogram[2][argb[2] / HISTOGRAM_BIN_SIZE]++; + histogram[3][argb[3] / HISTOGRAM_BIN_SIZE]++; + } + } + + return histogram; + } + + private double compareHistograms(int[][] actualHistogram, int[][] expectedHistogram) + { + long diff = 0; + long total = 0; + + for(int i = 0; i < 256 / HISTOGRAM_BIN_SIZE; i ++) + { + diff += Math.abs(actualHistogram[0][i] - expectedHistogram[0][i]); + diff += Math.abs(actualHistogram[1][i] - expectedHistogram[1][i]); + diff += Math.abs(actualHistogram[2][i] - expectedHistogram[2][i]); + diff += Math.abs(actualHistogram[3][i] - expectedHistogram[3][i]); + + total += actualHistogram[0][i]; + total += actualHistogram[1][i]; + total += actualHistogram[2][i]; + total += actualHistogram[3][i]; + } + + return (double) diff / total / 2; + } + + protected int dpToPixels(int dp) + { + return (int) DialogHelper.dpToPixels(targetContext, dp); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java index 3f0950fe6..5455e974d 100644 --- a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java +++ b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java @@ -30,10 +30,11 @@ import android.text.Layout; import android.text.StaticLayout; import android.text.TextPaint; import android.util.AttributeSet; +import android.util.Log; import android.view.View; -import org.isoron.uhabits.helpers.ColorHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; import org.isoron.uhabits.models.Habit; public class CheckmarkView extends View @@ -48,9 +49,9 @@ public class CheckmarkView extends View private int width; private int height; - private int leftMargin; - private int topMargin; - private int padding; + private float leftMargin; + private float topMargin; + private float padding; private String label; private String fa_check; @@ -65,6 +66,7 @@ public class CheckmarkView extends View private Rect rect; private TextPaint textPaint; private StaticLayout labelLayout; + private Habit habit; public CheckmarkView(Context context) { @@ -114,11 +116,8 @@ public class CheckmarkView extends View public void setHabit(Habit habit) { - this.check_status = habit.checkmarks.getTodayValue(); - this.star_status = habit.scores.getTodayStarStatus(); - this.primaryColor = Color.argb(230, Color.red(habit.color), Color.green(habit.color), Color.blue(habit.color)); - this.label = habit.name; - updateLabel(); + this.habit = habit; + refreshData(); } @Override @@ -152,8 +151,6 @@ public class CheckmarkView extends View pIcon.setTextSize(width * 0.5f); pIcon.getTextBounds(text, 0, 1, rect); -// canvas.drawLine(0, 0.67f * height, width, 0.67f * height, pIcon); - int y = (int) ((0.67f * height - rect.bottom - rect.top) / 2); canvas.drawText(text, width / 2, y, pIcon); } @@ -188,18 +185,25 @@ public class CheckmarkView extends View this.width = getMeasuredWidth(); this.height = getMeasuredHeight(); - leftMargin = (int) (width * 0.015); - topMargin = (int) (height * 0.015); + leftMargin = (width * 0.015f); + topMargin = (height * 0.015f); padding = 8 * leftMargin; textPaint.setTextSize(0.15f * width); - updateLabel(); + refreshData(); } - private void updateLabel() + public void refreshData() { + this.check_status = habit.checkmarks.getTodayValue(); + this.star_status = habit.scores.getTodayStarStatus(); + this.primaryColor = Color.argb(230, Color.red(habit.color), Color.green(habit.color), + Color.blue(habit.color)); + this.label = habit.name; + textPaint.setColor(Color.WHITE); - labelLayout = new StaticLayout(label, textPaint, width - 2 * leftMargin - 2 * padding, + labelLayout = new StaticLayout(label, textPaint, + (int) (width - 2 * leftMargin - 2 * padding), Layout.Alignment.ALIGN_CENTER, 1.0f, 0.0f, false); } diff --git a/app/src/main/java/org/isoron/uhabits/views/RingView.java b/app/src/main/java/org/isoron/uhabits/views/RingView.java index a8314a453..ce02ed5f1 100644 --- a/app/src/main/java/org/isoron/uhabits/views/RingView.java +++ b/app/src/main/java/org/isoron/uhabits/views/RingView.java @@ -29,11 +29,11 @@ import android.text.Layout; import android.text.StaticLayout; import android.text.TextPaint; import android.util.AttributeSet; +import android.util.Log; import android.view.View; -import org.isoron.uhabits.helpers.ColorHelper; -import org.isoron.uhabits.helpers.DialogHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.DialogHelper; public class RingView extends View { @@ -48,10 +48,16 @@ public class RingView extends View private int width; private int height; private float diameter; + private float maxDiameter; private float textSize; private int fadedTextColor; + public RingView(Context context) + { + super(context); + init(); + } public RingView(Context context, AttributeSet attrs) { @@ -59,25 +65,28 @@ public class RingView extends View this.label = DialogHelper.getAttribute(context, attrs, "label"); this.maxDiameter = DialogHelper.getFloatAttribute(context, attrs, "maxDiameter"); - this.maxDiameter = DialogHelper.dpToPixels(context, maxDiameter); - this.textSize = getResources().getDimension(R.dimen.smallTextSize); - this.color = ColorHelper.palette[7]; - this.percentage = 0.75f; init(); } public void setColor(int color) { this.color = color; - pRing.setColor(color); - postInvalidate(); + } + + public void setMaxDiameter(float maxDiameter) + { + this.maxDiameter = maxDiameter; + } + + public void setLabel(String label) + { + this.label = label; } public void setPercentage(float percentage) { this.percentage = percentage; - postInvalidate(); } private void init() @@ -88,6 +97,8 @@ public class RingView extends View pRing.setTextAlign(Paint.Align.CENTER); fadedTextColor = getResources().getColor(R.color.fadedTextColor); + textSize = getResources().getDimension(R.dimen.smallTextSize); + Log.d("RingView", String.format("textSize=%f", textSize)); rect = new RectF(); } diff --git a/circle.yml b/circle.yml index 84be75a54..62ea01de5 100644 --- a/circle.yml +++ b/circle.yml @@ -11,8 +11,6 @@ test: parallel: true - circle-android wait-for-boot - adb shell input keyevent 82 - - ./gradlew -PdisablePreDex connectedAndroidTest + - ./run_tests - cp -r app/build/outputs $CIRCLE_ARTIFACTS || echo ok - - cp -r app/build/reports/androidTests/connected/* $CIRCLE_TEST_REPORTS || echo ok - - adb logcat -d > $CIRCLE_TEST_REPORTS/logcat.txt - - bash <(curl -s https://codecov.io/bash) + - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/run_tests b/run_tests new file mode 100755 index 000000000..2058d04ee --- /dev/null +++ b/run_tests @@ -0,0 +1,53 @@ +#!/bin/bash +PACKAGE_NAME=org.isoron.uhabits +OUTPUT_DIR=app/build/outputs +LOG=${OUTPUT_DIR}/test.log + +info() { + local COLOR='\033[1;32m' + local NC='\033[0m' + echo -e " $COLOR*$NC $1" +} + +info "Cleaning output directory..." +rm -rf ${OUTPUT_DIR} +mkdir -p ${OUTPUT_DIR} + +info "Building and installing APK..." +./gradlew assembleDebug assembleAndroidTest >> $LOG 2>> $LOG || exit 1 +adb install -r ${OUTPUT_DIR}/apk/app-debug.apk >> $LOG 2>> $LOG || exit 1 +adb install -r ${OUTPUT_DIR}/apk/app-debug-androidTest-unaligned.apk \ + >> $LOG 2>> $LOG || exit 1 + +info "Granting permission to disable animations..." +adb shell pm grant org.isoron.uhabits android.permission.SET_ANIMATION_SCALE \ + >> $LOG 2>> $LOG || exit 1 + +info "Running tests..." +adb shell am instrument \ + -e coverage true $* \ + -w ${PACKAGE_NAME}.test/android.support.test.runner.AndroidJUnitRunner \ + | tee ${OUTPUT_DIR}/runner.txt \ + | tee -a $LOG +grep -q "FAILURES\!\!\!" ${OUTPUT_DIR}/runner.txt && failed=1 + +info "Fetching failed generated files..." +mkdir -p ${OUTPUT_DIR}/failed +adb pull /sdcard/Android/data/${PACKAGE_NAME}/cache/Failed \ + ${OUTPUT_DIR}/failed >> $LOG 2>> $LOG +adb shell rm -r /sdcard/Android/data/${PACKAGE_NAME}/cache/ >> $LOG 2>> $LOG + +info "Fetching logcat..." +adb logcat -d > ${OUTPUT_DIR}/logcat.txt + +info "Building coverage report..." +mkdir -p ${OUTPUT_DIR}/code-coverage/connected/ +adb pull /data/data/${PACKAGE_NAME}/files/coverage.ec \ + ${OUTPUT_DIR}/code-coverage/connected/ >> $LOG 2>> $LOG +./gradlew app:createDebugCoverageReport \ + -x app:connectedDebugAndroidTest >> $LOG 2>> $LOG + +info "Uninstalling test APK..." +adb uninstall ${PACKAGE_NAME}.test >> $LOG 2>> $LOG + +exit $failed