ソースを参照

Merge branch 'test/unitTests/backFill'

* test/unitTests/backFill:
  Add tests for most of the GUI features involved.
  Add things to support testing of GUI classes
  Fix some issues with creating an AutoGrowPanel pre-loaded with multiple rows.
  Perform some testing related cleanup.
  Fix a bug in ProduceSummaryPanel rendering.
  Use @Spy for the MockObserverListener.
  Use mockito for Observer testing.
  Properly separate responsibilities for FileController so that it can be tested.
  Perform some cleanup, add another test.
  Start writing tests for FileController using Mockito.
  Properly pull in Hamcrest matchers.
  Make FileController not stupid.
  Start writing test cases for GUI components with IngredientPanel. - Create a MockCallable for a simple way to check if things are working correctly without learning mockito (Internet is Out). - Make all of the relevant text objects in the ingredient panel reachable by test code.
  Get 100% coverage on model classes.
  Add DurationTest.
  Add tests for Amount and Ingredient, as well as dependencies for using JUnit5.
Sam Jaffe 5 年 前
コミット
43e18354d3
45 ファイル変更1476 行追加173 行削除
  1. 34 1
      pom.xml
  2. 47 36
      src/main/lombok/org/leumasjaffe/recipe/controller/FileController.java
  3. 12 0
      src/main/lombok/org/leumasjaffe/recipe/controller/SaveLoadHandle.java
  4. 66 0
      src/main/lombok/org/leumasjaffe/recipe/controller/SwingSaveLoadHandle.java
  5. 2 1
      src/main/lombok/org/leumasjaffe/recipe/model/Card.java
  6. 9 3
      src/main/lombok/org/leumasjaffe/recipe/model/CompoundRecipeComponent.java
  7. 6 3
      src/main/lombok/org/leumasjaffe/recipe/model/Duration.java
  8. 3 5
      src/main/lombok/org/leumasjaffe/recipe/model/Product.java
  9. 2 2
      src/main/lombok/org/leumasjaffe/recipe/model/Recipe.java
  10. 2 1
      src/main/lombok/org/leumasjaffe/recipe/model/Rest.java
  11. 2 2
      src/main/lombok/org/leumasjaffe/recipe/model/Step.java
  12. 0 24
      src/main/lombok/org/leumasjaffe/recipe/util/Collator.java
  13. 0 47
      src/main/lombok/org/leumasjaffe/recipe/util/IOUtil.java
  14. 17 10
      src/main/lombok/org/leumasjaffe/recipe/view/AutoGrowPanel.java
  15. 8 2
      src/main/lombok/org/leumasjaffe/recipe/view/CardPanel.java
  16. 12 4
      src/main/lombok/org/leumasjaffe/recipe/view/IngredientPanel.java
  17. 14 5
      src/main/lombok/org/leumasjaffe/recipe/view/IngredientPreparationPanel.java
  18. 11 5
      src/main/lombok/org/leumasjaffe/recipe/view/PreparationPanel.java
  19. 14 2
      src/main/lombok/org/leumasjaffe/recipe/view/ProductPanel.java
  20. 13 6
      src/main/lombok/org/leumasjaffe/recipe/view/ProductSummaryPanel.java
  21. 2 2
      src/main/lombok/org/leumasjaffe/recipe/view/RecipeFrame.java
  22. 10 3
      src/main/lombok/org/leumasjaffe/recipe/view/RestPanel.java
  23. 12 5
      src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java
  24. 12 4
      src/main/lombok/org/leumasjaffe/recipe/view/SummaryIngredientPanel.java
  25. 15 0
      src/test/java/org/leumasjaffe/mock/MockObserverListener.java
  26. 11 0
      src/test/java/org/leumasjaffe/recipe/TestSuite.java
  27. 123 0
      src/test/java/org/leumasjaffe/recipe/controller/FileControllerTest.java
  28. 96 0
      src/test/java/org/leumasjaffe/recipe/model/AmountTest.java
  29. 94 0
      src/test/java/org/leumasjaffe/recipe/model/CardTest.java
  30. 72 0
      src/test/java/org/leumasjaffe/recipe/model/DurationTest.java
  31. 36 0
      src/test/java/org/leumasjaffe/recipe/model/IngredientTest.java
  32. 28 0
      src/test/java/org/leumasjaffe/recipe/model/ProductTest.java
  33. 31 0
      src/test/java/org/leumasjaffe/recipe/model/RecipeTest.java
  34. 22 0
      src/test/java/org/leumasjaffe/recipe/model/StepTest.java
  35. 133 0
      src/test/java/org/leumasjaffe/recipe/view/AutoGrowPanelTest.java
  36. 45 0
      src/test/java/org/leumasjaffe/recipe/view/CardPanelTest.java
  37. 75 0
      src/test/java/org/leumasjaffe/recipe/view/IngredientPanelTest.java
  38. 47 0
      src/test/java/org/leumasjaffe/recipe/view/IngredientPreparationPanelTest.java
  39. 52 0
      src/test/java/org/leumasjaffe/recipe/view/PreparationPanelTest.java
  40. 45 0
      src/test/java/org/leumasjaffe/recipe/view/ProductPanelTest.java
  41. 59 0
      src/test/java/org/leumasjaffe/recipe/view/ProductSummaryPanelTest.java
  42. 40 0
      src/test/java/org/leumasjaffe/recipe/view/RestPanelTest.java
  43. 57 0
      src/test/java/org/leumasjaffe/recipe/view/StepPanelTest.java
  44. 45 0
      src/test/java/org/leumasjaffe/recipe/view/SummaryIngredientPanelTest.java
  45. 40 0
      src/test/java/org/leumasjaffe/recipe/view/SwingTestCase.java

+ 34 - 1
pom.xml

@@ -113,12 +113,45 @@
       <groupId>org.hamcrest</groupId>
       <artifactId>hamcrest-all</artifactId>
       <version>1.3</version>
-      <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>org.leumasjaffe</groupId>
       <artifactId>container</artifactId>
       <version>0.3.0</version>
     </dependency>
+    <dependency>
+      <groupId>org.junit.platform</groupId>
+      <artifactId>junit-platform-runner</artifactId>
+      <version>1.7.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <version>5.7.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <version>5.7.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-params</artifactId>
+      <version>5.7.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-junit-jupiter</artifactId>
+      <version>3.6.28</version>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <version>3.6.28</version>
+    </dependency>
   </dependencies>
 </project>

+ 47 - 36
src/main/lombok/org/leumasjaffe/recipe/controller/FileController.java

@@ -1,80 +1,91 @@
 package org.leumasjaffe.recipe.controller;
 
 import java.awt.Component;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
 import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
 import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
 
-import javax.swing.JFileChooser;
-import javax.swing.JOptionPane;
-
-import org.leumasjaffe.container.functional.Result;
 import org.leumasjaffe.recipe.model.Recipe;
