Explorar el Código

Merge branch 'feat/input-duration'

* feat/input-duration:
  Add coverage to phase-panel test.
  Make some better tests.
  Add duration hookups, minimally alter tests to pass.
  Get better ObservableController from v0.6.0
  Add ObservableController to IngredientPanel for Amounts
  Switch all ingredient panels to use AmountFormatter instead of two separate fields.
  Remove unneeded dependency.
  Change DurationPanel to use the new Formatter. Make CollatedDurationPanel's children not editable.
  Add formatters for Duration and Amount in anticipation of using them.
  Add string ctor to Duration, as well as the test cases to cover it.
Sam Jaffe hace 5 años
padre
commit
7db8dfb3e9
Se han modificado 28 ficheros con 481 adiciones y 240 borrados
  1. 1 6
      pom.xml
  2. 25 1
      src/main/lombok/org/leumasjaffe/recipe/model/Duration.java
  3. 5 2
      src/main/lombok/org/leumasjaffe/recipe/model/Rest.java
  4. 11 6
      src/main/lombok/org/leumasjaffe/recipe/view/CollatedDurationPanel.java
  5. 18 8
      src/main/lombok/org/leumasjaffe/recipe/view/DurationPanel.java
  6. 22 5
      src/main/lombok/org/leumasjaffe/recipe/view/ElementPanel.java
  7. 28 36
      src/main/lombok/org/leumasjaffe/recipe/view/IngredientPanel.java
  8. 21 34
      src/main/lombok/org/leumasjaffe/recipe/view/IngredientPreparationPanel.java
  9. 9 1
      src/main/lombok/org/leumasjaffe/recipe/view/PhasePanel.java
  10. 7 4
      src/main/lombok/org/leumasjaffe/recipe/view/PreparationPanel.java
  11. 0 1
      src/main/lombok/org/leumasjaffe/recipe/view/RecipeCardPanel.java
  12. 28 11
      src/main/lombok/org/leumasjaffe/recipe/view/RestPanel.java
  13. 11 1
      src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java
  14. 34 0
      src/main/lombok/org/leumasjaffe/recipe/view/formatter/AmountFormatter.java
  15. 49 0
      src/main/lombok/org/leumasjaffe/recipe/view/formatter/DurationFormatter.java
  16. 6 26
      src/main/lombok/org/leumasjaffe/recipe/view/summary/IngredientPanel.java
  17. 1 2
      src/main/lombok/org/leumasjaffe/recipe/view/summary/SummaryPanel.java
  18. 28 0
      src/test/java/org/leumasjaffe/recipe/model/DurationTest.java
  19. 1 1
      src/test/java/org/leumasjaffe/recipe/model/PhaseTest.java
  20. 7 24
      src/test/java/org/leumasjaffe/recipe/view/IngredientPanelTest.java
  21. 8 4
      src/test/java/org/leumasjaffe/recipe/view/IngredientPreparationPanelTest.java
  22. 26 1
      src/test/java/org/leumasjaffe/recipe/view/PhasePanelTest.java
  23. 7 20
      src/test/java/org/leumasjaffe/recipe/view/PreparationPanelTest.java
  24. 25 13
      src/test/java/org/leumasjaffe/recipe/view/RestPanelTest.java
  25. 40 0
      src/test/java/org/leumasjaffe/recipe/view/formatter/AmountFormatterTest.java
  26. 56 0
      src/test/java/org/leumasjaffe/recipe/view/formatter/DurationFormatterTest.java
  27. 1 3
      src/test/java/org/leumasjaffe/recipe/view/summary/IngredientPanelTest.java
  28. 6 30
      src/test/resources/example.json

+ 1 - 6
pom.xml

@@ -75,7 +75,7 @@
     <dependency>
       <groupId>org.leumasjaffe</groupId>
       <artifactId>observer</artifactId>
-      <version>0.5.1</version>
+      <version>0.6.0</version>
     </dependency>
     <dependency>
       <groupId>org.leumasjaffe</groupId>
@@ -114,11 +114,6 @@
       <artifactId>hamcrest-all</artifactId>
       <version>1.3</version>
     </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>

+ 25 - 1
src/main/lombok/org/leumasjaffe/recipe/model/Duration.java

