package games.strategy.engine.data.annotations; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import java.io.File; import java.io.FileFilter; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import org.junit.Test; import games.strategy.debug.ClientLogger; import games.strategy.engine.data.DefaultAttachment; import games.strategy.engine.data.IAttachment; import games.strategy.triplea.attachments.CanalAttachment; import games.strategy.triplea.attachments.PlayerAttachment; import games.strategy.triplea.attachments.PoliticalActionAttachment; import games.strategy.triplea.attachments.RelationshipTypeAttachment; import games.strategy.triplea.attachments.RulesAttachment; import games.strategy.triplea.attachments.TechAbilityAttachment; import games.strategy.triplea.attachments.TechAttachment; import games.strategy.triplea.attachments.TerritoryAttachment; import games.strategy.triplea.attachments.TerritoryEffectAttachment; import games.strategy.triplea.attachments.TriggerAttachment; import games.strategy.triplea.attachments.UnitAttachment; import games.strategy.triplea.attachments.UnitSupportAttachment; import games.strategy.util.IntegerMap; import games.strategy.util.PropertyUtil; /** * A test that validates that all attachment classes have properties with valid setters and getters. */ public class ValidateAttachmentsTest { /** * Test that the Example Attachment is valid. */ @Test public void testExample() { final String errors = validateAttachment(ExampleAttachment.class); assertEquals(0, errors.length()); } /** * Tests that the algorithm finds invalidly named field. */ @Test public void testInvalidField() { final String errors = validateAttachment(InvalidFieldNameExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("missing field for setter")); } /** * tests that the algorithm will find invalid annotation on a getters. */ @Test public void testAnnotationOnGetter() { final String errors = validateAttachment(InvalidGetterExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("begins with 'set' so must have either InternalDoNotExport or GameProperty annotation")); } /** * Tests that the algorithm will find invalid return types. */ @Test public void testInvalidReturnType() { final String errors = validateAttachment(InvalidReturnTypeExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("property field is type")); } /** * Tests that the algorithm will find invalid clear method. */ @Test public void testInvalidClearMethod() { final String errors = validateAttachment(InvalidClearExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("doesn't have a clear method")); } /** * Tests that the algorithm will find invalid clear method. */ @Test public void testInvalidResetMethod() { final String errors = validateAttachment(InvalidResetExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("doesn't have a resetter method")); } /** * Tests that the algorithm will find adders that doesn't have type IntegerMap. */ @Test public void testInvalidFieldType() { final String errors = validateAttachment(InvalidFieldTypeExample.class); assertTrue(errors.length() > 0); assertTrue(errors.contains("is not a Collection or Map or IntegerMap")); } private static List<Class<? extends IAttachment>> getKnownAttachmentClasses() { final List<Class<? extends IAttachment>> result = new ArrayList<>(); result.add(DefaultAttachment.class); result.add(CanalAttachment.class); result.add(PlayerAttachment.class); result.add(PoliticalActionAttachment.class); result.add(RelationshipTypeAttachment.class); result.add(RulesAttachment.class); result.add(TechAttachment.class); result.add(TerritoryAttachment.class); result.add(TerritoryEffectAttachment.class); result.add(TriggerAttachment.class); result.add(UnitAttachment.class); result.add(UnitSupportAttachment.class); result.add(TechAbilityAttachment.class); // result.add(AbstractConditionsAttachment.class); // result.add(AbstractPlayerRulesAttachment.class); // result.add(AbstractRulesAttachment.class); // result.add(AbstractTriggerAttachment.class); return result; } /** * When testAllAttachments doesn't work, we can test specific attachments here. */ @Test public void testSpecificAttachments() { final StringBuilder sb = new StringBuilder(); for (final Class<? extends IAttachment> clazz : getKnownAttachmentClasses()) { sb.append(validateAttachment(clazz)); } if (sb.length() > 0) { System.out.println(sb.toString()); // fail(sb.toString()); } } /** * Scans the compiled /classes folder and finds all classes that implement IAttachment to verify that * all @GameProperty have valid setters and getters. */ @Test public void testAllAttachments() { // find the classes folder final URL url = getClass().getResource("/"); File file = null; try { file = new File(url.toURI()); file = new File(file.getParent(), "classes"); } catch (final URISyntaxException e) { fail(e.getMessage()); ClientLogger.logQuietly(e); } final String errors = findAttachmentsAndValidate(file); if (errors.length() > 0) { System.out.println(errors); // fail("\n" + errors); } } // file to find classes or directory static FileFilter s_classOrDirectory = new FileFilter() { @Override public boolean accept(final File file) { return file.isDirectory() || file.getName().endsWith(".class"); } }; /** * Recursive method to find all classes that implement IAttachment and validate that they use the @GameProperty * annotation correctly. * * @param file * the file or directory */ private static String findAttachmentsAndValidate(final File file) { final StringBuilder sb = new StringBuilder(""); if (file.isDirectory()) { final File[] files = file.listFiles(s_classOrDirectory); for (final File aFile : files) { sb.append(findAttachmentsAndValidate(aFile)); } } else { final String fileName = file.getAbsolutePath(); final String classesRoot = File.separatorChar + "classes" + File.separatorChar; final int index = fileName.indexOf(classesRoot) + classesRoot.length(); String className = fileName.substring(index); className = className.replace(File.separator, "."); if (!className.endsWith(".class")) { return ""; } className = className.substring(0, className.lastIndexOf(".class")); if (isSkipClass(className)) { return ""; } Class<?> clazz; try { clazz = Class.forName(className); if (!clazz.isInterface() && IAttachment.class.isAssignableFrom(clazz)) { @SuppressWarnings("unchecked") final Class<? extends IAttachment> attachmentClass = (Class<? extends IAttachment>) clazz; // sb.append("Testing class: " + attachmentClass.getCanonicalName()); sb.append(validateAttachment(attachmentClass)); } } catch (final ClassNotFoundException e) { sb.append("Warning: Class ").append(className).append(" not found. Error Message: ").append(e.getMessage()) .append("\n"); } catch (final Throwable e) { sb.append("Warning: Class ").append(className).append(" could not be loaded. Error Message: ") .append(e.getMessage()).append("\n"); } } return sb.toString(); } /** * todo(kg) fix this * ReliefImageBreaker and TileImageBreaker has a static field that opens a save dialog!!! * "InvalidGetterExample", "InvalidFieldNameExample", "InvalidReturnTypeExample" are skipped because they are * purposely invalid, and use * to test the validation algorithm. */ public static final List<String> SKIPCLASSES = Arrays.asList("ReliefImageBreaker", "TileImageBreaker", "InvalidGetterExample", "InvalidFieldNameExample", "InvalidReturnTypeExample", "InvalidClearExample", "InvalidFieldTypeExample", "ChatPlayerPanel", "GUID", "Node"); /** * Contains a list of classes which has static initializes, unfortunately you can't reflect this, since loading the * class triggers * the initializer. * * @param className * the class name * @return true if this class has a static initializer */ private static boolean isSkipClass(final String className) { for (final String staticInitClass : SKIPCLASSES) { if (className.contains(staticInitClass)) { return true; } } return false; } private static String validateAttachment(final Class<? extends IAttachment> clazz) { final StringBuilder sb = new StringBuilder(); for (final Method setter : clazz.getMethods()) { final boolean internalDoNotExportAnnotation = setter.isAnnotationPresent(InternalDoNotExport.class); final boolean startsWithSet = setter.getName().startsWith("set"); final boolean gamePropertyAnnotation = setter.isAnnotationPresent(GameProperty.class); if (internalDoNotExportAnnotation && gamePropertyAnnotation) { sb.append("WARNING: Class ").append(clazz.getCanonicalName()).append(" setter ").append(setter.getName()) .append(": cannot have both InternalDoNotExport and GameProperty annotations"); continue; } else if (startsWithSet && !(internalDoNotExportAnnotation || gamePropertyAnnotation)) { sb.append("WARNING: Class ").append(clazz.getCanonicalName()).append(" setter ").append(setter.getName()) .append(": begins with 'set' so must have either InternalDoNotExport or GameProperty annotation"); continue; } else if (!startsWithSet && gamePropertyAnnotation) { sb.append("WARNING: Class ").append(clazz.getCanonicalName()).append(" setter ").append(setter.getName()) .append(": does not begin with 'set' but has GameProperty annotation"); continue; } else if (!startsWithSet || internalDoNotExportAnnotation) { // no error, we are supposed to ignore things that are labeled as ignore, or do not start with 'set' continue; } else if (!startsWithSet && !gamePropertyAnnotation) { sb.append("WARNING: Class ").append(clazz.getCanonicalName()).append(" setter ").append(setter.getName()) .append(": I must have missed a possibility"); continue; } Method getter; final GameProperty annotation = setter.getAnnotation(GameProperty.class); if (annotation == null) { sb.append("Class ").append(clazz.getCanonicalName()).append(" has ").append(setter.getName()) .append(" and it doesn't have the GameProperty annotation on it\n"); } if (!setter.getReturnType().equals(void.class)) { sb.append("Class ").append(clazz.getCanonicalName()).append(" has ").append(setter.getName()) .append(" and it doesn't return void\n"); } // the property name must be derived from the method name final String propertyName = getPropertyName(setter); // For debug purposes only // sb.append("TESTING: Class " + clazz.getCanonicalName() + ", setter property " + propertyName + "\n"); // if this is a deprecated setter, we skip it now if (setter.getAnnotation(Deprecated.class) != null) { continue; } // validate that there is a field and a getter Field field = null; try { field = PropertyUtil.getFieldIncludingFromSuperClasses(clazz, "m_" + propertyName, false); // adders must have a field of type IntegerMap, or be a collection of sorts if (annotation.adds()) { if (!(Collection.class.isAssignableFrom(field.getType()) || Map.class.isAssignableFrom(field.getType()) || IntegerMap.class.isAssignableFrom(field.getType()))) { sb.append("Class ").append(clazz.getCanonicalName()).append(" has a setter ").append(setter.getName()) .append(" which adds but the field ").append(field.getName()) .append(" is not a Collection or Map or IntegerMap\n"); } } } catch (final IllegalStateException e) { sb.append("Class ").append(clazz.getCanonicalName()).append(" is missing field for setter ") .append(setter.getName()).append(" with @GameProperty\n"); continue; } final String resetterName = "reset" + capitalizeFirstLetter(propertyName); Method resetterMethod = null; try { resetterMethod = clazz.getMethod(resetterName); if (!resetterMethod.getReturnType().equals(void.class)) { sb.append("Class ").append(clazz.getCanonicalName()).append(" has a reset method ") .append(resetterMethod.getName()).append(" that doesn't return void\n"); } } catch (final NoSuchMethodException e) { sb.append("Class ").append(clazz.getCanonicalName()).append(" doesn't have a resetter method for property: ") .append(propertyName).append("\n"); continue; } final String getterName = "get" + capitalizeFirstLetter(propertyName); try { // getter must return same type as the field final Class<?> type = field.getType(); getter = clazz.getMethod(getterName); if (!type.equals(getter.getReturnType())) { sb.append("Class ").append(clazz.getCanonicalName()).append(". ").append(getterName).append(" returns type ") .append(getter.getReturnType().getName()).append(" but property field is type ").append(type.getName()) .append("\n"); } } catch (final NoSuchMethodException e) { sb.append("Class ").append(clazz.getCanonicalName()) .append(" doesn't have a valid getter method for property: ").append(propertyName).append("\n"); continue; } if (annotation.adds()) { // check that there is a clear method final String clearName = "clear" + capitalizeFirstLetter(propertyName); Method clearMethod = null; try { clearMethod = clazz.getMethod(clearName); } catch (final NoSuchMethodException e) { sb.append("Class ").append(clazz.getCanonicalName()) .append(" doesn't have a clear method for 'adder' property ").append(propertyName).append("\n"); continue; } if (!clearMethod.getReturnType().equals(void.class)) { sb.append("Class ").append(clazz.getCanonicalName()).append(" has a clear method ") .append(clearMethod.getName()).append(" that doesn't return void\n"); } } else if (!Modifier.isAbstract(clazz.getModifiers())) { // check the symmetry of regular setters @SuppressWarnings("unused") String method = null; try { final Constructor<? extends IAttachment> constructor = clazz.getConstructor(IAttachment.attachmentConstructorParameter); method = constructor.toString(); final IAttachment attachment = constructor.newInstance("testAttachment", null, null); Object value = null; if (field.getType().equals(Integer.TYPE)) { value = 5; } else if (field.getType().equals(Boolean.TYPE)) { value = true; } else if (field.getType().equals(String.class)) { value = "aString"; } else { // we do not handle complex types for now continue; } method = setter.toString(); if (setter.getParameterTypes()[0] == String.class) { setter.invoke(attachment, String.valueOf(value)); } else { setter.invoke(attachment, value); } method = getter.toString(); final Object getterValue = getter.invoke(attachment); if (!value.equals(getterValue)) { sb.append("Class ").append(clazz.getCanonicalName()).append(", value set could not be obtained using ") .append(getterName).append("\n"); } field.setAccessible(true); final Object fieldValue = field.get(attachment); if (!getterValue.equals(fieldValue)) { sb.append("Class ").append(clazz.getCanonicalName()).append(", ").append(getterName) .append(" returns type ").append(getterValue.getClass().getName()).append(" but field is of type ") .append(fieldValue.getClass().getName()); } } catch (final NoSuchMethodException e) { sb.append("Warning, Class ").append(clazz.getCanonicalName()).append(" testing '").append(propertyName) .append("', has no default constructor\n"); } catch (final IllegalArgumentException e) { sb.append("Warning, Class ").append(clazz.getCanonicalName()).append(" testing '").append(propertyName) .append("', has error: IllegalArgumentException: ").append(e.getMessage()).append("\n"); } catch (final InstantiationException e) { sb.append("Warning, Class ").append(clazz.getCanonicalName()).append(" testing '").append(propertyName) .append("', has error: InstantiationException: ").append(e.getMessage()).append("\n"); } catch (final IllegalAccessException e) { sb.append("Warning, Class ").append(clazz.getCanonicalName()).append(" testing '").append(propertyName) .append("', has error: IllegalAccessException: ").append(e.getMessage()).append("\n"); } catch (final InvocationTargetException e) { // this only occurs if the constructor/getter or setter throws an exception, Usually it is because we pass // null to the constructor // sb.append("Warning calling " + method + " threw exception " + e.getTargetException().getClass() + "\n"); } } } return sb.toString(); } private static String getPropertyName(final Method method) { final String propertyName = method.getName().substring("set".length()); char first = propertyName.charAt(0); first = Character.toLowerCase(first); return first + propertyName.substring(1); } private static String capitalizeFirstLetter(final String str) { char first = str.charAt(0); first = Character.toUpperCase(first); return first + str.substring(1); } }