-import org.leumasjaffe.recipe.util.IOUtil;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
 
 import lombok.AccessLevel;
 import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
 import lombok.experimental.FieldDefaults;
 import lombok.experimental.NonFinal;
 
 @RequiredArgsConstructor
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class FileController<T extends Component & FileController.Model> {
-	public static interface Model {
+public class FileController {
+	public static interface ViewModel {
 		void setModel(Recipe model);
 	}
 	
-	JFileChooser chooser = new JFileChooser();
-	T owner;
-	@NonFinal File filename = null;
+	private static final ObjectMapper mapper;
+	SaveLoadHandle handle;
+	ViewModel viewmodel;
 	@NonFinal Recipe model = null;
 	
+	static {
+		mapper = new ObjectMapper();
+		mapper.registerModule(new Jdk8Module());
+	}
+	
+	public <T extends Component & ViewModel> FileController(T viewmodel) {
+		this.handle = new SwingSaveLoadHandle(viewmodel);
+		this.viewmodel = viewmodel;
+	}
+	
 	public void create() {
 		setModel(new Recipe());
 	}
 	
 	public void save() {
-		if (filename == null) {
-			if (chooser.showSaveDialog(owner) == JFileChooser.APPROVE_OPTION) {
-				// TODO Store the file instead
-				filename = chooser.getSelectedFile();
-			}
-			return;
-		}
-		try {
-			IOUtil.save(filename, model);
-		} catch (IOException ioe) {
-			errorPopup(ioe);
-		}
+		handle.writer().ifPresent(this::save);
 	}
 	
 	public void saveAs() {
-		this.filename = null;
+		handle.clear();
 		save();
 	}
 	
 	public void open() {
-		if (chooser.showOpenDialog(owner) == JFileChooser.APPROVE_OPTION) {
-			// TODO Store the file instead
-			filename = chooser.getSelectedFile();
-		}
-		Result.maybe(IOUtil::load).apply(filename)
-			.consume(this::setModel, this::errorPopup);
+		handle.reader().ifPresent(this::load);
 	}
 
 	@Deprecated
+	@SneakyThrows(FileNotFoundException.class)
 	public void open(final String newFilename) {
-		this.filename = new File(newFilename);
-		Result.maybe(IOUtil::load).apply(filename)
-			.consume(this::setModel, this::errorPopup);
+		load(new FileReader(new File(newFilename)));
+	}
+	
+	private void load(final Reader reader) {
+		try (Reader in = reader;
+				BufferedReader buf = new BufferedReader(in)) {
+			setModel(mapper.readValue(in, Recipe.class));
+		} catch (IOException ioe) {
+			handle.error(ioe);
+		}
 	}
 
-	private void errorPopup(final Exception ex) {
-		JOptionPane.showMessageDialog(owner, ex.getLocalizedMessage(),
-				"File Error", JOptionPane.ERROR_MESSAGE);
+	private void save(final Writer writer) {
+		try (Writer out = writer;
+				BufferedWriter buf = new BufferedWriter(out)) {
+			mapper.writeValue(buf, model);
+		} catch (IOException e) {
+			handle.error(e);
+		}
 	}
 	
 	private void setModel(Recipe recipe) {
 		this.model = recipe;
-		owner.setModel(recipe);
+		viewmodel.setModel(recipe);
 	}
 }

+ 12 - 0
src/main/lombok/org/leumasjaffe/recipe/controller/SaveLoadHandle.java

@@ -0,0 +1,12 @@
+package org.leumasjaffe.recipe.controller;
+
+import java.io.Reader;
+import java.io.Writer;
+import java.util.Optional;
+
+public interface SaveLoadHandle {
+	Optional<Writer> writer();
+	Optional<Reader> reader();
+	void clear();
+	void error(Exception ex);
+}

+ 66 - 0
src/main/lombok/org/leumasjaffe/recipe/controller/SwingSaveLoadHandle.java

@@ -0,0 +1,66 @@
+package org.leumasjaffe.recipe.controller;
+
+import java.awt.Component;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.util.Optional;
+
+import javax.swing.JFileChooser;
+import javax.swing.JOptionPane;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@RequiredArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class SwingSaveLoadHandle implements SaveLoadHandle {
+	JFileChooser chooser = new JFileChooser();
+	Component owner;
+	@NonFinal File filename = null;
+	
+	@Override
+	@SuppressWarnings("resource")
+	@SneakyThrows(IOException.class)
+	public Optional<Writer> writer() {
+		if (filename != null) {
+			return Optional.of(new FileWriter(filename));
+		} else if (chooser.showOpenDialog(owner) == JFileChooser.APPROVE_OPTION) {
+			filename = chooser.getSelectedFile();
+			return Optional.of(new FileWriter(filename));
+		} else {
+			return Optional.empty();
+		}
+	}
+	
+	@Override
+	@SuppressWarnings("resource")
+	@SneakyThrows(FileNotFoundException.class)
+	public Optional<Reader> reader() {
+		if (chooser.showOpenDialog(owner) == JFileChooser.APPROVE_OPTION) {
+			filename = chooser.getSelectedFile();
+			return Optional.of(new FileReader(filename));
+		} else {
+			return Optional.empty();
+		}
+	}
+
+	@Override
+	public void error(Exception ex) {
+		JOptionPane.showMessageDialog(owner, ex.getLocalizedMessage(),
+				"File Error", JOptionPane.ERROR_MESSAGE);
+	}
+
+	@Override
+	public void clear() {
+		this.filename = null;
+	}
+
+}

+ 2 - 1
src/main/lombok/org/leumasjaffe/recipe/model/Card.java

@@ -21,9 +21,10 @@ public class Card extends Observable.Instance implements CompoundRecipeComponent
 	Optional<Rest> rest = Optional.empty();
 	
 	public Stream<Ingredient> getIngredientsAsStream() {
-		return getComponents().flatMap(RecipeComponent::getIngredientsAsStream);
+		return cooking.stream().flatMap(RecipeComponent::getIngredientsAsStream);
 	}
 	
+	// TODO Include Rest and Preparation
 	public Stream<? extends RecipeComponent> getComponents() {
 		return cooking.stream();
 	}

+ 9 - 3
src/main/lombok/org/leumasjaffe/recipe/model/CompoundRecipeComponent.java

@@ -1,10 +1,10 @@
 package org.leumasjaffe.recipe.model;
 
 import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.stream.Stream;
 
-import org.leumasjaffe.recipe.util.Collator;
-
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties({"duration", "ingredients", "components", "ingredientsAsStream"})
@@ -20,6 +20,12 @@ interface CompoundRecipeComponent extends RecipeComponent {
 	
 	@Override
 	default Collection<Ingredient> getIngredients() {
-		return Collator.collateBy(getIngredientsAsStream(), Ingredient::key, Ingredient::plus);
+		final Map<String, Ingredient> map = new HashMap<>();
+		getIngredientsAsStream().forEach(value -> {
+        	final String key = value.key();
+            map.computeIfPresent(key, (k, v) -> value.plus(v));
+            map.computeIfAbsent(key, k -> value);
+        });
+        return map.values();
 	}
 }

+ 6 - 3
src/main/lombok/org/leumasjaffe/recipe/model/Duration.java

@@ -1,13 +1,16 @@
 package org.leumasjaffe.recipe.model;
 
+import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.Getter;
 import lombok.NoArgsConstructor;
+import lombok.Setter;
 
-@AllArgsConstructor @NoArgsConstructor @Data
+@AllArgsConstructor @NoArgsConstructor
+@Getter(AccessLevel.PACKAGE) @Setter(AccessLevel.PRIVATE)
 public class Duration {
 	@AllArgsConstructor
-	enum Display {
+	public enum Display {
 		SECONDS("s", 1), MINUTES("min", 60), HOURS("hr", 3600);
 		String abbreviation;
 		int inSeconds;

+ 3 - 5
src/main/lombok/org/leumasjaffe/recipe/model/Product.java

@@ -4,7 +4,6 @@ import java.util.List;
 import java.util.stream.Stream;
 
 import org.leumasjaffe.observer.Observable;
-import org.leumasjaffe.recipe.util.Collator;
 
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -16,13 +15,12 @@ public class Product extends Observable.Instance implements CompoundRecipeCompon
 
 	@Override
 	public Stream<Ingredient> getIngredientsAsStream() {
-		return Collator.collateBy(cards.stream().flatMap(Card::getIngredientsAsStream)
-				.map(i -> new Ingredient(i.getName(), "", i.getAmount())),
-				Ingredient::key, Ingredient::plus).stream();
+		return getComponents().flatMap(Card::getIngredientsAsStream)
+				.map(i -> new Ingredient(i.getName(), "", i.getAmount()));
 	}
 
 	@Override
-	public Stream<? extends RecipeComponent> getComponents() {
+	public Stream<Card> getComponents() {
 		return cards.stream();
 	}	
 }

+ 2 - 2
src/main/lombok/org/leumasjaffe/recipe/model/Recipe.java

@@ -19,12 +19,12 @@ public class Recipe implements CompoundRecipeComponent {
 	List<Product> products = new ArrayList<>();
 	
 	@Override
-	public Stream<? extends RecipeComponent> getComponents() {
+	public Stream<Product> getComponents() {
 		return products.stream();
 	}
 	
 	@Override
 	public Stream<Ingredient> getIngredientsAsStream() {
-		return products.stream().flatMap(Product::getIngredientsAsStream);
+		return getComponents().flatMap(Product::getIngredientsAsStream);
 	}
 }

+ 2 - 1
src/main/lombok/org/leumasjaffe/recipe/model/Rest.java

@@ -4,9 +4,10 @@ import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 import lombok.experimental.FieldDefaults;
 
-@Data
+@Data @AllArgsConstructor @NoArgsConstructor
 public class Rest {
 	@AllArgsConstructor @Getter @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 	public enum Where {

+ 2 - 2
src/main/lombok/org/leumasjaffe/recipe/model/Step.java

@@ -11,6 +11,6 @@ import lombok.EqualsAndHashCode;
 @Data @EqualsAndHashCode(callSuper=false)
 public class Step extends Observable.Instance implements RecipeComponent {
 	List<Ingredient> ingredients = new ArrayList<>();
-	Duration duration;
-	String instruction;
+	Duration duration = Duration.ZERO;
+	String instruction = "";
 }

+ 0 - 24
src/main/lombok/org/leumasjaffe/recipe/util/Collator.java

@@ -1,24 +0,0 @@
-package org.leumasjaffe.recipe.util;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.function.BinaryOperator;
-import java.util.function.Function;
-import java.util.stream.Stream;
-
-import lombok.experimental.UtilityClass;
-
-@UtilityClass
-public class Collator {
-	public <T> Collection<T> collateBy(final Stream<T> stream, final Function<T, String> getKey,
-			final BinaryOperator<T> folder) {
-		final Map<String, T> map = new HashMap<>();
-        stream.forEach(value -> {
-        	final String key = getKey.apply(value);
-            map.computeIfPresent(key, (k, v) -> folder.apply(v, value));
-            map.computeIfAbsent(key, k -> value);
-        });
-        return map.values();
-	}
-}

+ 0 - 47
src/main/lombok/org/leumasjaffe/recipe/util/IOUtil.java

@@ -1,47 +0,0 @@
-package org.leumasjaffe.recipe.util;
-
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.io.Reader;
-import java.io.Writer;
-
-import org.leumasjaffe.recipe.model.Recipe;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
-
-import lombok.NonNull;
-import lombok.experimental.UtilityClass;
-
-@UtilityClass
-public class IOUtil {	
-	Recipe load(final @NonNull Reader in) throws IOException {
-		ObjectMapper mapper = new ObjectMapper();
-		mapper.registerModule(new Jdk8Module());
-		return mapper.readValue(in, Recipe.class);
-	}
-	
-	void save(final @NonNull Writer out, final @NonNull Recipe recipe) throws IOException {
-		ObjectMapper mapper = new ObjectMapper();
-		mapper.registerModule(new Jdk8Module());
-		mapper.writeValue(out, recipe);
-	}
-
-	public Recipe load(final @NonNull File handle) throws IOException {
-		try (FileReader file = new FileReader(handle);
-				BufferedReader buf = new BufferedReader(file)) {
-			return load(buf);
-		}
-	}
-	
-	public void save(final @NonNull File handle, final @NonNull Recipe recipe) throws IOException {
-		try (FileWriter file = new FileWriter(handle);
-				BufferedWriter buf = new BufferedWriter(file)) {
-			save(buf, recipe);
-		}
-	}
-}

+ 17 - 10
src/main/lombok/org/leumasjaffe/recipe/view/AutoGrowPanel.java

@@ -41,6 +41,17 @@ public class AutoGrowPanel extends JPanel {
 		}
 	}
 	
+	private class GrowAction implements AnyActionDocumentListener {
+		@Override
+		public void update(DocumentEvent e) {
+			getBack().removeDocumentListener(this);
+			getBack().addDocumentListener(new DeleteOnEmpty(getBack()));
+			extend();
+			getBack().addDocumentListener(this);
+			getParent().getParent().validate();
+		}
+	}
+	
 	IntFunction<DocumentListenable> prod;
 	AnyActionDocumentListener grow;
 	List<DocumentListenable> members = new ArrayList<>();
@@ -54,20 +65,16 @@ public class AutoGrowPanel extends JPanel {
 	
 	public AutoGrowPanel(IntFunction<DocumentListenable> prod, int create) {
 		this.prod = prod;
-		
-		this.grow = new AnyActionDocumentListener() {
-			@Override public void update(DocumentEvent e) {
-				getBack().removeDocumentListener(this);
-				getBack().addDocumentListener(new DeleteOnEmpty(getBack()));
-				extend();
-				getParent().getParent().validate();
-			}
-		};
+		this.grow = new GrowAction();
 
 		setLayout(new VerticalLayout(5));
-		while (create-- > 0) {
+		if (create == 0) return; // TODO
+		while (create-- > 1) {
 			extend();
+			getBack().addDocumentListener(new DeleteOnEmpty(getBack()));
 		}
+		extend();
+		getBack().addDocumentListener(grow);
 	}
 
 	public <T> AutoGrowPanel(Function<T, DocumentListenable> function, List<T> ingredients) {

+ 8 - 2
src/main/lombok/org/leumasjaffe/recipe/view/CardPanel.java

@@ -8,12 +8,18 @@ import org.leumasjaffe.recipe.model.Card;
 import org.leumasjaffe.recipe.model.Preparation;
 import org.leumasjaffe.recipe.model.Rest;
 import org.leumasjaffe.recipe.model.Step;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
 import org.jdesktop.swingx.VerticalLayout;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class CardPanel extends JPanel {
-	private int steps = 0;
-	private final ForwardingObservableListener<Card> listener = new ForwardingObservableListener<>();
+	@NonFinal int steps = 0;
+	ForwardingObservableListener<Card> listener = new ForwardingObservableListener<>();
 
 	public CardPanel(final Card card) {		
 		setLayout(new VerticalLayout(5));

+ 12 - 4
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPanel.java

@@ -15,14 +15,22 @@ import org.leumasjaffe.observer.ObservableController;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import javax.swing.JFormattedTextField;
 import java.awt.Font;
 import javax.swing.JLabel;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
 	ObservableController<JTextField, Ingredient> controller;
-	private JTextField txtName;
+	@Getter(AccessLevel.PACKAGE) JTextField txtName;
+	@Getter(AccessLevel.PACKAGE) JFormattedTextField txtAmount;
+	@Getter(AccessLevel.PACKAGE) JTextField txtUnit;
+	@Getter(AccessLevel.PACKAGE) JTextField txtPreparation;
 		
 	public IngredientPanel(final Ingredient ingredient) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -53,7 +61,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
 		fmtDone.setMinimum(0.0);
 		fmtDone.setCommitsOnValidEdit(true);
-		JFormattedTextField txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount = new JFormattedTextField(fmtDone);
 		txtAmount.setValue(ingredient.getAmount().getValue());
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
@@ -64,7 +72,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		add(txtAmount, gbc_txtAmount);
 		txtAmount.setColumns(4);
 		
-		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
+		txtUnit = new JTextField(ingredient.getAmount().unitName());
 		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
 		gbc_txtUnit.insets = new Insets(0, 0, 0, 5);
@@ -75,7 +83,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		add(txtUnit, gbc_txtUnit);
 		txtUnit.setColumns(6);
 		
-		JTextField txtPreparation = new JTextField(ingredient.getPreparation());
+		txtPreparation = new JTextField(ingredient.getPreparation());
 		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();
 		gbc_txtPreparation.anchor = GridBagConstraints.ABOVE_BASELINE;

+ 14 - 5
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPreparationPanel.java

@@ -14,14 +14,23 @@ import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import javax.swing.JFormattedTextField;
 import java.awt.Font;
 import javax.swing.JLabel;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class IngredientPreparationPanel extends JPanel {
 	
-	private ObservableListener<IngredientPreparationPanel, Ingredient> listener;
+	ObservableListener<IngredientPreparationPanel, Ingredient> listener;
+	@Getter(AccessLevel.PACKAGE) JTextField txtName;
+	@Getter(AccessLevel.PACKAGE) JFormattedTextField txtAmount;
+	@Getter(AccessLevel.PACKAGE) JTextField txtUnit;
+	@Getter(AccessLevel.PACKAGE) JTextField txtPreparation;
 	
 	public IngredientPreparationPanel(final Ingredient ingredient) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -39,7 +48,7 @@ public class IngredientPreparationPanel extends JPanel {
 		gbc_label.gridy = 0;
 		add(label, gbc_label);
 		
-		JTextField txtName = new JTextField(ingredient.getName());
+		txtName = new JTextField(ingredient.getName());
 		txtName.setEditable(false);
 		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtName = new GridBagConstraints();
@@ -53,7 +62,7 @@ public class IngredientPreparationPanel extends JPanel {
 		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
 		fmtDone.setMinimum(0.0);
 		fmtDone.setCommitsOnValidEdit(true);
-		JFormattedTextField txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount = new JFormattedTextField(fmtDone);
 		txtAmount.setEditable(false);
 		txtAmount.setValue(ingredient.getAmount().getValue());
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
@@ -65,7 +74,7 @@ public class IngredientPreparationPanel extends JPanel {
 		add(txtAmount, gbc_txtAmount);
 		txtAmount.setColumns(4);
 		
-		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
+		txtUnit = new JTextField(ingredient.getAmount().unitName());
 		txtUnit.setEditable(false);
 		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
@@ -77,7 +86,7 @@ public class IngredientPreparationPanel extends JPanel {
 		add(txtUnit, gbc_txtUnit);
 		txtUnit.setColumns(6);
 		
-		JTextField txtPreparation = new JTextField(ingredient.getPreparation());
+		txtPreparation = new JTextField(ingredient.getPreparation());
 		txtPreparation.setEditable(false);
 		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();

+ 11 - 5
src/main/lombok/org/leumasjaffe/recipe/view/PreparationPanel.java

@@ -5,6 +5,10 @@ import javax.swing.JPanel;
 import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.recipe.model.Preparation;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import java.awt.GridBagLayout;
 
 import java.awt.GridBagConstraints;
@@ -15,8 +19,10 @@ import java.awt.Component;
 import javax.swing.Box;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class PreparationPanel extends JPanel {
-	private JLabel lblIndex;
+	@Getter(AccessLevel.PACKAGE) JLabel lblDuration;
+	@Getter(AccessLevel.PACKAGE) JPanel panelIngredients;
 		
 	public PreparationPanel(Preparation step) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -40,7 +46,7 @@ public class PreparationPanel extends JPanel {
 		gbl_panelLeft.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
 		panelLeft.setLayout(gbl_panelLeft);
 		
-		lblIndex = new JLabel("Prep");
+		JLabel lblIndex = new JLabel("Prep");
 		GridBagConstraints gbc_lblIndex = new GridBagConstraints();
 		gbc_lblIndex.fill = GridBagConstraints.HORIZONTAL;
 		gbc_lblIndex.insets = new Insets(0, 0, 5, 5);
@@ -55,14 +61,14 @@ public class PreparationPanel extends JPanel {
 		gbc_horizontalGlue.gridy = 0;
 		panelLeft.add(horizontalGlue, gbc_horizontalGlue);
 		
-		JLabel lblDuration = new JLabel("Requires: " + step.getDuration().toString());
+		lblDuration = new JLabel("Requires: " + step.getDuration().toString());
 		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
 		gbc_lblDuration.insets = new Insets(0, 0, 5, 0);
 		gbc_lblDuration.gridx = 2;
 		gbc_lblDuration.gridy = 0;
 		panelLeft.add(lblDuration, gbc_lblDuration);
 		
-		JPanel panelIngredients = new JPanel();
+		panelIngredients = new JPanel();
 		panelIngredients.setLayout(new VerticalLayout(5));
 		GridBagConstraints gbc_panelIngredients = new GridBagConstraints();
 		gbc_panelIngredients.gridwidth = 3;
@@ -71,7 +77,7 @@ public class PreparationPanel extends JPanel {
 		gbc_panelIngredients.gridx = 0;
 		gbc_panelIngredients.gridy = 1;
 		panelLeft.add(panelIngredients, gbc_panelIngredients);
-		step.getIngredients().stream().map(IngredientPreparationPanel::new).forEach(panelIngredients::add);
+		step.getIngredientsAsStream().map(IngredientPreparationPanel::new).forEach(panelIngredients::add);
 	}
 
 }

+ 14 - 2
src/main/lombok/org/leumasjaffe/recipe/view/ProductPanel.java

@@ -4,17 +4,23 @@ import javax.swing.JPanel;
 import javax.swing.JScrollPane;
 
 import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Card;
 import org.leumasjaffe.recipe.model.Product;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
 import org.jdesktop.swingx.VerticalLayout;
 
 import javax.swing.JButton;
 import javax.swing.JSeparator;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class ProductPanel extends JScrollPane {
-	private JPanel panelViewPort;
-	private final ForwardingObservableListener<Product> listener = new ForwardingObservableListener<>();
+	JPanel panelViewPort;
+	ForwardingObservableListener<Product> listener = new ForwardingObservableListener<>();
 
 	public ProductPanel(Product product) {
 		JPanel panelColumnHeader = new JPanel();
@@ -34,4 +40,10 @@ public class ProductPanel extends JScrollPane {
 		
 		listener.setObserved(product, product.getCards());
 	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
 }

+ 13 - 6
src/main/lombok/org/leumasjaffe/recipe/view/ProductSummaryPanel.java

@@ -7,6 +7,10 @@ import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Product;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import java.awt.GridBagLayout;
 import java.awt.Insets;
 
@@ -14,8 +18,11 @@ import javax.swing.JLabel;
 import java.awt.GridBagConstraints;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class ProductSummaryPanel extends JPanel {
 	ObservableListener<JPanel, Product> listener;
+	@Getter(AccessLevel.PACKAGE) JLabel lblProductName;
+	@Getter(AccessLevel.PACKAGE) JPanel panelIngredients;
 	
 	public ProductSummaryPanel(final Product product) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -25,26 +32,26 @@ public class ProductSummaryPanel extends JPanel {
 		gridBagLayout.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
-		JLabel lblProductName = new JLabel(product.getName());
+		lblProductName = new JLabel(product.getName());
 		GridBagConstraints gbc_lblProductName = new GridBagConstraints();
 		gbc_lblProductName.insets = new Insets(0, 0, 5, 5);
 		gbc_lblProductName.gridx = 0;
 		gbc_lblProductName.gridy = 0;
 		add(lblProductName, gbc_lblProductName);
 				
-		JPanel panel = new JPanel();
-		panel.setLayout(new VerticalLayout());
+		panelIngredients = new JPanel();
+		panelIngredients.setLayout(new VerticalLayout());
 		GridBagConstraints gbc_panel = new GridBagConstraints();
 		gbc_panel.gridwidth = 2;
 		gbc_panel.insets = new Insets(0, 0, 0, 5);
 		gbc_panel.fill = GridBagConstraints.BOTH;
 		gbc_panel.gridx = 0;
 		gbc_panel.gridy = 1;
-		add(panel, gbc_panel);
+		add(panelIngredients, gbc_panel);
 		
-		listener = new ObservableListener<>(panel, (c, t) -> {
+		listener = new ObservableListener<>(panelIngredients, (c, t) -> {
 			c.removeAll();
-			product.getIngredientsAsStream().map(SummaryIngredientPanel::new).forEach(c::add);
+			product.getIngredients().stream().map(SummaryIngredientPanel::new).forEach(c::add);
 		});
 		listener.setObserved(product);
 	}

+ 2 - 2
src/main/lombok/org/leumasjaffe/recipe/view/RecipeFrame.java

@@ -21,12 +21,12 @@ import java.awt.event.InputEvent;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class RecipeFrame extends JFrame implements FileController.Model {
+public class RecipeFrame extends JFrame implements FileController.ViewModel {
 	SummaryPanel summaryPanel;
 	JTabbedPane tabbedPane;
 	
 	public RecipeFrame() {
-		FileController<RecipeFrame> fileController = new FileController<>(this);
+		FileController fileController = new FileController(this);
 
 		final int MASK = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
 		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

+ 10 - 3
src/main/lombok/org/leumasjaffe/recipe/view/RestPanel.java

@@ -3,13 +3,21 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JPanel;
 
 import org.leumasjaffe.recipe.model.Rest;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import java.awt.GridBagLayout;
 import javax.swing.JLabel;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class RestPanel extends JPanel {
+	@Getter(AccessLevel.PACKAGE) JLabel lblLocation;
+	@Getter(AccessLevel.PACKAGE) JLabel lblDuration;
 
 	public RestPanel(Rest rest) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -26,19 +34,18 @@ public class RestPanel extends JPanel {
 		gbc_lblRest.gridy = 0;
 		add(lblRest, gbc_lblRest);
 		
-		JLabel lblLocation = new JLabel(rest.getWhere().getHumanReadable());
+		lblLocation = new JLabel(rest.getWhere().getHumanReadable());
 		GridBagConstraints gbc_lblLocation = new GridBagConstraints();
 		gbc_lblLocation.insets = new Insets(0, 0, 0, 5);
 		gbc_lblLocation.gridx = 1;
 		gbc_lblLocation.gridy = 0;
 		add(lblLocation, gbc_lblLocation);
 		
-		JLabel lblDuration = new JLabel(rest.getDuration().toString());
+		lblDuration = new JLabel(rest.getDuration().toString());
 		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
 		gbc_lblDuration.gridx = 2;
 		gbc_lblDuration.gridy = 0;
 		add(lblDuration, gbc_lblDuration);
-		// TODO Auto-generated constructor stub
 	}
 
 }

+ 12 - 5
src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java

@@ -7,6 +7,10 @@ import org.leumasjaffe.observer.ForwardingObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Step;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import java.awt.GridBagLayout;
 
 import java.awt.GridBagConstraints;
@@ -19,10 +23,13 @@ import javax.swing.Box;
 import java.awt.Dimension;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
-	private JLabel lblIndex;
-	private JTextPane txtpnInstructions;
-	private final ForwardingObservableListener<Step> listener = new ForwardingObservableListener<>();
+	@Getter(AccessLevel.PACKAGE) JLabel lblIndex;
+	@Getter(AccessLevel.PACKAGE) JLabel lblDuration;
+	@Getter(AccessLevel.PACKAGE) JTextPane txtpnInstructions;
+	@Getter(AccessLevel.PACKAGE) JPanel panelIngredients;
+	ForwardingObservableListener<Step> listener = new ForwardingObservableListener<>();
 		
 	public StepPanel(int zeroIndex, Step step) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -61,14 +68,14 @@ public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenabl
 		gbc_horizontalGlue.gridy = 0;
 		panelLeft.add(horizontalGlue, gbc_horizontalGlue);
 		
-		JLabel lblDuration = new JLabel("Requires: " + step.getDuration().toString());
+		lblDuration = new JLabel("Requires: " + step.getDuration().toString());
 		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
 		gbc_lblDuration.insets = new Insets(0, 0, 5, 0);
 		gbc_lblDuration.gridx = 2;
 		gbc_lblDuration.gridy = 0;
 		panelLeft.add(lblDuration, gbc_lblDuration);
 		
-		AutoGrowPanel panelIngredients = new AutoGrowPanel(IngredientPanel::new, step.getIngredients());
+		panelIngredients = new AutoGrowPanel(IngredientPanel::new, step.getIngredients());
 		GridBagConstraints gbc_panelIngredients = new GridBagConstraints();
 		gbc_panelIngredients.gridwidth = 3;
 		gbc_panelIngredients.insets = new Insets(0, 0, 0, 5);

+ 12 - 4
src/main/lombok/org/leumasjaffe/recipe/view/SummaryIngredientPanel.java

@@ -14,13 +14,21 @@ import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
 
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
 import javax.swing.JFormattedTextField;
 import java.awt.Font;
 import javax.swing.JLabel;
 
 @SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class SummaryIngredientPanel extends JPanel {
-	private ObservableListener<SummaryIngredientPanel, Ingredient> listener;
+	ObservableListener<SummaryIngredientPanel, Ingredient> listener;
+	@Getter(AccessLevel.PACKAGE) JTextField txtName;
+	@Getter(AccessLevel.PACKAGE) JFormattedTextField txtAmount;
+	@Getter(AccessLevel.PACKAGE) JTextField txtUnit;
 	
 	public SummaryIngredientPanel(final Ingredient ingredient) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -38,7 +46,7 @@ public class SummaryIngredientPanel extends JPanel {
 		gbc_label.gridy = 0;
 		add(label, gbc_label);
 		
-		JTextField txtName = new JTextField(ingredient.getName());
+		txtName = new JTextField(ingredient.getName());
 		txtName.setEditable(false);
 		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtName = new GridBagConstraints();
@@ -52,7 +60,7 @@ public class SummaryIngredientPanel extends JPanel {
 		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
 		fmtDone.setMinimum(0.0);
 		fmtDone.setCommitsOnValidEdit(true);
-		JFormattedTextField txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount = new JFormattedTextField(fmtDone);
 		txtAmount.setEditable(false);
 		txtAmount.setValue(ingredient.getAmount().getValue());
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
@@ -64,7 +72,7 @@ public class SummaryIngredientPanel extends JPanel {
 		add(txtAmount, gbc_txtAmount);
 		txtAmount.setColumns(4);
 		
-		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
+		txtUnit = new JTextField(ingredient.getAmount().unitName());
 		txtUnit.setEditable(false);
 		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtUnit = new GridBagConstraints();

+ 15 - 0
src/test/java/org/leumasjaffe/mock/MockObserverListener.java

@@ -0,0 +1,15 @@
+package org.leumasjaffe.mock;
+
+import org.leumasjaffe.observer.Observable;
+import org.leumasjaffe.observer.ObservableListener;
+
+public abstract class MockObserverListener {
+	private final ObservableListener<Void, Observable> impl =
+			new ObservableListener<>(null, (v, i) -> updateWasSignalled());
+	
+	public final void setObserved(Observable object) {
+		impl.setObserved(object);
+	}
+
+	public abstract void updateWasSignalled();
+}

+ 11 - 0
src/test/java/org/leumasjaffe/recipe/TestSuite.java

@@ -0,0 +1,11 @@
+package org.leumasjaffe.recipe;
+
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.platform.suite.api.SelectPackages;
+import org.junit.runner.RunWith;
+
+@RunWith(JUnitPlatform.class)
+@SelectPackages("org.leumasjaffe.recipe")
+public class TestSuite {
+
+}

+ 123 - 0
src/test/java/org/leumasjaffe/recipe/controller/FileControllerTest.java

@@ -0,0 +1,123 @@
+package org.leumasjaffe.recipe.controller;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.recipe.model.Recipe;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class FileControllerTest {	
+	@Mock SaveLoadHandle handle;
+	@Mock FileController.ViewModel viewmodel;
+	@InjectMocks FileController controller;
+	
+	@Test
+	void testCanCreateNewModel() {
+		controller.create();
+		verify(viewmodel).setModel(any(Recipe.class));
+	}
+
+	@Test
+	void testRepeatedCreatesProvideNewObjects() {
+		InOrder inOrder = inOrder(viewmodel);
+		final ArgumentCaptor<Recipe> first = ArgumentCaptor.forClass(Recipe.class);
+		final ArgumentCaptor<Recipe> second = ArgumentCaptor.forClass(Recipe.class);
+
+		controller.create();
+		inOrder.verify(viewmodel).setModel(first.capture());
+
+		controller.create();
+		inOrder.verify(viewmodel).setModel(second.capture());
+		
+		assertNotSame(first.getValue(), second.getValue());
+	}
+
+	@Test
+	void testOpensContentIntoRecipe() {
+		final String data = "{ \"title\": \"Example\" }";
+		doReturn(Optional.of(new StringReader(data))).when(handle).reader();
+
+		controller.open();
+		
+		verify(viewmodel).setModel(argThat(r -> r.getTitle().equals("Example")));
+	}
+
+	@Test
+	void testEmitsErrorWithMalformedData() {
+		final String data = "{ \"name\": \"Example\" }";
+		doReturn(Optional.of(new StringReader(data))).when(handle).reader();
+		doNothing().when(handle).error(any());
+
+		controller.open();
+		
+		verify(handle).error(any());
+	}
+	
+	@Test
+	void testCanWriteRecipeToStream() {
+		StringWriter writer = new StringWriter();
+		doReturn(Optional.of(writer)).when(handle).writer();
+		
+		controller.create();
+		controller.save();
+		assertNotEquals("", writer.toString());
+	}
+	
+	@Test
+	void testSaveAsClearsHandle() {
+		StringWriter writer = new StringWriter();
+		doReturn(Optional.of(writer)).when(handle).writer();
+		
+		controller.create();
+		controller.saveAs();
+		verify(handle).clear();
+		assertNotEquals("", writer.toString());
+	}
+	
+	@Test
+	void testWriteErrorCallsHandle() {
+		Writer writer = mock(Writer.class, inv -> { throw new IOException(); });
+		
+		doReturn(Optional.of(writer)).when(handle).writer();
+		doNothing().when(handle).error(any());
+		
+		controller.create();
+		controller.save();
+		verify(handle).error(any());
+	}
+	
+	@Test
+	void testDoesNothingIfHandleReturnsMissingReader() {
+		doReturn(Optional.empty()).when(handle).reader();
+		
+		controller.open();
+		verify(viewmodel, never()).setModel(any());
+		verify(handle, never()).error(any());
+	}
+	
+	@Test
+	void testDoesNothingIfHandleReturnsMissingWriter() {
+		doReturn(Optional.empty()).when(handle).writer();
+		
+		controller.create();
+		controller.save();
+		verify(handle, never()).error(any());
+	}
+
+}

+ 96 - 0
src/test/java/org/leumasjaffe/recipe/model/AmountTest.java

@@ -0,0 +1,96 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class AmountTest {
+	
+	private static Stream<Arguments> unitOfCount() {
+		return Stream.of(Arguments.of(new Amount(Amount.Unit.COUNT, 1, null, null)));
+	}
+	
+	private static Stream<Arguments> unitOfVolume() {
+		return Stream.of(Amount.Volume.values())
+				.map(vol -> new Amount(Amount.Unit.VOLUME, 1, vol, null))
+				.map(Arguments::of);
+	}
+	
+	private static Stream<Arguments> unitOfWeight() {
+		return Stream.of(Amount.Weight.values())
+				.map(wgt -> new Amount(Amount.Unit.WEIGHT, 1, null, wgt))
+				.map(Arguments::of);
+	}
+	
+	private static Stream<Arguments> crossVolume() {
+		return unitOfVolume().flatMap(arg0 -> unitOfVolume()
+				.map(arg1 -> Arguments.of(arg0.get()[0], arg1.get()[0])));
+	}
+	
+	private static Stream<Arguments> crossWeight() {
+		return unitOfWeight().flatMap(arg0 -> unitOfWeight()
+				.map(arg1 -> Arguments.of(arg0.get()[0], arg1.get()[0])));
+	}
+	
+	private static Stream<Arguments> unitStrings() {
+		return Stream.of("1.0 g", "1.0 oz", "1.0 lb", "1.0 kg", 
+			"1.0 ml", "1.0 tsp", "1.0 Tbsp", "1.0 cup", 
+			"1.0 pinch", "1.0 dash", "1.0").map(Arguments::of);
+	}
+	
+	@ParameterizedTest
+	@MethodSource("unitStrings")
+	void testCanConstructFromString(final String text) {
+		assertDoesNotThrow(() -> new Amount(text));
+	}
+	
+	@ParameterizedTest
+	@MethodSource("unitStrings")
+	void testToStringMatchesFromString(final String text) {
+		assertEquals(text, new Amount(text).toString());
+	}
+	
+	@Test
+	void testConstructFromCtLosesOnToString() {
+		assertEquals("1.0", new Amount("1 ct").toString());
+	}
+	
+	@Test
+	void testCountUnitNameReturnCt() {
+		assertEquals("ct", new Amount("1 ct").unitName());
+	}
+
+	@ParameterizedTest
+	@MethodSource({"unitOfCount", "unitOfVolume", "unitOfWeight"})
+	void testCanAddTogetherAmounts(final Amount amt) {
+		assertDoesNotThrow(() -> amt.plus(amt));
+	}
+
+	@ParameterizedTest
+	@MethodSource({"unitOfCount", "unitOfVolume", "unitOfWeight"})
+	void testAddingEqualUnitsIsStraightAddition(final Amount amt) {
+		assertEquals(amt.plus(amt).getValue(), 2 * amt.getValue());
+	}
+
+	@ParameterizedTest
+	@MethodSource({"crossWeight", "crossVolume"})
+	void testAddingDifferentScalesWillMatchLeftScale(final Amount lhs, final Amount rhs) {
+		assertEquals(lhs.plus(rhs).getWgt(), lhs.getWgt());
+		assertEquals(lhs.plus(rhs).getVol(), lhs.getVol());
+	}
+	
+	@Test
+	void testThrowsWhenTryingToCrossUnits() {
+		final Amount ct = new Amount(Amount.Unit.COUNT, 1, null, null);
+		final Amount vol = new Amount(Amount.Unit.VOLUME, 1, Amount.Volume.ml, null);
+		final Amount wgt = new Amount(Amount.Unit.WEIGHT, 1, null, Amount.Weight.g);
+		assertThrows(IllegalArgumentException.class, () -> ct.plus(vol));
+		assertThrows(IllegalArgumentException.class, () -> ct.plus(wgt));
+		assertThrows(IllegalArgumentException.class, () -> vol.plus(wgt));
+	}
+}

+ 94 - 0
src/test/java/org/leumasjaffe/recipe/model/CardTest.java

@@ -0,0 +1,94 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.hamcrest.MatcherAssert.*;
+import static org.hamcrest.collection.IsCollectionWithSize.*;
+import static org.hamcrest.core.IsCollectionContaining.*;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+class CardTest {
+	private static final Amount _1g = new Amount("1 g");
+	private static final Duration dur = new Duration(Duration.Display.SECONDS, false, 10, 20);
+
+	@Test
+	void cannotAddNullPreparation() {
+		final Card card = new Card();
+		assertThrows(NullPointerException.class, () -> card.setPreparation(null));
+	}
+	
+	@Test
+	void testDurationIsZeroByDefault() {
+		final Card card = new Card();
+		assertEquals(Duration.ZERO, card.getDuration());
+	}
+	
+	@Test
+	void testSumsTogetherStepDurations() {
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setDuration(dur);
+		card.setCooking(Arrays.asList(step, step));
+		assertEquals(new Duration(Duration.Display.SECONDS, false, 20, 40),
+				card.getDuration());
+	}
+	
+	@Test
+	void testDoesNotAddPrepDurationIfPresent() {
+		final Card card = new Card();
+		final Preparation prep = new Preparation();
+		prep.setDuration(dur);
+		card.setPreparation(prep);
+		assertEquals(Duration.ZERO, card.getDuration());
+	}
+	
+	@Test
+	void testDoesNotAddRestDurationIfPresent() {
+		final Card card = new Card();
+		final Rest rest = new Rest();
+		rest.setWhere(Rest.Where.REFRIGERATOR);
+		rest.setDuration(dur);
+		card.setRest(Optional.of(rest));
+		assertEquals(Duration.ZERO, card.getDuration());
+	}
+
+	@Test
+	void testMergesLikeIngredients() {
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(new Ingredient("TEST", "", _1g)));
+		card.setCooking(Arrays.asList(step, step));
+		assertThat(card.getIngredients(), hasSize(1));
+		assertThat(card.getIngredients(),
+				hasItem(new Ingredient("TEST", "", new Amount("2 g"))));
+	}
+
+	@Test
+	void testDoesNotMergeIngredientsWithDifferentPrep() {
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(
+				new Ingredient("TEST", "A", _1g),
+				new Ingredient("TEST", "B", _1g)));
+		card.setCooking(Arrays.asList(step));
+		assertThat(card.getIngredients(), hasSize(2));
+	}
+
+	@Test
+	void testPrepIngredientsAreCardIngredientsWithPrep() {
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(
+				new Ingredient("A", "", _1g),
+				new Ingredient("B", "TEST", _1g)));
+		card.setCooking(Arrays.asList(step));
+		card.setPreparation(new Preparation());
+		assertThat(card.getIngredients(), hasSize(2));
+		final Preparation prep = card.getPreparation().get();
+		assertThat(prep.getIngredients(), hasSize(1));
+		assertThat(prep.getIngredients(), hasItem(new Ingredient("B", "TEST", _1g)));
+	}
+}

+ 72 - 0
src/test/java/org/leumasjaffe/recipe/model/DurationTest.java

@@ -0,0 +1,72 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import static org.hamcrest.MatcherAssert.*;
+import static org.hamcrest.core.IsNot.*;
+import static org.hamcrest.core.StringContains.*;
+import static org.hamcrest.core.StringStartsWith.*;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+class DurationTest {
+
+	@Test
+	void testPlusConvertsToLowestUnit() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 10, 20);
+		final Duration inMin = new Duration(Duration.Display.MINUTES, false, 60, 120);
+		
+		assertEquals(Duration.Display.SECONDS, inSec.plus(inMin).getDisplayAs());
+		assertEquals(Duration.Display.SECONDS, inMin.plus(inSec).getDisplayAs());
+	}
+
+	@Test
+	void testPlusWillCarryOverApproximation() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, true, 10, 20);
+		final Duration inMin = new Duration(Duration.Display.MINUTES, false, 60, 120);
+		
+		assertTrue(inSec.plus(inMin).isApproximate());
+		assertTrue(inMin.plus(inSec).isApproximate());
+	}
+	
+	@Test
+	void testToStringApproxAddsTilde() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, true, 0, 0);
+		assertThat(inSec.toString(), startsWith("~"));
+	}
+	
+	@Test
+	void testToStringNonApproxDoesNotHaveTilde() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 0, 0);
+		assertThat(inSec.toString(), not(containsString("~")));
+	}
+	
+	@Test
+	void testNonZeroMinProducesRange() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 10, 0);
+		assertThat(inSec.toString(), containsString("-"));
+	}
+	
+	@Test
+	void testZeroMinProducesSingleNumber() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 0, 0);
+		assertThat(inSec.toString(), not(containsString("-")));
+	}
+	
+	@ParameterizedTest
+	@EnumSource(Duration.Display.class)
+	void testUnitStringIsIncludedInOutput(final Duration.Display as) {
+		final Duration dur = new Duration(as, false, 0, 0);
+		assertThat(dur.toString(), containsString(as.abbreviation));
+	}
+	
+	@Test
+	void testUnitControlsOutputScale() {
+		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 10, 20);
+		final Duration inMin = new Duration(Duration.Display.MINUTES, false, 10, 20);
+		assertEquals("10 - 20 s", inSec.toString());
+		assertEquals("0 - 0 min", inMin.toString());
+	}
+}

+ 36 - 0
src/test/java/org/leumasjaffe/recipe/model/IngredientTest.java

@@ -0,0 +1,36 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class IngredientTest {
+	private final Amount _1g = new Amount("1 g");
+
+	@Test
+	void testCanAddTogetherIngredients() {
+		final Ingredient in = new Ingredient("TEST", "", _1g);
+		assertDoesNotThrow(() -> in.plus(in));
+	}
+
+	@Test
+	void testThrowsIfAddingDiffIngredients() {
+		final Ingredient lhs = new Ingredient("A", "", _1g);
+		final Ingredient rhs = new Ingredient("B", "", _1g);
+		assertThrows(IllegalArgumentException.class, () -> lhs.plus(rhs));
+	}
+
+	@Test
+	void testThrowsIfAddingDiffPreparations() {
+		final Ingredient lhs = new Ingredient("TEST", "A", _1g);
+		final Ingredient rhs = new Ingredient("TEST", "B", _1g);
+		assertThrows(IllegalArgumentException.class, () -> lhs.plus(rhs));
+	}
+
+	@Test
+	void testHasPreparationIfNotNullOrEmptyString() {
+		assertFalse(new Ingredient("TEST", null, _1g).hasPreparation());
+		assertFalse(new Ingredient("TEST", "", _1g).hasPreparation());
+		assertTrue(new Ingredient("TEST", "A", _1g).hasPreparation());
+	}
+}

+ 28 - 0
src/test/java/org/leumasjaffe/recipe/model/ProductTest.java

@@ -0,0 +1,28 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.*;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+class ProductTest {
+	private static final Amount _1g = new Amount("1 g");
+
+	@Test
+	void testMergesIngredientsWithDifferentPrep() {
+		final Product prod = new Product();
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(
+				new Ingredient("TEST", "A", _1g),
+				new Ingredient("TEST", "B", _1g)));
+		card.setCooking(Arrays.asList(step));
+		prod.setCards(Arrays.asList(card));
+		assertThat(prod.getIngredients(), hasSize(1));
+		assertThat(prod.getIngredients(),
+				hasItem(new Ingredient("TEST", "", new Amount("2 g"))));
+	}
+}

+ 31 - 0
src/test/java/org/leumasjaffe/recipe/model/RecipeTest.java

@@ -0,0 +1,31 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.*;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+class RecipeTest {
+	private static final Amount _1g = new Amount("1 g");
+
+	@Test
+	void testMergesIngredientsWithDifferentPrep() {
+		final Recipe recipe = new Recipe();
+		final Product prod = new Product();
+		final Card card = new Card();
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(
+				new Ingredient("TEST", "A", _1g),
+				new Ingredient("TEST", "B", _1g)));
+		card.setCooking(Arrays.asList(step));
+		prod.setCards(Arrays.asList(card));
+		recipe.setProducts(Arrays.asList(prod, prod));
+		assertThat(recipe.getIngredients(), hasSize(1));
+		assertThat(recipe.getIngredients(),
+				hasItem(new Ingredient("TEST", "", new Amount("4 g"))));
+	}
+
+}

+ 22 - 0
src/test/java/org/leumasjaffe/recipe/model/StepTest.java

@@ -0,0 +1,22 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.hamcrest.MatcherAssert.*;
+import static org.hamcrest.collection.IsCollectionWithSize.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+class StepTest {
+	private static final Amount _1g = new Amount("1 g");
+
+	@Test
+	void testDoesNotMergeLikeIngredients() {
+		final Step step = new Step();
+		step.setIngredients(Arrays.asList(
+				new Ingredient("TEST", "", _1g),
+				new Ingredient("TEST", "", _1g)));
+		assertThat(step.getIngredients(), hasSize(2));
+	}
+
+}

+ 133 - 0
src/test/java/org/leumasjaffe/recipe/view/AutoGrowPanelTest.java

@@ -0,0 +1,133 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
+import static org.mockito.Mockito.*;
+
+import javax.swing.JTextField;
+import javax.swing.event.DocumentListener;
+import javax.swing.text.BadLocationException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.recipe.view.AutoGrowPanel.DocumentListenable;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class AutoGrowPanelTest extends SwingTestCase {
+	@SuppressWarnings("serial")
+	private static class MockComponent extends JTextField implements DocumentListenable {
+		public MockComponent() {
+		}
+
+		public MockComponent(String s) {
+			super(s);
+		}
+
+		@Override
+		public void addDocumentListener(DocumentListener dl) {
+			super.getDocument().addDocumentListener(dl);
+		}
+
+		@Override
+		public void removeDocumentListener(DocumentListener dl) {
+			super.getDocument().removeDocumentListener(dl);
+		}
+	}
+	
+	@Test
+	void testAlwaysHasAtLeastOneComponent() {
+		final MockComponent wrapper = spy(new MockComponent());
+		
+		AutoGrowPanel panel = new AutoGrowPanel(() -> wrapper);
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+	}
+
+	@Test
+	void testInputParamIsZeroIndex() {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent()),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 1);
+
+		verify(wrapper[0]).addDocumentListener(any());
+		verify(wrapper[1], never()).addDocumentListener(any());
+		assertThat(panel.getComponents(), arrayWithSize(1));
+	}
+
+	@Test
+	void testCreatesGivenNumberOfChildren() {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent()),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 2);
+
+		assertThat(panel.getComponents(), arrayWithSize(2));
+	}
+
+	@Test
+	void testEnteringContentTriggersNewRow() {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent()),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 1);
+		getTestFrame().add(panel);
+		wrapper[0].setText("A");
+		
+		assertThat(panel.getComponents(), arrayWithSize(2));
+	}
+
+	@Test
+	void testEnteringEmptyContentDoesNotTrigger() {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent()),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 1);
+		getTestFrame().add(panel);
+		wrapper[0].setText("");
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+		verify(wrapper[0], never()).removeDocumentListener(any());
+	}
+
+	@Test
+	void testEmptyingContentClearsPanel() {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent("A")),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 2);
+		getTestFrame().add(panel);
+		wrapper[0].setText("");
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+	}
+
+
+	@Test
+	void testChangingTextDoesNotDeleteRow() throws BadLocationException {
+		final MockComponent[] wrapper = {
+				spy(new MockComponent("A")),
+				spy(new MockComponent())
+		};
+		
+		AutoGrowPanel panel = new AutoGrowPanel((i) -> wrapper[i], 2);
+		getTestFrame().add(panel);
+		wrapper[0].getDocument().insertString(0, "B", null);
+		
+		assertThat(panel.getComponents(), arrayWithSize(2));
+	}
+}

+ 45 - 0
src/test/java/org/leumasjaffe/recipe/view/CardPanelTest.java

@@ -0,0 +1,45 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.mockito.Mockito.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.mock.MockObserverListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Card;
+import org.leumasjaffe.recipe.model.Step;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class CardPanelTest extends SwingTestCase {
+	
+	@Spy MockObserverListener listener;
+	final Step stub = new Step();
+	@Mock Card stuff;
+	CardPanel panel;
+
+	@BeforeEach
+	void setUp() {
+		doReturn(Arrays.asList(stub)).when(stuff).getCooking();
+		panel = new CardPanel(stuff);
+		
+		listener.setObserved(stuff);
+		// setObserved() calls update
+		clearInvocations(listener);
+	}
+
+	@Test
+	void testPropogatesSignalFromChildren() {
+		ObserverDispatch.notifySubscribers(stub);
+		verify(listener).updateWasSignalled();
+	}
+
+}

+ 75 - 0
src/test/java/org/leumasjaffe/recipe/view/IngredientPanelTest.java

@@ -0,0 +1,75 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.mock.MockObserverListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Ingredient;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class IngredientPanelTest extends SwingTestCase {
+	@Spy MockObserverListener listener;
+	Ingredient stuff;
+	IngredientPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		stuff = new Ingredient("Onions", "Sliced", new Amount("100 g"));
+		panel = new IngredientPanel(stuff);
+		listener.setObserved(stuff);
+		// setObserved invokes our callback.
+		clearInvocations(listener);
+	}
+
+	@Test
+	void testFilledOutWithContent() {
+		assertEquals("Onions", panel.getTxtName().getText());
+		assertEquals("Sliced", panel.getTxtPreparation().getText());
+		assertEquals("100", panel.getTxtAmount().getText());
+		assertEquals("g", panel.getTxtUnit().getText());
+	}
+	
+	@Test
+	void testContentIsEditable() {
+		assertTrue(panel.getTxtName().isEditable());
+		assertTrue(panel.getTxtPreparation().isEditable());
+		assertTrue(panel.getTxtAmount().isEditable());
+		assertTrue(panel.getTxtUnit().isEditable());
+	}
+
+	@Test
+	void testIsSubscribedToUpdates() {
+		stuff.setName("Bacon");
+		assertEquals("Onions", panel.getTxtName().getText());
+		ObserverDispatch.notifySubscribers(stuff);
+		assertEquals("Bacon", panel.getTxtName().getText());
+		// TODO: I need to add hook-ups for the rest of the fields, too
+	}
+
+	// TODO: I need to add hook-ups for the rest of the fields, too
+	@Test
+	void testViewUpdateAltersModel() {
+		panel.getTxtName().setText("Bacon");
+		waitForSwing();
+		assertEquals("Bacon", stuff.getName());
+	}
+
+	// TODO: I need to add hook-ups for the rest of the fields, too
+	@Test
+	void testViewUpdateSendsNotify() {
+		panel.getTxtName().setText("Bacon");
+		waitForSwing();
+		verify(listener).updateWasSignalled();
+	}
+
+}

+ 47 - 0
src/test/java/org/leumasjaffe/recipe/view/IngredientPreparationPanelTest.java

@@ -0,0 +1,47 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Ingredient;
+
+class IngredientPreparationPanelTest extends SwingTestCase {
+
+	Ingredient stuff;
+	IngredientPreparationPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		stuff = new Ingredient("Onions", "Sliced", new Amount("100 g"));
+		panel = new IngredientPreparationPanel(stuff);
+	}
+
+	@Test
+	void testFilledOutWithContent() {
+		assertEquals("Onions", panel.getTxtName().getText());
+		assertEquals("Sliced", panel.getTxtPreparation().getText());
+		assertEquals("100", panel.getTxtAmount().getText());
+		assertEquals("g", panel.getTxtUnit().getText());
+	}
+	
+	@Test
+	void testCannotEditContent() {
+		assertFalse(panel.getTxtName().isEditable());
+		assertFalse(panel.getTxtPreparation().isEditable());
+		assertFalse(panel.getTxtAmount().isEditable());
+		assertFalse(panel.getTxtUnit().isEditable());
+	}
+
+	@Test
+	void testIsSubscribedToUpdates() {
+		stuff.setName("Bacon");
+		assertEquals("Onions", panel.getTxtName().getText());
+		ObserverDispatch.notifySubscribers(stuff);
+		assertEquals("Bacon", panel.getTxtName().getText());
+		// TODO: I need to add hook-ups for the rest of the fields, too
+	}
+
+}

+ 52 - 0
src/test/java/org/leumasjaffe/recipe/view/PreparationPanelTest.java

@@ -0,0 +1,52 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.mockito.Mockito.*;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Duration;
+import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.model.Preparation;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class PreparationPanelTest extends SwingTestCase {
+	
+	Duration dur;
+	@Mock Preparation stuff;
+	PreparationPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		doReturn(dur).when(stuff).getDuration();
+		doReturn(Stream.of(
+				new Ingredient("Butter", "", new Amount("10 g")),
+				new Ingredient("Salt", "", new Amount("0.25 tsp"))
+				)).when(stuff).getIngredientsAsStream();
+		
+		panel = new PreparationPanel(stuff);
+	}
+
+	@Test
+	void testHasContent() {
+		assertThat(panel.getLblDuration().getText(),
+				containsString(dur.toString()));
+		assertThat(panel.getPanelIngredients().getComponents(),
+				arrayWithSize(2));
+	}
+
+	// TODO: Hook-ups for editing the preparation time
+	// TODO: Hook-ups for changes to the preparation model
+}

+ 45 - 0
src/test/java/org/leumasjaffe/recipe/view/ProductPanelTest.java

@@ -0,0 +1,45 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.mockito.Mockito.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.mock.MockObserverListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Card;
+import org.leumasjaffe.recipe.model.Product;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class ProductPanelTest extends SwingTestCase {
+	
+	@Spy MockObserverListener listener;
+	final Card stub = new Card();
+	@Mock Product stuff;
+	ProductPanel panel;
+
+	@BeforeEach
+	void setUp() {
+		doReturn(Arrays.asList(stub)).when(stuff).getCards();
+		panel = new ProductPanel(stuff);
+		
+		listener.setObserved(stuff);
+		// setObserved() calls update
+		clearInvocations(listener);
+	}
+
+	@Test
+	void testPropogatesSignalFromChildren() {
+		ObserverDispatch.notifySubscribers(stub);
+		verify(listener).updateWasSignalled();
+	}
+
+}

+ 59 - 0
src/test/java/org/leumasjaffe/recipe/view/ProductSummaryPanelTest.java

@@ -0,0 +1,59 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.model.Product;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class ProductSummaryPanelTest extends SwingTestCase {
+	
+	@Mock Product stuff;
+	ProductSummaryPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		doReturn("Curry").when(stuff).getName();
+		doReturn(Arrays.asList(
+				new Ingredient("Onions", "", new Amount("100 g")),
+				new Ingredient("Garlic", "", new Amount("2 tsp"))))
+		.when(stuff).getIngredients();
+		
+		panel = new ProductSummaryPanel(stuff);
+	}
+
+	@Test
+	void testHasContent() {
+		assertEquals("Curry", panel.getLblProductName().getText());
+		assertThat(panel.getPanelIngredients().getComponents(),
+				arrayWithSize(2));
+	}
+	
+	@Test
+	void testRegeneratesListWhenProductNotify() {
+		doReturn(Arrays.asList(new Ingredient("Onions", "", new Amount("100 g"))))
+			.when(stuff).getIngredients();
+		
+		ObserverDispatch.notifySubscribers(stuff);
+		waitForSwing();
+		
+		assertThat(panel.getPanelIngredients().getComponents(),
+				arrayWithSize(1));
+	}
+
+}