@@ -1,5 +1,11 @@
 package org.leumasjaffe.recipe.model;
 
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
@@ -24,6 +30,24 @@ public class Duration {
 	int minSeconds = 0;
 	int maxSeconds = 0;
 	
+	@JsonCreator
+	public Duration(final String serial) {
+		final String[] tokens = serial.split(" ");
+		final boolean isRange = tokens.length == 4;
+		
+		final Optional<Display> rv = Stream.of(Display.values())
+				.filter(v -> v.abbreviation.equals(tokens[isRange ? 3 : 1])).findFirst();
+		
+		displayAs = rv.orElseThrow(() -> new IllegalArgumentException("Unknown time measure: " + tokens[1]));
+
+		if (isRange) {
+			minSeconds = (int) (Float.parseFloat(tokens[0]) * displayAs.inSeconds);
+			maxSeconds = (int) (Float.parseFloat(tokens[2]) * displayAs.inSeconds);
+		} else {
+			maxSeconds = (int) (Float.parseFloat(tokens[0]) * displayAs.inSeconds);
+		}
+	}
+	
 	public Duration plus(Duration rhs) {
 		final Display newDisplayAs = displayAs.ordinal() < rhs.displayAs.ordinal() ?
 				displayAs : rhs.displayAs;
@@ -50,7 +74,7 @@ public class Duration {
 		return this;
 	}
 
-	@Override
+	@Override @JsonValue
 	public String toString() {
 		StringBuilder build = new StringBuilder();
 		if (minSeconds != 0) {

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

@@ -1,14 +1,17 @@
 package org.leumasjaffe.recipe.model;
 
+import org.leumasjaffe.observer.Observable;
+
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.experimental.FieldDefaults;
 
-@Data @AllArgsConstructor @NoArgsConstructor
-public class Rest {
+@Data @AllArgsConstructor @NoArgsConstructor @EqualsAndHashCode(callSuper=false)
+public class Rest extends Observable.Instance {
 	@AllArgsConstructor @Getter @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 	public enum Where {
 		FREEZER("in the freezer"), REFRIGERATOR("in the refrigerator"),

+ 11 - 6
src/main/lombok/org/leumasjaffe/recipe/view/CollatedDurationPanel.java

@@ -6,7 +6,6 @@ import javax.swing.SwingConstants;
 
 import org.jdesktop.swingx.HorizontalLayout;
 import org.leumasjaffe.recipe.model.CollatedDuration;
-import org.leumasjaffe.recipe.model.Duration;
 
 import lombok.AccessLevel;
 import lombok.experimental.FieldDefaults;
@@ -18,20 +17,26 @@ public class CollatedDurationPanel extends JPanel {
 	DurationPanel panelCookingTime;
 	DurationPanel panelTotalTime;
 	
-	public CollatedDurationPanel(final CollatedDuration duration) {
+	public CollatedDurationPanel() {
 		setLayout(new HorizontalLayout(5));
 		
-		panelPrepTime = new DurationPanel("Prep", Duration.ZERO);
+		panelPrepTime = new DurationPanel("Prep");
+		panelPrepTime.setEditable(false);
 		add(panelPrepTime);
 		add(new JSeparator(SwingConstants.VERTICAL));
 		
-		panelCookingTime = new DurationPanel("Cooking", Duration.ZERO);
+		panelCookingTime = new DurationPanel("Cooking");
+		panelCookingTime.setEditable(false);
 		add(panelCookingTime);
 		add(new JSeparator(SwingConstants.VERTICAL));
 		
-		panelTotalTime = new DurationPanel("Total", Duration.ZERO);
+		panelTotalTime = new DurationPanel("Total");
+		panelTotalTime.setEditable(false);
 		add(panelTotalTime);
-		
+	}
+	
+	public CollatedDurationPanel(final CollatedDuration duration) {
+		this();
 		setModel(duration);
 	}
 	

+ 18 - 8
src/main/lombok/org/leumasjaffe/recipe/view/DurationPanel.java

@@ -4,14 +4,20 @@ import java.awt.GridBagConstraints;
 import java.awt.GridBagLayout;
 import java.awt.Insets;
 
+import javax.swing.JFormattedTextField;
 import javax.swing.JLabel;
 import javax.swing.JPanel;
 
 import org.leumasjaffe.recipe.model.Duration;
+import org.leumasjaffe.recipe.view.formatter.DurationFormatter;
 
 @SuppressWarnings("serial")
 public class DurationPanel extends JPanel {
-	private JLabel lblTime;
+	JFormattedTextField txtTime;
+	
+	/**
+	 * @wbp.parser.constructor
+	 */
 	public DurationPanel(String name) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0};
@@ -27,19 +33,23 @@ public class DurationPanel extends JPanel {
 		gbc_lblName.gridy = 0;
 		add(lblName, gbc_lblName);
 		
-		lblTime = new JLabel();
-		GridBagConstraints gbc_lblTime = new GridBagConstraints();
-		gbc_lblTime.gridx = 1;
-		gbc_lblTime.gridy = 0;
-		add(lblTime, gbc_lblTime);
+		txtTime = new JFormattedTextField(new DurationFormatter());
+		GridBagConstraints gbc_txtTime = new GridBagConstraints();
+		gbc_txtTime.gridx = 1;
+		gbc_txtTime.gridy = 0;
+		add(txtTime, gbc_txtTime);
 	}
 
-	public DurationPanel(String name, Duration duration) {
+	public DurationPanel(String name, final Duration duration) {
 		this(name);
 		setModel(duration);
 	}
 
 	public void setModel(final Duration duration) {
-		lblTime.setText(duration.toString());
+		txtTime.setValue(duration);
+	}
+
+	public void setEditable(boolean b) {
+		txtTime.setEditable(b);
 	}
 }

+ 22 - 5
src/main/lombok/org/leumasjaffe/recipe/view/ElementPanel.java

@@ -4,6 +4,7 @@ import javax.swing.JPanel;
 import javax.swing.JScrollPane;
 
 import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Phase;
 import org.leumasjaffe.recipe.model.Element;
@@ -28,10 +29,13 @@ import javax.swing.Box;
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class ElementPanel extends JScrollPane {
-	JPanel panelViewPort;
+	ObservableListener<CollatedDurationPanel, Element> durationListener;
 	ForwardingObservableListener<Element> listener = new ForwardingObservableListener<>();
 
-	public ElementPanel(Element element) {
+	JLabel lblName;
+	JPanel panelViewPort;
+
+	public ElementPanel() {
 		setPreferredSize(new Dimension(500, 450));
 
 		setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
@@ -46,7 +50,7 @@ public class ElementPanel extends JScrollPane {
 		gbl_panelColumnHeader.rowWeights = new double[]{0.0, Double.MIN_VALUE};
 		panelColumnHeader.setLayout(gbl_panelColumnHeader);
 		
-		JLabel lblName = new JLabel(element.getName());
+		lblName = new JLabel();
 		GridBagConstraints gbc_lblName = new GridBagConstraints();
 		gbc_lblName.insets = new Insets(0, 0, 0, 5);
 		gbc_lblName.gridx = 0;
@@ -60,8 +64,7 @@ public class ElementPanel extends JScrollPane {
 		gbc_horizontalGlue.gridy = 0;
 		panelColumnHeader.add(horizontalGlue, gbc_horizontalGlue);
 		
-		CollatedDurationPanel panelDuration =
-				new CollatedDurationPanel(element.getCollatedDuration());
+		CollatedDurationPanel panelDuration = new CollatedDurationPanel();
 		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
 		gbc_panelDuration.gridx = 2;
 		gbc_panelDuration.gridy = 0;
@@ -71,12 +74,26 @@ public class ElementPanel extends JScrollPane {
 		setViewportView(panelViewPort);
 		panelViewPort.setLayout(new VerticalLayout(5));
 				
+		durationListener = new ObservableListener<>(panelDuration,
+				(c, v) -> c.setModel(v.getCollatedDuration()));
+	}
+	
+	public ElementPanel(final Element element) {
+		this();
+		setModel(element);
+	}
+	
+	public void setModel(final Element element) {
+		lblName.setText(element.getName());
+		
+		panelViewPort.removeAll();
 		for (final Phase phase : element.getPhases()) {
 			panelViewPort.add(new PhasePanel(phase));
 			panelViewPort.add(new JSeparator());
 		}
 		
 		listener.setObserved(element, element.getPhases());
+		durationListener.setObserved(element);
 	}
 	
 	@Override

+ 28 - 36
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPanel.java

@@ -5,15 +5,14 @@ import java.awt.GridBagLayout;
 import javax.swing.JTextField;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
-import java.text.NumberFormat;
-import java.util.Locale;
 
 import javax.swing.event.DocumentListener;
-import javax.swing.text.NumberFormatter;
 
 import org.leumasjaffe.observer.ObservableController;
+import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.view.formatter.AmountFormatter;
 
 import lombok.AccessLevel;
 import lombok.Getter;
@@ -26,18 +25,18 @@ import javax.swing.JLabel;
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
-	ObservableController<JTextField, Ingredient> nameController;
-	ObservableController<JTextField, Ingredient> preparationController;
+	ObservableListener<JTextField, Ingredient> nameController;
+	ObservableListener<JFormattedTextField, Ingredient> amountController;
+	ObservableListener<JTextField, Ingredient> preparationController;
 	@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) {
+	public IngredientPanel() {
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0};
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, Double.MIN_VALUE};
 		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
@@ -49,7 +48,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		gbc_label.gridy = 0;
 		add(label, gbc_label);
 		
-		txtName = new JTextField(ingredient.getName());
+		txtName = new JTextField();
 		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtName = new GridBagConstraints();
 		gbc_txtName.fill = GridBagConstraints.HORIZONTAL;
@@ -59,11 +58,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		add(txtName, gbc_txtName);
 		txtName.setColumns(10);
 		
-		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
-		fmtDone.setMinimum(0.0);
-		fmtDone.setCommitsOnValidEdit(true);
-		txtAmount = new JFormattedTextField(fmtDone);
-		txtAmount.setValue(ingredient.getAmount().getValue());
+		txtAmount = new JFormattedTextField(new AmountFormatter());
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
 		gbc_txtAmount.fill = GridBagConstraints.HORIZONTAL;
@@ -71,42 +66,39 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		gbc_txtAmount.gridx = 2;
 		gbc_txtAmount.gridy = 0;
 		add(txtAmount, gbc_txtAmount);
-		txtAmount.setColumns(4);
-		
-		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);
-		gbc_txtUnit.anchor = GridBagConstraints.ABOVE_BASELINE;
-		gbc_txtUnit.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtUnit.gridx = 3;
-		gbc_txtUnit.gridy = 0;
-		add(txtUnit, gbc_txtUnit);
-		txtUnit.setColumns(6);
+		txtAmount.setColumns(11);
 		
-		txtPreparation = new JTextField(ingredient.getPreparation());
+		txtPreparation = new JTextField();
 		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();
 		gbc_txtPreparation.anchor = GridBagConstraints.ABOVE_BASELINE;
 		gbc_txtPreparation.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtPreparation.gridx = 4;
+		gbc_txtPreparation.gridx = 3;
 		gbc_txtPreparation.gridy = 0;
 		add(txtPreparation, gbc_txtPreparation);
 		txtPreparation.setColumns(10);
 		
 		// I technically don't need to listen here as of this change,
 		// but if I ever restore support for it, it will be convenient.
-		nameController = new ObservableController<>(txtName,
-				Ingredient::getName, Ingredient::setName,
-				JTextField::setText);
-		preparationController = new ObservableController<>(txtPreparation,
-				Ingredient::getPreparation, Ingredient::setPreparation,
-				JTextField::setText);
+		nameController = ObservableController.from(txtName,
+				Ingredient::getName, Ingredient::setName);
+		amountController = ObservableController.from(txtAmount,
+				Ingredient::getAmount, Ingredient::setAmount);
+		preparationController = ObservableController.from(txtPreparation,
+				Ingredient::getPreparation, Ingredient::setPreparation);
+	}
 
+	public IngredientPanel(final Ingredient ingredient) {
+		this();
+		setModel(ingredient);
+	}
+	
+	public void setModel(final Ingredient ingredient) {
 		nameController.setObserved(ingredient);
+		amountController.setObserved(ingredient);
 		preparationController.setObserved(ingredient);
 	}
-
+	
 	@Override
 	public void addDocumentListener(DocumentListener dl) {
 		this.txtName.getDocument().addDocumentListener(dl);

+ 21 - 34
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPreparationPanel.java

@@ -5,14 +5,11 @@ import java.awt.GridBagLayout;
 import javax.swing.JTextField;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
-import java.text.NumberFormat;
-import java.util.Locale;
-
-import javax.swing.text.NumberFormatter;
 
 import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.view.formatter.AmountFormatter;
 
 import lombok.AccessLevel;
 import lombok.Getter;
@@ -29,14 +26,13 @@ public class IngredientPreparationPanel extends JPanel {
 	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) {
+	public IngredientPreparationPanel() {
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0};
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, Double.MIN_VALUE};
 		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
@@ -48,7 +44,7 @@ public class IngredientPreparationPanel extends JPanel {
 		gbc_label.gridy = 0;
 		add(label, gbc_label);
 		
-		txtName = new JTextField(ingredient.getName());
+		txtName = new JTextField();
 		txtName.setEditable(false);
 		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtName = new GridBagConstraints();
@@ -59,12 +55,8 @@ public class IngredientPreparationPanel extends JPanel {
 		add(txtName, gbc_txtName);
 		txtName.setColumns(10);
 		
-		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
-		fmtDone.setMinimum(0.0);
-		fmtDone.setCommitsOnValidEdit(true);
-		txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount = new JFormattedTextField(new AmountFormatter());
 		txtAmount.setEditable(false);
-		txtAmount.setValue(ingredient.getAmount().getValue());
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
 		gbc_txtAmount.fill = GridBagConstraints.HORIZONTAL;
@@ -72,37 +64,32 @@ public class IngredientPreparationPanel extends JPanel {
 		gbc_txtAmount.gridx = 2;
 		gbc_txtAmount.gridy = 0;
 		add(txtAmount, gbc_txtAmount);
-		txtAmount.setColumns(4);
+		txtAmount.setColumns(11);
 		
-		txtUnit = new JTextField(ingredient.getAmount().unitName());
-		txtUnit.setEditable(false);
-		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
-		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
-		gbc_txtUnit.insets = new Insets(0, 0, 0, 5);
-		gbc_txtUnit.anchor = GridBagConstraints.ABOVE_BASELINE;
-		gbc_txtUnit.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtUnit.gridx = 3;
-		gbc_txtUnit.gridy = 0;
-		add(txtUnit, gbc_txtUnit);
-		txtUnit.setColumns(6);
-		
-		txtPreparation = new JTextField(ingredient.getPreparation());
+		txtPreparation = new JTextField();
 		txtPreparation.setEditable(false);
 		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();
 		gbc_txtPreparation.anchor = GridBagConstraints.ABOVE_BASELINE;
 		gbc_txtPreparation.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtPreparation.gridx = 4;
+		gbc_txtPreparation.gridx = 3;
 		gbc_txtPreparation.gridy = 0;
 		add(txtPreparation, gbc_txtPreparation);
 		txtPreparation.setColumns(10);
 		
-		listener = new ObservableListener<>(this, (c, t) -> {
-			txtName.setText(t.getName());
-			txtAmount.setValue(t.getAmount().getValue());
-			txtUnit.setText(t.getAmount().unitName());
-			txtPreparation.setText(t.getPreparation());
+		listener = new ObservableListener<>(this, (c, v) -> {
+			c.txtName.setText(v.getName());
+			c.txtAmount.setValue(v.getAmount());
+			c.txtPreparation.setText(v.getPreparation());
 		});
+	}
+	
+	public IngredientPreparationPanel(final Ingredient ingredient) {
+		this();
+		setModel(ingredient);
+	}
+	
+	public void setModel(final Ingredient ingredient) {
 		listener.setObserved(ingredient);
 	}
 	

+ 9 - 1
src/main/lombok/org/leumasjaffe/recipe/view/PhasePanel.java

@@ -1,8 +1,12 @@
 package org.leumasjaffe.recipe.view;
 
+import java.util.ArrayList;
+import java.util.List;
+
 import javax.swing.JPanel;
 
 import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.Observable;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Phase;
 import org.leumasjaffe.recipe.model.Preparation;
@@ -27,7 +31,11 @@ public class PhasePanel extends JPanel {
 		phase.getPreparation().ifPresent(this::addPrep);
 		phase.getCooking().forEach(this::addStep);
 		phase.getRest().ifPresent(this::addRest);
-		listener.setObserved(phase, phase.getCooking());
+
+		List<Observable> children = new ArrayList<>(phase.getCooking());
+		phase.getPreparation().ifPresent(children::add);
+		phase.getRest().ifPresent(children::add);
+		listener.setObserved(phase, children);
 	}
 	
 	void addPrep(final Preparation step) {

+ 7 - 4
src/main/lombok/org/leumasjaffe/recipe/view/PreparationPanel.java

@@ -3,6 +3,7 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JPanel;
 
 import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.observer.ObservableController;
 import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.recipe.controller.ReplaceChildrenController;
 import org.leumasjaffe.recipe.model.Duration;
@@ -20,15 +21,15 @@ import java.awt.Insets;
 import javax.swing.JLabel;
 import java.awt.Component;
 import javax.swing.Box;
+import javax.swing.JFormattedTextField;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE)
 public class PreparationPanel extends JPanel {
 	ReplaceChildrenController<Preparation, Ingredient> controller;
 	ObservableListener<JPanel, Preparation> childListener;
+	ObservableListener<JFormattedTextField, Preparation> durationListener;
 	
-	DurationPanel panelDuration;
-		
 	public PreparationPanel() {
 		controller = new ReplaceChildrenController<>(Preparation::getIngredients,
 				IngredientPreparationPanel::new);
@@ -69,7 +70,7 @@ public class PreparationPanel extends JPanel {
 		gbc_horizontalGlue.gridy = 0;
 		panelLeft.add(horizontalGlue, gbc_horizontalGlue);
 		
-		panelDuration = new DurationPanel("Requires", Duration.ZERO);
+		DurationPanel panelDuration = new DurationPanel("Requires", Duration.ZERO);
 		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
 		gbc_panelDuration.insets = new Insets(0, 0, 5, 0);
 		gbc_panelDuration.gridx = 2;
@@ -89,6 +90,8 @@ public class PreparationPanel extends JPanel {
 		// This indirection allows for testing of controller
 		childListener = new ObservableListener<>(panelIngredients,
 				(c, v) -> controller.accept(c, v));
+		durationListener = ObservableController.from(panelDuration.txtTime,
+				Preparation::getDuration, Preparation::setDuration);
 	}
 	
 	public PreparationPanel(final Preparation preparation) {
@@ -97,7 +100,7 @@ public class PreparationPanel extends JPanel {
 	}
 	
 	public void setModel(final Preparation preparation) {
-		panelDuration.setModel(preparation.getDuration());
+		durationListener.setObserved(preparation);
 		childListener.setObserved(preparation);
 	}
 

+ 0 - 1
src/main/lombok/org/leumasjaffe/recipe/view/RecipeCardPanel.java

@@ -5,7 +5,6 @@ import javax.swing.JSplitPane;
 import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.observer.ForwardingObservableListener;
 import org.leumasjaffe.observer.ObservableListener;
-import org.leumasjaffe.recipe.model.Element;
 import org.leumasjaffe.recipe.model.RecipeCard;
 import org.leumasjaffe.recipe.view.summary.SummaryPanel;
 

+ 28 - 11
src/main/lombok/org/leumasjaffe/recipe/view/RestPanel.java

@@ -2,24 +2,28 @@ package org.leumasjaffe.recipe.view;
 
 import javax.swing.JPanel;
 
+import org.leumasjaffe.observer.ObservableController;
+import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.recipe.model.Rest;
 
 import lombok.AccessLevel;
-import lombok.Getter;
 import lombok.experimental.FieldDefaults;
 
 import java.awt.GridBagLayout;
+
+import javax.swing.JFormattedTextField;
 import javax.swing.JLabel;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
 
 @SuppressWarnings("serial")
-@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+@FieldDefaults(level=AccessLevel.PRIVATE)
 public class RestPanel extends JPanel {
-	@Getter(AccessLevel.PACKAGE) JLabel lblLocation;
-	@Getter(AccessLevel.PACKAGE) JLabel lblDuration;
+	ObservableListener<JFormattedTextField, Rest> durationListener;
+
+	JLabel lblLocation;
 
-	public RestPanel(Rest rest) {
+	public RestPanel() {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
@@ -34,18 +38,31 @@ public class RestPanel extends JPanel {
 		gbc_lblRest.gridy = 0;
 		add(lblRest, gbc_lblRest);
 		
-		lblLocation = new JLabel(rest.getWhere().getHumanReadable());
+		lblLocation = new JLabel();
 		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);
 		
-		lblDuration = new JLabel(rest.getDuration().toString());
-		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
-		gbc_lblDuration.gridx = 2;
-		gbc_lblDuration.gridy = 0;
-		add(lblDuration, gbc_lblDuration);
+		DurationPanel panelDuration = new DurationPanel("");
+		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
+		gbc_panelDuration.gridx = 2;
+		gbc_panelDuration.gridy = 0;
+		add(panelDuration, gbc_panelDuration);
+		
+		durationListener = ObservableController.from(panelDuration.txtTime,
+				Rest::getDuration, Rest::setDuration);
+	}
+
+	public RestPanel(final Rest rest) {
+		this();
+		setModel(rest);
+	}
+	
+	public void setModel(final Rest rest) {
+		lblLocation.setText(rest.getWhere().getHumanReadable());
+		durationListener.setObserved(rest);
 	}
 
 }

+ 11 - 1
src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java

@@ -4,6 +4,8 @@ import javax.swing.JPanel;
 import javax.swing.event.DocumentListener;
 
 import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.ObservableController;
+import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
 import org.leumasjaffe.recipe.model.Step;
@@ -22,15 +24,19 @@ import javax.swing.JLabel;
 import javax.swing.JTextPane;
 import java.awt.Component;
 import javax.swing.Box;
+import javax.swing.JFormattedTextField;
+
 import java.awt.Dimension;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
+	ForwardingObservableListener<Step> listener = new ForwardingObservableListener<>();
+	ObservableListener<JFormattedTextField, Step> durationListener;
+
 	@Getter(AccessLevel.PACKAGE) JLabel lblIndex;
 	@Getter(AccessLevel.PACKAGE) JTextPane txtpnInstructions;
 	@Getter(AccessLevel.PACKAGE) AutoGrowPanel panelIngredients;
-	ForwardingObservableListener<Step> listener = new ForwardingObservableListener<>();
 		
 	public StepPanel(int zeroIndex, Step step) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -103,8 +109,12 @@ public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenabl
 		gbc_txtpnInstructions.gridy = 0;
 		add(txtpnInstructions, gbc_txtpnInstructions);
 		
+		durationListener = ObservableController.from(panelDuration.txtTime,
+				Step::getDuration, Step::setDuration);
+		
 		setListPosition(zeroIndex);
 		listener.setObserved(step, ingredients);
+		durationListener.setObserved(step);
 	}
 
 	@Override

+ 34 - 0
src/main/lombok/org/leumasjaffe/recipe/view/formatter/AmountFormatter.java

@@ -0,0 +1,34 @@
+package org.leumasjaffe.recipe.view.formatter;
+
+import java.text.ParseException;
+
+import javax.swing.text.DefaultFormatter;
+
+import org.leumasjaffe.recipe.model.Amount;
+
+public class AmountFormatter extends DefaultFormatter {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 7379502839670512936L;
+	
+	public AmountFormatter() {
+		setCommitsOnValidEdit(false);
+	}
+
+	@Override
+	public Amount stringToValue(String text) throws ParseException {
+		final int to = text.indexOf(' ');
+		try {
+			Float.parseFloat(to == -1 ? text : text.substring(0, to));
+		} catch (NumberFormatException nfe) {
+			throw new ParseException(nfe.getMessage(), 0);
+		}
+
+		// This is technically a little permissive, since we're not checking
+		// the validity of the unit type.
+		return new Amount(text);
+	}
+
+}

+ 49 - 0
src/main/lombok/org/leumasjaffe/recipe/view/formatter/DurationFormatter.java

@@ -0,0 +1,49 @@
+package org.leumasjaffe.recipe.view.formatter;
+
+import java.text.ParseException;
+
+import javax.swing.text.DefaultFormatter;
+
+import org.leumasjaffe.recipe.model.Duration;
+
+public class DurationFormatter extends DefaultFormatter {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = -2233461250996247706L;
+	
+	public DurationFormatter() {
+		setCommitsOnValidEdit(false);
+	}
+
+	@Override
+	public Duration stringToValue(String text) throws ParseException {
+		int tokens = 0;
+		int from = 0;
+		for (int to = text.indexOf(' '); to != -1;
+				from = to + 1, to = text.indexOf(' ', from), ++tokens) {
+			// In order to pass muster - a string must match the following:
+			// Composed of either 2 or 4 tokens (space separated) matching the regex:
+			// (\d+(\.5)? - )?\d+(\.5)? (s|min|hr)
+			if (tokens == 1) { continue; } // Ignore '-'
+			else if (tokens == 3) {
+				throw new ParseException("Too many tokens in input", from);
+			}
+			try {
+				Float.parseFloat(text.substring(from, to));
+			} catch (NumberFormatException nfe) {
+				throw new ParseException(nfe.getMessage(), from);
+			}
+		}
+		
+		// Less efficient, but this allows me to punt the validation to the object
+		// once I'm confident it 'appears' correct.
+		try {
+			return new Duration(text);
+		} catch (IllegalArgumentException | ArrayIndexOutOfBoundsException ex) {
+			throw new ParseException("No valid duration interval was provided", from);
+		}
+	}
+
+}

+ 6 - 26
src/main/lombok/org/leumasjaffe/recipe/view/summary/IngredientPanel.java

@@ -5,12 +5,9 @@ import java.awt.GridBagLayout;
 import javax.swing.JTextField;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
-import java.text.NumberFormat;
-import java.util.Locale;
-
-import javax.swing.text.NumberFormatter;
 
 import org.leumasjaffe.recipe.model.Ingredient;
+import org.leumasjaffe.recipe.view.formatter.AmountFormatter;
 
 import lombok.AccessLevel;
 import lombok.Getter;
@@ -25,13 +22,12 @@ import javax.swing.JLabel;
 public class IngredientPanel extends JPanel {
 	@Getter(AccessLevel.PACKAGE) JTextField txtName;
 	@Getter(AccessLevel.PACKAGE) JFormattedTextField txtAmount;
-	@Getter(AccessLevel.PACKAGE) JTextField txtUnit;
 	
 	public IngredientPanel() {
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0};
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, Double.MIN_VALUE};
 		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
@@ -54,30 +50,15 @@ public class IngredientPanel extends JPanel {
 		add(txtName, gbc_txtName);
 		txtName.setColumns(10);
 		
-		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
-		fmtDone.setMinimum(0.0);
-		fmtDone.setCommitsOnValidEdit(true);
-		txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount = new JFormattedTextField(new AmountFormatter());
 		txtAmount.setEditable(false);
 		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
 		gbc_txtAmount.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtAmount.insets = new Insets(0, 0, 0, 5);
 		gbc_txtAmount.gridx = 2;
 		gbc_txtAmount.gridy = 0;
 		add(txtAmount, gbc_txtAmount);
-		txtAmount.setColumns(4);
-		
-		txtUnit = new JTextField();
-		txtUnit.setEditable(false);
-		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
-		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
-		gbc_txtUnit.anchor = GridBagConstraints.ABOVE_BASELINE;
-		gbc_txtUnit.fill = GridBagConstraints.HORIZONTAL;
-		gbc_txtUnit.gridx = 3;
-		gbc_txtUnit.gridy = 0;
-		add(txtUnit, gbc_txtUnit);
-		txtUnit.setColumns(6);
+		txtAmount.setColumns(11);
 	}
 
 	public IngredientPanel(final Ingredient ingredient) {
@@ -87,7 +68,6 @@ public class IngredientPanel extends JPanel {
 	
 	public void setModel(final Ingredient ingredient) {
 		txtName.setText(ingredient.getName());
-		txtAmount.setValue(ingredient.getAmount().getValue());
-		txtUnit.setText(ingredient.getAmount().unitName());
+		txtAmount.setValue(ingredient.getAmount());
 	}
 }

+ 1 - 2
src/main/lombok/org/leumasjaffe/recipe/view/summary/SummaryPanel.java

@@ -13,7 +13,6 @@ import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.controller.ReplaceChildrenController;
-import org.leumasjaffe.recipe.model.CollatedDuration;
 import org.leumasjaffe.recipe.model.Element;
 import org.leumasjaffe.recipe.model.RecipeCard;
 import org.leumasjaffe.recipe.view.CollatedDurationPanel;
@@ -72,7 +71,7 @@ public class SummaryPanel extends JPanel {
 		panelHeader.add(txtTitle, gbc_txtTitle);
 		txtTitle.setColumns(10);
 		
-		CollatedDurationPanel panelDuration = new CollatedDurationPanel(CollatedDuration.ZERO);
+		CollatedDurationPanel panelDuration = new CollatedDurationPanel();
 		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
 		gbc_panelDuration.gridx = 0;
 		gbc_panelDuration.gridy = 1;

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

@@ -96,5 +96,33 @@ class DurationTest {
 	void testHalfHourDisplayIsNotUsedForCloserToWhole(int value) {
 		assertNotEquals("0.5 hr", new Duration(HOURS, 0, value).toString());
 	}
+	
+	@Test
+	void testStringConstructorProducesCorrectData() {
+		assertEquals(new Duration(HOURS, 3600, 7200), new Duration("1 - 2 hr"));
+		assertEquals(new Duration(MINUTES, 600, 3600), new Duration("10 - 60 min"));
+		assertEquals(new Duration(SECONDS, 5, 10), new Duration("5 - 10 s"));
+
+		assertEquals(new Duration(HOURS, 0, 3600), new Duration("1 hr"));
+		assertEquals(new Duration(MINUTES, 0, 3600), new Duration("60 min"));
+		assertEquals(new Duration(SECONDS, 0, 10), new Duration("10 s"));
+	}
+	
+	@Test
+	void testStringConstructorThrowsOnUnknownDuration() {
+		assertThrows(IllegalArgumentException.class,
+				() -> new Duration("0.5 tocks"));
+	}
+	
+	@Test
+	void testStringConstructorProcessesFloats() {
+		assertEquals("0.5 hr", new Duration("0.5 hr").toString());
+	}
+	
+	@Test
+	void testStringConstructorCanBeLossy() {
+		assertEquals("0.5 hr", new Duration("0.25 hr").toString());
+		assertEquals("0 hr", new Duration("0.2 hr").toString());
+	}
 
 }

+ 1 - 1
src/test/java/org/leumasjaffe/recipe/model/PhaseTest.java

@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test;
 
 class PhaseTest {
 	private static final Amount _1g = new Amount("1 g");
-	private static final Duration dur = new Duration(Duration.Display.SECONDS, 10, 20);
+	private static final Duration dur = new Duration("10 - 20 s");
 
 	@Test
 	void cannotAddNullPreparation() {

+ 7 - 24
src/test/java/org/leumasjaffe/recipe/view/IngredientPanelTest.java

@@ -35,8 +35,7 @@ class IngredientPanelTest extends SwingTestCase {
 	void testFilledOutWithContent() {
 		assertEquals("Onions", panel.getTxtName().getText());
 		assertEquals("Sliced", panel.getTxtPreparation().getText());
-		assertEquals("100", panel.getTxtAmount().getText());
-		assertEquals("g", panel.getTxtUnit().getText());
+		assertEquals(new Amount("100 g"), panel.getTxtAmount().getValue());
 	}
 	
 	@Test
@@ -44,7 +43,6 @@ class IngredientPanelTest extends SwingTestCase {
 		assertTrue(panel.getTxtName().isEditable());
 		assertTrue(panel.getTxtPreparation().isEditable());
 		assertTrue(panel.getTxtAmount().isEditable());
-		assertTrue(panel.getTxtUnit().isEditable());
 	}
 
 	@Test
@@ -57,8 +55,7 @@ class IngredientPanelTest extends SwingTestCase {
 		ObserverDispatch.notifySubscribers(stuff);
 		
 		assertEquals("Bacon", panel.getTxtName().getText());
-		assertEquals(100.0, panel.getTxtAmount().getValue());
-		assertEquals("g", panel.getTxtUnit().getText());
+		assertEquals(new Amount("0.25 lb"), panel.getTxtAmount().getValue());
 		assertEquals("Cut into Lardons", panel.getTxtPreparation().getText());
 	}
 
@@ -77,17 +74,10 @@ class IngredientPanelTest extends SwingTestCase {
 	}
 	
 	@Test
-	void testViewUpdateToAmountDoesNotAltersModel() {
-		panel.getTxtAmount().setValue(0.25);
+	void testViewUpdateToAmountAltersModel() {
+		panel.getTxtAmount().setValue(new Amount("1 lb"));
 		waitForSwing();
-		assertEquals(100.0, stuff.getAmount().getValue());
-	}
-	
-	@Test
-	void testViewUpdateToUnitDoesNotAltersModel() {
-		panel.getTxtUnit().setText("lb");
-		waitForSwing();
-		assertEquals("g", stuff.getAmount().unitName());
+		assertEquals(new Amount("1 lb"), stuff.getAmount());
 	}
 
 	@Test
@@ -106,16 +96,9 @@ class IngredientPanelTest extends SwingTestCase {
 
 	@Test
 	void testUpdateToAmountSendsNotify() {
-		panel.getTxtAmount().setValue(0.25);
+		panel.getTxtAmount().setValue(new Amount("1 lb"));
 		waitForSwing();
-		verify(listener, never()).updateWasSignalled();
+		verify(listener).updateWasSignalled();
 	}
 	
-	@Test
-	void testUpdateToUnitSendsNotify() {
-		panel.getTxtUnit().setText("lb");
-		waitForSwing();
-		verify(listener, never()).updateWasSignalled();
-	}
-
 }

+ 8 - 4
src/test/java/org/leumasjaffe/recipe/view/IngredientPreparationPanelTest.java

@@ -23,8 +23,7 @@ class IngredientPreparationPanelTest extends SwingTestCase {
 	void testFilledOutWithContent() {
 		assertEquals("Onions", panel.getTxtName().getText());
 		assertEquals("Sliced", panel.getTxtPreparation().getText());
-		assertEquals("100", panel.getTxtAmount().getText());
-		assertEquals("g", panel.getTxtUnit().getText());
+		assertEquals(new Amount("100 g"), panel.getTxtAmount().getValue());
 	}
 	
 	@Test
@@ -32,16 +31,21 @@ class IngredientPreparationPanelTest extends SwingTestCase {
 		assertFalse(panel.getTxtName().isEditable());
 		assertFalse(panel.getTxtPreparation().isEditable());
 		assertFalse(panel.getTxtAmount().isEditable());
-		assertFalse(panel.getTxtUnit().isEditable());
 	}
 
 	@Test
 	void testIsSubscribedToUpdates() {
 		stuff.setName("Bacon");
+		stuff.setPreparation("Cut into Lardons");
+		stuff.setAmount(new Amount("50 g"));
+		
 		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
+		assertEquals("Cut into Lardons", panel.getTxtPreparation().getText());
+		assertEquals(new Amount("50 g"), panel.getTxtAmount().getValue());
 	}
 
 }

+ 26 - 1
src/test/java/org/leumasjaffe/recipe/view/PhasePanelTest.java

@@ -3,6 +3,7 @@ package org.leumasjaffe.recipe.view;
 import static org.mockito.Mockito.*;
 
 import java.util.Arrays;
+import java.util.Optional;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -11,7 +12,10 @@ 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.Duration;
 import org.leumasjaffe.recipe.model.Phase;
+import org.leumasjaffe.recipe.model.Preparation;
+import org.leumasjaffe.recipe.model.Rest;
 import org.leumasjaffe.recipe.model.Step;
 import org.mockito.Mock;
 import org.mockito.Spy;
@@ -22,13 +26,19 @@ import org.mockito.junit.jupiter.MockitoExtension;
 class PhasePanelTest extends SwingTestCase {
 	
 	@Spy MockObserverListener listener;
+	
+	final Preparation prep = new Preparation();
 	final Step stub = new Step();
+	final Rest rest = new Rest(Rest.Where.REFRIGERATOR, new Duration("10 s"));
+	
 	@Mock Phase stuff;
 	PhasePanel panel;
 
 	@BeforeEach
 	void setUp() {
 		doReturn(Arrays.asList(stub)).when(stuff).getCooking();
+		doReturn(Optional.of(prep)).when(stuff).getPreparation();
+		doReturn(Optional.of(rest)).when(stuff).getRest();
 		panel = new PhasePanel(stuff);
 		
 		listener.setObserved(stuff);
@@ -37,8 +47,23 @@ class PhasePanelTest extends SwingTestCase {
 	}
 
 	@Test
-	void testPropogatesSignalFromChildren() {
+	void testPropogatesSignalFromSteps() {
 		ObserverDispatch.notifySubscribers(stub);
+		
+		verify(listener).updateWasSignalled();
+	}
+
+	@Test
+	void testPropogatesSignalFromPrep() {
+		ObserverDispatch.notifySubscribers(prep);
+		
+		verify(listener).updateWasSignalled();
+	}
+
+	@Test
+	void testPropogatesSignalFromRest() {
+		ObserverDispatch.notifySubscribers(rest);
+		
 		verify(listener).updateWasSignalled();
 	}
 

+ 7 - 20
src/test/java/org/leumasjaffe/recipe/view/PreparationPanelTest.java

@@ -2,12 +2,14 @@ package org.leumasjaffe.recipe.view;
 
 import static org.mockito.Mockito.*;
 
+import javax.swing.JFormattedTextField;
+
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.controller.ReplaceChildrenController;
-import org.leumasjaffe.recipe.model.Duration;
 import org.leumasjaffe.recipe.model.Ingredient;
 import org.leumasjaffe.recipe.model.Preparation;
 import org.mockito.InjectMocks;
@@ -17,41 +19,26 @@ import org.mockito.junit.jupiter.MockitoExtension;
 @ExtendWith(MockitoExtension.class)
 class PreparationPanelTest extends SwingTestCase {
 	
-	@Mock DurationPanel panelDuration;
+	@Mock ObservableListener<JFormattedTextField, Preparation> durationListener;
 	@Mock ReplaceChildrenController<Preparation, Ingredient> controller;
-	Preparation stuff;
+	Preparation stuff = new Preparation();
 	@InjectMocks PreparationPanel panel = new PreparationPanel();
 	
 	@BeforeEach
 	void setUp() {
-		stuff = mock(Preparation.class);
-		Duration dur = new Duration(Duration.Display.SECONDS, 0, 30);
-		doReturn(dur).when(stuff).getDuration();
-		
 		panel.setModel(stuff);
 	}
 
 	@Test
 	void testHasContent() {
-		verify(panelDuration).setModel(any());
+		verify(durationListener, times(1)).setObserved(same(stuff));
 		verify(controller, times(1)).accept(any(), same(stuff));
 	}
 
-	@Test
-	void testDoesNotUpdateDurationWhenNotified() {
-		clearInvocations(panelDuration);
-
-		ObserverDispatch.notifySubscribers(stuff);		
-
-		verify(panelDuration, never()).setModel(any());
-	}
-
 	@Test
 	void testUpdatesNumberOfChildrenWhenNotified() {
-		clearInvocations((Object) controller);
-		
 		ObserverDispatch.notifySubscribers(stuff);		
 
-		verify(controller, times(1)).accept(any(), same(stuff));
+		verify(controller, times(2)).accept(any(), same(stuff));
 	}
 }

+ 25 - 13
src/test/java/org/leumasjaffe/recipe/view/RestPanelTest.java

@@ -1,40 +1,52 @@
 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 static org.mockito.Mockito.*;
+
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
 
 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.ObservableListener;
 import org.leumasjaffe.recipe.model.Duration;
 import org.leumasjaffe.recipe.model.Rest;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 @ExtendWith(MockitoExtension.class)
 @RunWith(JUnitPlatform.class)
 class RestPanelTest extends SwingTestCase {
 	
-	Duration dur;
+	@Spy MockObserverListener listener;
+	
 	Rest stuff;
-	RestPanel panel;
+	
+	@Spy JLabel lblLocation;
+	@Mock ObservableListener<JFormattedTextField, Rest> durationListener;
+	@InjectMocks RestPanel panel = new RestPanel();
 	
 	@BeforeEach
 	void setUp() {
-		dur = new Duration(Duration.Display.SECONDS, 0, 30);
-		stuff = new Rest(Rest.Where.REFRIGERATOR, dur);
-		panel = new RestPanel(stuff);
+		stuff = new Rest(Rest.Where.REFRIGERATOR, new Duration(Duration.Display.SECONDS, 0, 30));
+		panel.setModel(stuff);
+		
+		listener.setObserved(stuff);
+		clearInvocations(listener);
 	}
 
 	@Test
 	void testHasContent() {
-		assertThat(panel.getLblDuration().getText(),
-				containsString(dur.toString()));
-		assertEquals(stuff.getWhere().getHumanReadable(),
-				panel.getLblLocation().getText());
+		verify(lblLocation).setText(eq(stuff.getWhere().getHumanReadable()));
 	}
 
-	// TODO: Hook-ups for editing the preparation time
+	@Test
+	void testDurationIsListeningToModel() {
+		verify(durationListener).setObserved(same(stuff));
+	}
 }

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

@@ -0,0 +1,40 @@
+package org.leumasjaffe.recipe.view.formatter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.text.ParseException;
+
+import org.junit.jupiter.api.Test;
+
+class AmountFormatterTest {
+	
+	AmountFormatter formatter = new AmountFormatter();
+
+	@Test
+	void testCanParseValidStrings() {
+		assertDoesNotThrow(() -> formatter.stringToValue("1"));
+		assertDoesNotThrow(() -> formatter.stringToValue("1 ct"));
+		assertDoesNotThrow(() -> formatter.stringToValue("1 g"));
+		assertDoesNotThrow(() -> formatter.stringToValue("1 tsp"));
+	}
+	
+	@Test
+	void testCanParseRandomNames() {
+		assertDoesNotThrow(() -> formatter.stringToValue("1 large"));
+		assertDoesNotThrow(() -> formatter.stringToValue("1 bacon pancake"));
+	}
+
+
+	@Test
+	void testThrowsErrorOnInvalidFloat() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0.Q"));
+	}
+
+	@Test
+	void testThrowsErrorEmptyString() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue(""));
+	}
+
+}

+ 56 - 0
src/test/java/org/leumasjaffe/recipe/view/formatter/DurationFormatterTest.java

@@ -0,0 +1,56 @@
+package org.leumasjaffe.recipe.view.formatter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.text.ParseException;
+
+import org.junit.jupiter.api.Test;
+
+class DurationFormatterTest {
+	
+	DurationFormatter formatter = new DurationFormatter();
+
+	@Test
+	void testCanParseValidStrings() {
+		assertDoesNotThrow(() -> formatter.stringToValue("10 s"));
+		assertDoesNotThrow(() -> formatter.stringToValue("10 - 20 s"));
+	}
+	
+	@Test
+	void testThrowsErrorOnUnknownEnum() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("10 tocks"));
+	}
+
+	@Test
+	void testThrowsErrorOnInvalidFloat() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0.Q s"));
+	}
+
+	@Test
+	void testThrowsErrorEmptyString() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue(""));
+	}
+	
+	@Test
+	void testThrowsErrorOneArg() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0"));
+	}
+
+	@Test
+	void testThrowsErrorThreeArgs() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0 - s"));
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0 1 s"));
+	}
+
+	@Test
+	void testThrowsErrorMoreThanFourArgs() {
+		assertThrows(ParseException.class,
+				() -> formatter.stringToValue("0 - 1 s s"));
+	}
+}

+ 1 - 3
src/test/java/org/leumasjaffe/recipe/view/summary/IngredientPanelTest.java

@@ -23,15 +23,13 @@ class IngredientPanelTest extends SwingTestCase {
 	@Test
 	void testFilledOutWithContent() {
 		assertEquals("Onions", panel.getTxtName().getText());
-		assertEquals("100", panel.getTxtAmount().getText());
-		assertEquals("g", panel.getTxtUnit().getText());
+		assertEquals(new Amount("100 g"), panel.getTxtAmount().getValue());
 	}
 	
 	@Test
 	void testCannotEditContent() {
 		assertFalse(panel.getTxtName().isEditable());
 		assertFalse(panel.getTxtAmount().isEditable());
-		assertFalse(panel.getTxtUnit().isEditable());
 	}
 
 	@Test

+ 6 - 30
src/test/resources/example.json

@@ -10,11 +10,7 @@
           "dependsOn": [],
           "vessel": "",
           "preparation": {
-            "duration": {
-              "displayAs": "MINUTES",
-              "minSeconds": 300,
-              "maxSeconds": 600
-            }
+            "duration": "5 - 10 min"
           },
           "cooking": [
             {
@@ -25,11 +21,7 @@
                   "amount": "1 Tbsp"
                 }
               ],
-              "duration": {
-                "displayAs": "SECONDS",
-                "minSeconds": 30,
-                "maxSeconds": 60
-              },
+              "duration": "30 - 60 s",
               "instruction": "Heat oil over high heat"
             },
             {
@@ -40,21 +32,13 @@
                   "amount": "100 g"
                 }
               ],
-              "duration": {
-                "displayAs": "MINUTES",
-                "minSeconds": 300,
-                "maxSeconds": 900
-              },
+              "duration": "5 - 10 min",
               "instruction": "Sauté the onions until soft and translucent, stirring to prevent burning"
             }
           ],
           "rest": {
             "where": "ROOM_TEMPERATURE",
-            "duration": {
-              "displayAs": "SECONDS",
-              "minSeconds": 0,
-              "maxSeconds": 30
-            }
+            "duration": "30 s"
           }
         },
         {
@@ -70,11 +54,7 @@
                   "amount": "1 Tbsp"
                 }
               ],
-              "duration": {
-                "displayAs": "SECONDS",
-                "minSeconds": 30,
-                "maxSeconds": 60
-              },
+              "duration": "30 - 60 s",
               "instruction": "Heat oil over high heat"
             },
             {
@@ -85,11 +65,7 @@
                   "amount": "100 g"
                 }
               ],
-              "duration": {
-                "displayAs": "MINUTES",
-                "minSeconds": 300,
-                "maxSeconds": 900
-              },
+              "duration": "5 - 10 min",
               "instruction": "Sauté the onions until soft and translucent, stirring to prevent burning"
             }
           ]