/* * Copyright (C) 2013 The Android Open Source Project * * 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 com.android.tools.idea.rendering; import com.android.sdklib.devices.Device; import com.android.tools.idea.configurations.Configuration; import com.android.tools.idea.configurations.ConfigurationManager; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import org.jetbrains.android.AndroidTestCase; import org.jetbrains.android.facet.AndroidFacet; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicReference; import static java.awt.image.BufferedImage.TYPE_INT_ARGB; import static java.io.File.separator; import static java.io.File.separatorChar; /** * Base for unit tests which perform rendering; this test can generate configurations and perform * rendering, then check that the rendered result matches a known thumbnail (by a certain maximum * percentage difference). The test will generate the required thumbnail if it does not exist, * so to create a new render test just call {@link #checkRenderedImage(java.awt.image.BufferedImage, String)} * and run the test once; then verify that the thumbnail looks fine, and if so, check it in; the test * will now check that subsequent renders are similar. * <p> * The reason the test checks for similarity is that whenever rendering includes fonts, there are some * platform differences in text rendering etc which does not give us a pixel for pixel match. */ public abstract class RenderTestBase extends AndroidTestCase { protected static final String DEFAULT_DEVICE_ID = "Nexus 4"; private static final String DEFAULT_THEME_STYLE = "@android:style/Theme.Holo"; private static final float MAX_PERCENT_DIFFERENT = 5.0f; @Override protected boolean requireRecentSdk() { return true; } protected RenderService getRenderService(VirtualFile file) throws Exception { Configuration configuration = getConfiguration(file, DEFAULT_DEVICE_ID, DEFAULT_THEME_STYLE); return getRenderService(file, configuration); } protected Configuration getConfiguration(VirtualFile file, String deviceId) { AndroidFacet facet = AndroidFacet.getInstance(myModule); assertNotNull(facet); ConfigurationManager configurationManager = facet.getConfigurationManager(); assertNotNull(configurationManager); Configuration configuration = configurationManager.getConfiguration(file); configuration.setDevice(findDeviceById(configurationManager, deviceId), false); return configuration; } protected Configuration getConfiguration(VirtualFile file, String deviceId, String themeStyle) { Configuration configuration = getConfiguration(file, deviceId); configuration.setTheme(themeStyle); return configuration; } protected RenderService getRenderService(VirtualFile file, Configuration configuration) throws IOException { AndroidFacet facet = AndroidFacet.getInstance(myModule); PsiFile psiFile = PsiManager.getInstance(getProject()).findFile(file); assertNotNull(psiFile); assertNotNull(facet); RenderLogger logger = new RenderLogger("myLogger", myModule); RenderService service = RenderService.create(facet, myModule, psiFile, configuration, logger, null); assertNotNull(service); return service; } protected void checkRendering(RenderService service, String thumbnailPath) throws IOException { // Next try a render RenderResult result = service.render(); RenderResult render = renderOnSeparateThread(service); assertNotNull(render); assertNotNull(result); RenderedImage image = result.getImage(); assertNotNull(image); image.setMaxSize(200, 200); image.setDeviceFrameEnabled(false); @SuppressWarnings("UndesirableClassUsage") // Don't want Retina images in unit tests BufferedImage thumbnail = new BufferedImage(image.getRequiredWidth(), image.getRequiredHeight(), TYPE_INT_ARGB); Graphics graphics = thumbnail.getGraphics(); image.paint(graphics, 0, 0); graphics.dispose(); checkRenderedImage(thumbnail, "render" + separator + "thumbnails" + separator + thumbnailPath.replace('/', separatorChar)); } @Nullable public static RenderResult renderOnSeparateThread(@NotNull final RenderService service) { // Ensure that we don't render on the read lock (since we want to test that all parts of the // rendering system which needs a read lock asks for one!) final AtomicReference<RenderResult> holder = new AtomicReference<RenderResult>(); Thread thread = new Thread() { @Override public void run() { holder.set(service.render()); } }; thread.start(); try { thread.join(); } catch (InterruptedException e) { fail("Interrupted"); } return holder.get(); } @NotNull protected static Device findDeviceById(ConfigurationManager manager, String id) { for (Device device : manager.getDevices()) { if (device.getId().equals(id)) { return device; } } fail("Can't find device " + id); throw new IllegalStateException(); } protected void checkRenderedImage(BufferedImage image, String relativePath) throws IOException { relativePath = relativePath.replace('/', separatorChar); final String testDataPath = getTestDataPath(); assert testDataPath != null : "test data path not specified"; File fromFile = new File(testDataPath + "/" + relativePath); System.out.println("fromFile=" + fromFile); if (fromFile.exists()) { BufferedImage goldenImage = ImageIO.read(fromFile); assertImageSimilar(relativePath, goldenImage, image, MAX_PERCENT_DIFFERENT); } else { File dir = fromFile.getParentFile(); assertNotNull(dir); if (!dir.exists()) { boolean ok = dir.mkdirs(); assertTrue(dir.getPath(), ok); } ImageIO.write(image, "PNG", fromFile); fail("File did not exist, created " + fromFile); } } public static void assertImageSimilar(String imageName, BufferedImage goldenImage, BufferedImage image, double maxPercentDifferent) throws IOException { assertEquals("Only TYPE_INT_ARGB image types are supported", TYPE_INT_ARGB, image.getType()); if (goldenImage.getType() != TYPE_INT_ARGB) { @SuppressWarnings("UndesirableClassUsage") // Don't want Retina images in unit tests BufferedImage temp = new BufferedImage(goldenImage.getWidth(), goldenImage.getHeight(), TYPE_INT_ARGB); temp.getGraphics().drawImage(goldenImage, 0, 0, null); goldenImage = temp; } assertEquals(TYPE_INT_ARGB, goldenImage.getType()); int imageWidth = Math.min(goldenImage.getWidth(), image.getWidth()); int imageHeight = Math.min(goldenImage.getHeight(), image.getHeight()); // Blur the images to account for the scenarios where there are pixel // differences // in where a sharp edge occurs // goldenImage = blur(goldenImage, 6); // image = blur(image, 6); int width = 3 * imageWidth; @SuppressWarnings("UnnecessaryLocalVariable") int height = imageHeight; // makes code more readable @SuppressWarnings("UndesirableClassUsage") // Don't want Retina images in unit tests BufferedImage deltaImage = new BufferedImage(width, height, TYPE_INT_ARGB); Graphics g = deltaImage.getGraphics(); // Compute delta map long delta = 0; for (int y = 0; y < imageHeight; y++) { for (int x = 0; x < imageWidth; x++) { int goldenRgb = goldenImage.getRGB(x, y); int rgb = image.getRGB(x, y); if (goldenRgb == rgb) { deltaImage.setRGB(imageWidth + x, y, 0x00808080); continue; } // If the pixels have no opacity, don't delta colors at all if (((goldenRgb & 0xFF000000) == 0) && (rgb & 0xFF000000) == 0) { deltaImage.setRGB(imageWidth + x, y, 0x00808080); continue; } int deltaR = ((rgb & 0xFF0000) >>> 16) - ((goldenRgb & 0xFF0000) >>> 16); int newR = 128 + deltaR & 0xFF; int deltaG = ((rgb & 0x00FF00) >>> 8) - ((goldenRgb & 0x00FF00) >>> 8); int newG = 128 + deltaG & 0xFF; int deltaB = (rgb & 0x0000FF) - (goldenRgb & 0x0000FF); int newB = 128 + deltaB & 0xFF; int avgAlpha = ((((goldenRgb & 0xFF000000) >>> 24) + ((rgb & 0xFF000000) >>> 24)) / 2) << 24; int newRGB = avgAlpha | newR << 16 | newG << 8 | newB; deltaImage.setRGB(imageWidth + x, y, newRGB); delta += Math.abs(deltaR); delta += Math.abs(deltaG); delta += Math.abs(deltaB); } } // 3 different colors, 256 color levels long total = imageHeight * imageWidth * 3L * 256L; float percentDifference = (float) (delta * 100 / (double) total); String error = null; if (percentDifference > maxPercentDifferent) { error = String.format("Images differ (by %.1f%%)", percentDifference); } else if (Math.abs(goldenImage.getWidth() - image.getWidth()) >= 2) { error = "Widths differ too much for " + imageName + ": " + goldenImage.getWidth() + "x" + goldenImage.getHeight() + "vs" + image.getWidth() + "x" + image.getHeight(); } else if (Math.abs(goldenImage.getHeight() - image.getHeight()) >= 2) { error = "Heights differ too much for " + imageName + ": " + goldenImage.getWidth() + "x" + goldenImage.getHeight() + "vs" + image.getWidth() + "x" + image.getHeight(); } assertEquals(TYPE_INT_ARGB, image.getType()); if (error != null) { // Expected on the left // Golden on the right g.drawImage(goldenImage, 0, 0, null); g.drawImage(image, 2 * imageWidth, 0, null); // Labels if (imageWidth > 80) { g.setColor(Color.RED); g.drawString("Expected", 10, 20); g.drawString("Actual", 2 * imageWidth + 10, 20); } File output = new File(getTempDir(), "delta-"+ imageName.replace(separatorChar, '_')); if (output.exists()) { boolean deleted = output.delete(); assertTrue(deleted); } ImageIO.write(deltaImage, "PNG", output); error += " - see details in " + output.getPath(); System.out.println(error); fail(error); } g.dispose(); } @NotNull public static File getTempDir() { if (System.getProperty("os.name").equals("Mac OS X")) { return new File("/tmp"); //$NON-NLS-1$ } return new File(System.getProperty("java.io.tmpdir")); //$NON-NLS-1$ } }