+ 40 - 0
src/test/java/org/leumasjaffe/recipe/view/RestPanelTest.java

@@ -0,0 +1,40 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.recipe.model.Duration;
+import org.leumasjaffe.recipe.model.Rest;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class RestPanelTest extends SwingTestCase {
+	
+	Duration dur;
+	Rest stuff;
+	RestPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		stuff = new Rest(Rest.Where.REFRIGERATOR, dur);
+		panel = new RestPanel(stuff);
+	}
+
+	@Test
+	void testHasContent() {
+		assertThat(panel.getLblDuration().getText(),
+				containsString(dur.toString()));
+		assertEquals(stuff.getWhere().getHumanReadable(),
+				panel.getLblLocation().getText());
+	}
+
+	// TODO: Hook-ups for editing the preparation time
+}

+ 57 - 0
src/test/java/org/leumasjaffe/recipe/view/StepPanelTest.java

@@ -0,0 +1,57 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.mockito.Mockito.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.mock.MockObserverListener;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Duration;
+import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.model.Step;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class StepPanelTest extends SwingTestCase {
+	@Spy MockObserverListener listener;
+	Duration dur;
+	@Mock Step stuff;
+	StepPanel panel;
+
+	@BeforeEach
+	void setUp() {
+		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		doReturn(dur).when(stuff).getDuration();
+		doReturn("These are test instructions").when(stuff).getInstruction();
+		doReturn(Arrays.asList(new Ingredient("Onion", "Sliced", new Amount("100 g"))))
+			.when(stuff).getIngredients();
+		
+		panel = new StepPanel(0, stuff);
+		listener.setObserved(stuff);
+		// setObserved invokes our callback.
+		clearInvocations(listener);
+	}
+
+	@Test
+	void testFilledOutWithContent() {
+		assertEquals("Step 1", panel.getLblIndex().getText());
+		assertThat(panel.getLblDuration().getText(),
+				containsString(dur.toString()));
+		assertEquals("These are test instructions", panel.getTxtpnInstructions().getText());
+		assertThat(panel.getPanelIngredients().getComponents(),
+				arrayWithSize(1));
+	}
+
+}

+ 45 - 0
src/test/java/org/leumasjaffe/recipe/view/SummaryIngredientPanelTest.java

@@ -0,0 +1,45 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Amount;
+import org.leumasjaffe.recipe.model.Ingredient;
+
+class SummaryIngredientPanelTest extends SwingTestCase {
+
+	Ingredient stuff;
+	SummaryIngredientPanel panel;
+	
+	@BeforeEach
+	void setUp() {
+		stuff = new Ingredient("Onions", "Sliced", new Amount("100 g"));
+		panel = new SummaryIngredientPanel(stuff);
+	}
+
+	@Test
+	void testFilledOutWithContent() {
+		assertEquals("Onions", panel.getTxtName().getText());
+		assertEquals("100", panel.getTxtAmount().getText());
+		assertEquals("g", panel.getTxtUnit().getText());
+	}
+	
+	@Test
+	void testCannotEditContent() {
+		assertFalse(panel.getTxtName().isEditable());
+		assertFalse(panel.getTxtAmount().isEditable());
+		assertFalse(panel.getTxtUnit().isEditable());
+	}
+
+	@Test
+	void testIsSubscribedToUpdates() {
+		stuff.setName("Bacon");
+		assertEquals("Onions", panel.getTxtName().getText());
+		ObserverDispatch.notifySubscribers(stuff);
+		assertEquals("Bacon", panel.getTxtName().getText());
+		// TODO: I need to add hook-ups for the rest of the fields, too
+	}
+
+}

+ 40 - 0
src/test/java/org/leumasjaffe/recipe/view/SwingTestCase.java

@@ -0,0 +1,40 @@
+package org.leumasjaffe.recipe.view;
+
+import java.lang.reflect.InvocationTargetException;
+
+import javax.swing.JFrame;
+import javax.swing.SwingUtilities;
+
+import org.junit.jupiter.api.AfterEach;
+
+public class SwingTestCase {
+    private JFrame testFrame;
+
+    @AfterEach
+    protected void tearDown() {
+        if (this.testFrame != null) {
+            this.testFrame.dispose();
+            this.testFrame = null;
+        }
+    }
+
+    public JFrame getTestFrame() {
+        if (this.testFrame == null) {
+            this.testFrame = new JFrame("Test");
+        }
+        return this.testFrame;
+    }
+    
+    public void waitForSwing() {
+        if (!SwingUtilities.isEventDispatchThread(  )) {
+            try {
+                SwingUtilities.invokeAndWait(new Runnable(  ) {
+                    public void run(  ) {
+                    }
+                });
+            } catch (InterruptedException e) {
+            } catch (InvocationTargetException e) {
+            }
+        }
+    }
+}