Explorar el Código

Merge branch 'feat/duration'

* feat/duration:
  Change RecipeCardPanel's model
  Do some work on RecipeCardPanel, but break the tests.
  Add Duration to SummaryPanel
  More testing
  Restore testing for Duration.
  Fix resource file
  Eliminate isApproximate, since it's a dumb idea.
  Fix test failure.
  Apply rounding in the summary, and support half-hour resolution display for Durations
  Change StepPanel to use DurationPanel.
  Add DurationPanel, CollatedDuration+Panel
Sam Jaffe hace 5 años
padre
commit
b32cf0037f
Se han modificado 22 ficheros con 315 adiciones y 151 borrados
  1. 17 0
      src/main/lombok/org/leumasjaffe/recipe/model/CollatedDuration.java
  2. 4 1
      src/main/lombok/org/leumasjaffe/recipe/model/CompoundRecipeComponent.java
  3. 29 9
      src/main/lombok/org/leumasjaffe/recipe/model/Duration.java
  4. 6 1
      src/main/lombok/org/leumasjaffe/recipe/model/Element.java
  5. 8 0
      src/main/lombok/org/leumasjaffe/recipe/model/Phase.java
  6. 5 0
      src/main/lombok/org/leumasjaffe/recipe/model/RecipeCard.java
  7. 28 0
      src/main/lombok/org/leumasjaffe/recipe/view/CollatedDurationPanel.java
  8. 35 0
      src/main/lombok/org/leumasjaffe/recipe/view/DurationPanel.java
  9. 32 3
      src/main/lombok/org/leumasjaffe/recipe/view/ElementPanel.java
  10. 5 21
      src/main/lombok/org/leumasjaffe/recipe/view/RecipeCardPanel.java
  11. 11 5
      src/main/lombok/org/leumasjaffe/recipe/view/RecipeManagerFrame.java
  12. 6 7
      src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java
  13. 33 30
      src/main/lombok/org/leumasjaffe/recipe/view/summary/SummaryPanel.java
  14. 33 0
      src/test/java/org/leumasjaffe/recipe/model/CollatedDurationTest.java
  15. 56 21
      src/test/java/org/leumasjaffe/recipe/model/DurationTest.java
  16. 2 2
      src/test/java/org/leumasjaffe/recipe/model/PhaseTest.java
  17. 2 0
      src/test/java/org/leumasjaffe/recipe/view/ElementPanelTest.java
  18. 1 1
      src/test/java/org/leumasjaffe/recipe/view/PreparationPanelTest.java
  19. 0 39
      src/test/java/org/leumasjaffe/recipe/view/RecipeCardPanelTest.java
  20. 1 1
      src/test/java/org/leumasjaffe/recipe/view/RestPanelTest.java
  21. 1 4
      src/test/java/org/leumasjaffe/recipe/view/StepPanelTest.java
  22. 0 6
      src/test/resources/example.json

+ 17 - 0
src/main/lombok/org/leumasjaffe/recipe/model/CollatedDuration.java

@@ -0,0 +1,17 @@
+package org.leumasjaffe.recipe.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data @AllArgsConstructor
+public class CollatedDuration {
+	public static final CollatedDuration ZERO =
+			new CollatedDuration(Duration.ZERO, Duration.ZERO, Duration.ZERO);
+	
+	Duration prepTime, cookingTime, totalTime;
+	
+	public CollatedDuration plus(CollatedDuration rhs) {
+		return new CollatedDuration(prepTime.plus(rhs.prepTime),
+				cookingTime.plus(rhs.cookingTime), totalTime.plus(rhs.totalTime));
+	}
+}

+ 4 - 1
src/main/lombok/org/leumasjaffe/recipe/model/CompoundRecipeComponent.java

@@ -7,10 +7,13 @@ import java.util.stream.Stream;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
-@JsonIgnoreProperties({"duration", "ingredients", "components", "ingredientsAsStream"})
+@JsonIgnoreProperties({
+	"duration", "ingredients", "components", "ingredientsAsStream", "collatedDuration"
+})
 interface CompoundRecipeComponent extends RecipeComponent {
 	Stream<? extends RecipeComponent> getComponents();
 	Stream<Ingredient> getIngredientsAsStream();
+	CollatedDuration getCollatedDuration();
 	
 	@Override
 	default Duration getDuration() {

+ 29 - 9
src/main/lombok/org/leumasjaffe/recipe/model/Duration.java

@@ -13,7 +13,7 @@ import lombok.Setter;
 public class Duration {
 	@AllArgsConstructor
 	public enum Display {
-		SECONDS("s", 1), MINUTES("min", 60), HOURS("hr", 3600);
+		SECONDS("s", 1), MINUTES("min", 60), HALF_HOURS("hr", 3600), HOURS("hr", 3600);
 		String abbreviation;
 		int inSeconds;
 	}
@@ -21,23 +21,38 @@ public class Duration {
 	public static final Duration ZERO = new Duration();
 	
 	Display displayAs = Display.SECONDS;
-	boolean isApproximate = false;
 	int minSeconds = 0;
 	int maxSeconds = 0;
 	
 	public Duration plus(Duration rhs) {
 		final Display newDisplayAs = displayAs.ordinal() < rhs.displayAs.ordinal() ?
 				displayAs : rhs.displayAs;
-		return new Duration(newDisplayAs, isApproximate || rhs.isApproximate,
-				minSeconds + rhs.minSeconds, maxSeconds + rhs.maxSeconds);
+		return new Duration(newDisplayAs, minSeconds + rhs.minSeconds,
+				maxSeconds + rhs.maxSeconds).smartScale();
 	}
 	
+	public Duration round(int to) {
+		to *= displayAs.inSeconds;
+		return new Duration(displayAs,
+				to * Math.round(minSeconds / (float) to),
+				to * Math.round(maxSeconds / (float) to));
+	}
+	
+	private Duration smartScale() {
+		final int toCheck = minSeconds == 0 ? maxSeconds : minSeconds;
+		if (toCheck > Display.HOURS.inSeconds * 4) {
+			displayAs = Display.HOURS;
+		} else if (toCheck > Display.HOURS.inSeconds * 1) {
+			displayAs = Display.HALF_HOURS;
+		} else if (toCheck > Display.MINUTES.inSeconds * 2) {
+			displayAs = Display.MINUTES;
+		}
+		return this;
+	}
+
 	@Override
 	public String toString() {
 		StringBuilder build = new StringBuilder();
-		if (isApproximate) {
-			build.append("~ ");
-		}
 		if (minSeconds != 0) {
 			build.append(convert(minSeconds, displayAs));
 			build.append(" - ");
@@ -48,7 +63,12 @@ public class Duration {
 		return build.toString();
 	}
 
-	private static int convert(int seconds, Display as) {
-		return seconds / as.inSeconds;
+	private static String convert(float seconds, Display as) {
+		// X * 3600 + (901, 2399) => X.5
+		int rounded = Math.round(2 * seconds / as.inSeconds);
+		if (as == Display.HALF_HOURS && (rounded % 2) == 1) {
+			return Float.toString(rounded / 2.f);
+		}
+		return Integer.toString(Math.round(seconds / as.inSeconds));
 	}
 }

+ 6 - 1
src/main/lombok/org/leumasjaffe/recipe/model/Element.java

@@ -23,5 +23,10 @@ public class Element extends Observable.Instance implements CompoundRecipeCompon
 	@Override
 	public Stream<Phase> getComponents() {
 		return phases.stream();
-	}	
+	}
+
+	public CollatedDuration getCollatedDuration() {
+		return getComponents().map(Phase::getCollatedDuration)
+				.reduce(CollatedDuration.ZERO, CollatedDuration::plus);
+	}
 }

+ 8 - 0
src/main/lombok/org/leumasjaffe/recipe/model/Phase.java

@@ -29,6 +29,14 @@ public class Phase extends Observable.Instance implements CompoundRecipeComponen
 		return cooking.stream();
 	}
 	
+	public CollatedDuration getCollatedDuration() {
+		final Duration prep = preparation.map(Preparation::getDuration).orElse(Duration.ZERO);
+		final Duration rest = this.rest.map(Rest::getDuration).orElse(Duration.ZERO);
+		final Duration cooking = this.cooking.stream().map(Step::getDuration)
+				.reduce(Duration.ZERO, Duration::plus);
+		return new CollatedDuration(prep, cooking, prep.plus(cooking).plus(rest));
+	}
+	
 	public void setPreparation(final @NonNull Preparation p) {
 		preparation = Optional.of(new Preparation(p.duration, this::getIngredients));
 	}

+ 5 - 0
src/main/lombok/org/leumasjaffe/recipe/model/RecipeCard.java

@@ -27,4 +27,9 @@ public class RecipeCard implements CompoundRecipeComponent {
 	public Stream<Ingredient> getIngredientsAsStream() {
 		return getComponents().flatMap(Element::getIngredientsAsStream);
 	}
+
+	public CollatedDuration getCollatedDuration() {
+		return getComponents().map(Element::getCollatedDuration)
+				.reduce(CollatedDuration.ZERO, CollatedDuration::plus);
+	}
 }

+ 28 - 0
src/main/lombok/org/leumasjaffe/recipe/view/CollatedDurationPanel.java

@@ -0,0 +1,28 @@
+package org.leumasjaffe.recipe.view;
+
+import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.SwingConstants;
+
+import org.jdesktop.swingx.HorizontalLayout;
+import org.leumasjaffe.recipe.model.CollatedDuration;
+
+@SuppressWarnings("serial")
+public class CollatedDurationPanel extends JPanel {
+	
+	public CollatedDurationPanel(CollatedDuration duration) {
+		setLayout(new HorizontalLayout(5));
+		
+		DurationPanel panelPrepTime = new DurationPanel("Prep", duration.getPrepTime().round(5));
+		add(panelPrepTime);
+		add(new JSeparator(SwingConstants.VERTICAL));
+		
+		DurationPanel panelCookingTime = new DurationPanel("Cooking", duration.getCookingTime().round(5));
+		add(panelCookingTime);
+		add(new JSeparator(SwingConstants.VERTICAL));
+		
+		DurationPanel panelTotalTime = new DurationPanel("Total", duration.getTotalTime().round(5));
+		add(panelTotalTime);
+	}
+
+}

+ 35 - 0
src/main/lombok/org/leumasjaffe/recipe/view/DurationPanel.java

@@ -0,0 +1,35 @@
+package org.leumasjaffe.recipe.view;
+
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import org.leumasjaffe.recipe.model.Duration;
+
+@SuppressWarnings("serial")
+public class DurationPanel extends JPanel {
+	public DurationPanel(String name, Duration duration) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblPrep = new JLabel(name + ": ");
+		GridBagConstraints gbc_lblPrep = new GridBagConstraints();
+		gbc_lblPrep.insets = new Insets(0, 0, 0, 5);
+		gbc_lblPrep.gridx = 0;
+		gbc_lblPrep.gridy = 0;
+		add(lblPrep, gbc_lblPrep);
+		
+		JLabel lblPrepTime = new JLabel(duration.toString());
+		GridBagConstraints gbc_lblPrepTime = new GridBagConstraints();
+		gbc_lblPrepTime.gridx = 1;
+		gbc_lblPrepTime.gridy = 0;
+		add(lblPrepTime, gbc_lblPrepTime);
+	}
+}

+ 32 - 3
src/main/lombok/org/leumasjaffe/recipe/view/ElementPanel.java

@@ -13,8 +13,13 @@ import lombok.experimental.FieldDefaults;
 
 import org.jdesktop.swingx.VerticalLayout;
 
-import javax.swing.JButton;
 import javax.swing.JSeparator;
+import java.awt.GridBagLayout;
+import javax.swing.JLabel;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+import java.awt.Component;
+import javax.swing.Box;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
@@ -25,10 +30,34 @@ public class ElementPanel extends JScrollPane {
 	public ElementPanel(Element element) {
 		JPanel panelColumnHeader = new JPanel();
 		setColumnHeaderView(panelColumnHeader);
+		GridBagLayout gbl_panelColumnHeader = new GridBagLayout();
+		gbl_panelColumnHeader.columnWidths = new int[]{0, 0, 0, 0};
+		gbl_panelColumnHeader.rowHeights = new int[]{0, 0};
+		gbl_panelColumnHeader.columnWeights = new double[]{0.0, 1.0, 0.0, Double.MIN_VALUE};
+		gbl_panelColumnHeader.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		panelColumnHeader.setLayout(gbl_panelColumnHeader);
 		
-		JButton btnAddStep = new JButton("Add Phase");
-		panelColumnHeader.add(btnAddStep);
+		JLabel lblName = new JLabel(element.getName());
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.insets = new Insets(0, 0, 0, 5);
+		gbc_lblName.gridx = 0;
+		gbc_lblName.gridy = 0;
+		panelColumnHeader.add(lblName, gbc_lblName);
 		
+		Component horizontalGlue = Box.createHorizontalGlue();
+		GridBagConstraints gbc_horizontalGlue = new GridBagConstraints();
+		gbc_horizontalGlue.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalGlue.gridx = 1;
+		gbc_horizontalGlue.gridy = 0;
+		panelColumnHeader.add(horizontalGlue, gbc_horizontalGlue);
+		
+		CollatedDurationPanel panelDuration =
+				new CollatedDurationPanel(element.getCollatedDuration());
+		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
+		gbc_panelDuration.gridx = 2;
+		gbc_panelDuration.gridy = 0;
+		panelColumnHeader.add(panelDuration, gbc_panelDuration);
+
 		panelViewPort = new JPanel();
 		setViewportView(panelViewPort);
 		panelViewPort.setLayout(new VerticalLayout(5));

+ 5 - 21
src/main/lombok/org/leumasjaffe/recipe/view/RecipeCardPanel.java

@@ -3,8 +3,6 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JSplitPane;
 
 import org.jdesktop.swingx.VerticalLayout;
-import org.leumasjaffe.recipe.controller.FileController;
-import org.leumasjaffe.recipe.model.Element;
 import org.leumasjaffe.recipe.model.RecipeCard;
 import org.leumasjaffe.recipe.view.summary.SummaryPanel;
 
@@ -15,28 +13,14 @@ import javax.swing.JPanel;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE)
-public class RecipeCardPanel extends JSplitPane implements FileController.ViewModel {
-	SummaryPanel summaryPanel;
-	JPanel rightPanel;
+public class RecipeCardPanel extends JSplitPane {
 	
-	public RecipeCardPanel() {
-		rightPanel = new JPanel();
+	public RecipeCardPanel(final RecipeCard card) {
+		final JPanel rightPanel = new JPanel();
 		rightPanel.setLayout(new VerticalLayout(5));
 		setRightComponent(rightPanel);
-		
-		summaryPanel = new SummaryPanel();
-		setLeftComponent(summaryPanel);
+		setLeftComponent(new SummaryPanel(card));
+		card.getComponents().map(ElementPanel::new).forEach(rightPanel::add);
 	}
 
-	@Override
-	public void setModel(final RecipeCard card) {
-		rightPanel.removeAll();
-		summaryPanel.removeElements();
-		card.getElements().forEach(this::addElement);
-	}
-	
-	private void addElement(final Element comp) {
-		summaryPanel.addElement(comp);
-		rightPanel.add(new ElementPanel(comp));
-	}
 }

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

@@ -3,6 +3,7 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JFrame;
 
 import org.leumasjaffe.recipe.controller.FileController;
+import org.leumasjaffe.recipe.model.RecipeCard;
 
 import lombok.AccessLevel;
 import lombok.experimental.FieldDefaults;
@@ -12,11 +13,11 @@ import javax.swing.JMenu;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class RecipeManagerFrame extends JFrame {
+public class RecipeManagerFrame extends JFrame implements FileController.ViewModel {
 	
 	public RecipeManagerFrame() {
-		RecipeCardPanel panel = new RecipeCardPanel();
-		FileController fileController = new FileController(panel);
+//		RecipeCardPanel panel = new RecipeCardPanel();
+		FileController fileController = new FileController(this);
 
 		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
 		
@@ -25,8 +26,8 @@ public class RecipeManagerFrame extends JFrame {
 		
 		JMenu mnFile = new FileMenu(this, fileController);
 		menuBar.add(mnFile);
-				
-		setContentPane(panel);
+		
+//		setContentPane(panel);
 
 //		fileController.create();
 		fileController.open("src/test/resources/example.json");
@@ -35,4 +36,9 @@ public class RecipeManagerFrame extends JFrame {
 		repaint();
 		setVisible(true);
 	}
+	
+	@Override
+	public void setModel(final RecipeCard card) {
+		setContentPane(new RecipeCardPanel(card));
+	}
 }

+ 6 - 7
src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java

@@ -28,7 +28,6 @@ import java.awt.Dimension;
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
 	@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<>();
@@ -70,12 +69,12 @@ public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenabl
 		gbc_horizontalGlue.gridy = 0;
 		panelLeft.add(horizontalGlue, gbc_horizontalGlue);
 		
-		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);
+		DurationPanel panelDuration = new DurationPanel("Requires", step.getDuration());
+		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
+		gbc_panelDuration.insets = new Insets(0, 0, 5, 0);
+		gbc_panelDuration.gridx = 2;
+		gbc_panelDuration.gridy = 0;
+		panelLeft.add(panelDuration, gbc_panelDuration);
 		
 		final List<Ingredient> ingredients = step.getIngredients();
 		panelIngredients = new AutoGrowPanel(IngredientPanel::new,

+ 33 - 30
src/main/lombok/org/leumasjaffe/recipe/view/summary/SummaryPanel.java

@@ -4,25 +4,26 @@ import java.awt.GridBagConstraints;
 import java.awt.GridBagLayout;
 import java.awt.Insets;
 
-import javax.swing.JLabel;
 import javax.swing.JPanel;
 import javax.swing.JSeparator;
+import javax.swing.JTextArea;
 import javax.swing.JTextField;
-import javax.swing.JTextPane;
 
 import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.recipe.model.Element;
+import org.leumasjaffe.recipe.model.RecipeCard;
+import org.leumasjaffe.recipe.view.CollatedDurationPanel;
 import org.leumasjaffe.recipe.view.ImagePanel;
 
 import lombok.AccessLevel;
 import lombok.experimental.FieldDefaults;
+import java.awt.Font;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class SummaryPanel extends JPanel {
-	JPanel panelIngredients;
 	
-	public SummaryPanel() {
+	public SummaryPanel(final RecipeCard card) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0, 0, 0};
@@ -39,14 +40,14 @@ public class SummaryPanel extends JPanel {
 		gbc_panelHeader.gridy = 0;
 		add(panelHeader, gbc_panelHeader);
 		GridBagLayout gbl_panelHeader = new GridBagLayout();
-		gbl_panelHeader.columnWidths = new int[]{0, 0, 0};
-		gbl_panelHeader.rowHeights = new int[]{0, 0};
-		gbl_panelHeader.columnWeights = new double[]{1.0, 0.0, Double.MIN_VALUE};
-		gbl_panelHeader.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		gbl_panelHeader.columnWidths = new int[]{0, 0};
+		gbl_panelHeader.rowHeights = new int[]{0, 0, 0};
+		gbl_panelHeader.columnWeights = new double[]{1.0, Double.MIN_VALUE};
+		gbl_panelHeader.rowWeights = new double[]{0.0, 0.0, Double.MIN_VALUE};
 		panelHeader.setLayout(gbl_panelHeader);
 		
 		JTextField txtTitle = new JTextField();
-		txtTitle.setText("Title");
+		txtTitle.setText(card.getTitle());
 		GridBagConstraints gbc_txtTitle = new GridBagConstraints();
 		gbc_txtTitle.insets = new Insets(0, 0, 0, 5);
 		gbc_txtTitle.fill = GridBagConstraints.HORIZONTAL;
@@ -55,13 +56,14 @@ public class SummaryPanel extends JPanel {
 		panelHeader.add(txtTitle, gbc_txtTitle);
 		txtTitle.setColumns(10);
 		
-		JLabel lblDuration = new JLabel("Duration");
-		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
-		gbc_lblDuration.gridx = 1;
-		gbc_lblDuration.gridy = 0;
-		panelHeader.add(lblDuration, gbc_lblDuration);
+		CollatedDurationPanel panelDuration =
+				new CollatedDurationPanel(card.getCollatedDuration());
+		GridBagConstraints gbc_panelDuration = new GridBagConstraints();
+		gbc_panelDuration.gridx = 0;
+		gbc_panelDuration.gridy = 1;
+		panelHeader.add(panelDuration, gbc_panelDuration);
 		
-		panelIngredients = new JPanel();
+		JPanel panelIngredients = new JPanel();
 		GridBagConstraints gbc_panelIngredients = new GridBagConstraints();
 		gbc_panelIngredients.insets = new Insets(0, 0, 5, 5);
 		gbc_panelIngredients.fill = GridBagConstraints.BOTH;
@@ -91,21 +93,22 @@ public class SummaryPanel extends JPanel {
 		gbc_panelPhoto.gridy = 0;
 		panel.add(panelPhoto, gbc_panelPhoto);
 		
-		JTextPane textpnDesription = new JTextPane();
-		GridBagConstraints gbc_textpnDesription = new GridBagConstraints();
-		gbc_textpnDesription.insets = new Insets(0, 0, 5, 0);
-		gbc_textpnDesription.fill = GridBagConstraints.BOTH;
-		gbc_textpnDesription.gridx = 0;
-		gbc_textpnDesription.gridy = 1;
-		panel.add(textpnDesription, gbc_textpnDesription);
-	}
-
-	public void addElement(final Element comp) {
-		panelIngredients.add(new ElementPanel(comp));
-		panelIngredients.add(new JSeparator());
+		JTextArea txaDesription = new JTextArea(5, 20);
+		txaDesription.setFont(new Font("Verdana", Font.PLAIN, 10));
+		txaDesription.setWrapStyleWord(true);
+		txaDesription.setLineWrap(true);
+		txaDesription.setText(card.getDescription());
+		GridBagConstraints gbc_txaDesription = new GridBagConstraints();
+		gbc_txaDesription.insets = new Insets(0, 0, 5, 0);
+		gbc_txaDesription.fill = GridBagConstraints.BOTH;
+		gbc_txaDesription.gridx = 0;
+		gbc_txaDesription.gridy = 1;
+		panel.add(txaDesription, gbc_txaDesription);
+		
+		for (final Element element : card.getElements()) {
+			panelIngredients.add(new ElementPanel(element));
+			panelIngredients.add(new JSeparator());
+		}
 	}
 	
-	public void removeElements() {
-		panelIngredients.removeAll();
-	}
 }

+ 33 - 0
src/test/java/org/leumasjaffe/recipe/model/CollatedDurationTest.java

@@ -0,0 +1,33 @@
+package org.leumasjaffe.recipe.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.leumasjaffe.recipe.model.Duration.Display.*;
+
+import org.junit.jupiter.api.Test;
+
+class CollatedDurationTest {
+
+	@Test
+	void testAddZeroIsSelf() {
+		final CollatedDuration collate = new CollatedDuration(
+				new Duration(SECONDS, 0, 10), new Duration(SECONDS, 0, 20),
+				new Duration(SECONDS, 0, 60));
+		
+		assertEquals(collate, collate.plus(CollatedDuration.ZERO));
+		assertEquals(collate, CollatedDuration.ZERO.plus(collate));
+	}
+	
+	@Test
+	void testAddsElementsSeparately() {
+		final CollatedDuration collate = new CollatedDuration(
+				new Duration(SECONDS, 0, 10), new Duration(SECONDS, 0, 20),
+				new Duration(SECONDS, 0, 60));
+
+		final CollatedDuration expected = new CollatedDuration(
+				new Duration(SECONDS, 0, 20), new Duration(SECONDS, 0, 40),
+				new Duration(SECONDS, 0, 120));
+
+		assertEquals(expected, collate.plus(collate));
+	}
+
+}

+ 56 - 21
src/test/java/org/leumasjaffe/recipe/model/DurationTest.java

@@ -5,68 +5,103 @@ 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 static org.leumasjaffe.recipe.model.Duration.Display.*;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 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);
+	void testAddZeroIsSelf() {
+		final Duration dur = new Duration(SECONDS, 0, 10);
 		
-		assertEquals(Duration.Display.SECONDS, inSec.plus(inMin).getDisplayAs());
-		assertEquals(Duration.Display.SECONDS, inMin.plus(inSec).getDisplayAs());
+		assertEquals(dur, dur.plus(Duration.ZERO));
+		assertEquals(dur, Duration.ZERO.plus(dur));
 	}
 
 	@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);
+	void testPlusConvertsToLowestUnit() {
+		final Duration inSec = new Duration(SECONDS, 10, 20);
+		final Duration inMin = new Duration(MINUTES, 60, 120);
 		
-		assertTrue(inSec.plus(inMin).isApproximate());
-		assertTrue(inMin.plus(inSec).isApproximate());
+		assertEquals(SECONDS, inSec.plus(inMin).getDisplayAs());
+		assertEquals(SECONDS, inMin.plus(inSec).getDisplayAs());
 	}
 	
 	@Test
-	void testToStringApproxAddsTilde() {
-		final Duration inSec = new Duration(Duration.Display.SECONDS, true, 0, 0);
-		assertThat(inSec.toString(), startsWith("~"));
+	void testPlusCanUseHigherUnitOnLargeRanges() {
+		assertEquals(SECONDS, new Duration(SECONDS, 0, 120).plus(Duration.ZERO).getDisplayAs());
+		assertEquals(MINUTES, new Duration(SECONDS, 0, 121).plus(Duration.ZERO).getDisplayAs());
+		assertEquals(MINUTES, new Duration(SECONDS, 0, 3600).plus(Duration.ZERO).getDisplayAs());
+		assertEquals(HALF_HOURS, new Duration(SECONDS, 0, 3601).plus(Duration.ZERO).getDisplayAs());
+		assertEquals(HALF_HOURS, new Duration(SECONDS, 0, 14400).plus(Duration.ZERO).getDisplayAs());
+		assertEquals(HOURS, new Duration(SECONDS, 0, 14401).plus(Duration.ZERO).getDisplayAs());
 	}
 	
 	@Test
 	void testToStringNonApproxDoesNotHaveTilde() {
-		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 0, 0);
+		final Duration inSec = new Duration(SECONDS, 0, 0);
 		assertThat(inSec.toString(), not(containsString("~")));
 	}
 	
 	@Test
 	void testNonZeroMinProducesRange() {
-		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 10, 0);
+		final Duration inSec = new Duration(SECONDS, 10, 0);
 		assertThat(inSec.toString(), containsString("-"));
 	}
 	
 	@Test
 	void testZeroMinProducesSingleNumber() {
-		final Duration inSec = new Duration(Duration.Display.SECONDS, false, 0, 0);
+		final Duration inSec = new Duration(SECONDS, 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);
+		final Duration dur = new Duration(as, 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);
+		final Duration inSec = new Duration(SECONDS, 10, 20);
+		final Duration inMin = new Duration(MINUTES, 10, 20);
 		assertEquals("10 - 20 s", inSec.toString());
 		assertEquals("0 - 0 min", inMin.toString());
 	}
+	
+	@Test
+	void testPerformsRoundingOnHigherDisplay() {
+		assertEquals("0 min", new Duration(MINUTES, 0, 29).toString());
+		assertEquals("0 hr", new Duration(HOURS, 0, 1799).toString());
+		assertEquals("1 min", new Duration(MINUTES, 0, 30).toString());
+		assertEquals("1 hr", new Duration(HOURS, 0, 1800).toString());
+	}
+	
+	@Test
+	void testCanDisplayHalfHours() {
+		assertEquals("1 hr", new Duration(HALF_HOURS, 0, 3600).toString());
+		assertEquals("1 hr", new Duration(HOURS, 0, 3600).toString());
+
+		assertEquals("1.5 hr", new Duration(HALF_HOURS, 0, 5400).toString());
+		assertEquals("2 hr", new Duration(HOURS, 0, 5400).toString());
+	}
+	
+	@ParameterizedTest
+	@ValueSource(ints= {900, 2699})
+	void testHalfHourDisplayIsUsedForRoundNear(int value) {
+		assertEquals("0.5 hr", new Duration(HALF_HOURS, 0, value).toString());
+	}
+
+	@ParameterizedTest
+	@ValueSource(ints= {899, 2700})
+	void testHalfHourDisplayIsNotUsedForCloserToWhole(int value) {
+		assertNotEquals("0.5 hr", new Duration(HALF_HOURS, 0, value).toString());
+	}
+
 }

+ 2 - 2
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, false, 10, 20);
+	private static final Duration dur = new Duration(Duration.Display.SECONDS, 10, 20);
 
 	@Test
 	void cannotAddNullPreparation() {
@@ -32,7 +32,7 @@ class PhaseTest {
 		final Step step = new Step();
 		step.setDuration(dur);
 		phase.setCooking(Arrays.asList(step, step));
-		assertEquals(new Duration(Duration.Display.SECONDS, false, 20, 40),
+		assertEquals(new Duration(Duration.Display.SECONDS, 20, 40),
 				phase.getDuration());
 	}
 	

+ 2 - 0
src/test/java/org/leumasjaffe/recipe/view/ElementPanelTest.java

@@ -12,6 +12,7 @@ import org.junit.runner.RunWith;
 import org.leumasjaffe.mock.MockObserverListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Phase;
+import org.leumasjaffe.recipe.model.CollatedDuration;
 import org.leumasjaffe.recipe.model.Element;
 import org.mockito.Mock;
 import org.mockito.Spy;
@@ -29,6 +30,7 @@ class ElementPanelTest extends SwingTestCase {
 	@BeforeEach
 	void setUp() {
 		doReturn(Arrays.asList(stub)).when(stuff).getPhases();
+		doReturn(CollatedDuration.ZERO).when(stuff).getCollatedDuration();
 		panel = new ElementPanel(stuff);
 		
 		listener.setObserved(stuff);

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

@@ -29,7 +29,7 @@ class PreparationPanelTest extends SwingTestCase {
 	
 	@BeforeEach
 	void setUp() {
-		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		dur = new Duration(Duration.Display.SECONDS, 0, 30);
 		doReturn(dur).when(stuff).getDuration();
 		doReturn(Stream.of(
 				new Ingredient("Butter", "", new Amount("10 g")),

+ 0 - 39
src/test/java/org/leumasjaffe/recipe/view/RecipeCardPanelTest.java

@@ -1,39 +0,0 @@
-package org.leumasjaffe.recipe.view;
-
-import static org.mockito.Mockito.*;
-
-import java.util.Arrays;
-
-import javax.swing.JPanel;
-
-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.Element;
-import org.leumasjaffe.recipe.model.RecipeCard;
-import org.leumasjaffe.recipe.view.summary.SummaryPanel;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-@ExtendWith(MockitoExtension.class)
-@RunWith(JUnitPlatform.class)
-class RecipeCardPanelTest extends SwingTestCase {
-	
-	@Mock SummaryPanel summaryPanel;
-	@Mock JPanel rightPanel;
-	@InjectMocks RecipeCardPanel panel;
-	
-	@Test
-	void testAddsEachElementToEachMember() {
-		RecipeCard card = new RecipeCard();
-		card.setElements(Arrays.asList(new Element(), new Element()));
-		
-		panel.setModel(card);
-		
-		verify(summaryPanel, times(2)).addElement(any());
-		verify(rightPanel, times(2)).add(any(ElementPanel.class));
-	}
-
-}

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

@@ -23,7 +23,7 @@ class RestPanelTest extends SwingTestCase {
 	
 	@BeforeEach
 	void setUp() {
-		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		dur = new Duration(Duration.Display.SECONDS, 0, 30);
 		stuff = new Rest(Rest.Where.REFRIGERATOR, dur);
 		panel = new RestPanel(stuff);
 	}

+ 1 - 4
src/test/java/org/leumasjaffe/recipe/view/StepPanelTest.java

@@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.*;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
 import static org.hamcrest.number.OrderingComparison.greaterThanOrEqualTo;
-import static org.hamcrest.core.StringContains.containsString;
 import static org.mockito.Mockito.*;
 
 import java.util.Arrays;
@@ -33,7 +32,7 @@ class StepPanelTest extends SwingTestCase {
 
 	@BeforeEach
 	void setUp() {
-		dur = new Duration(Duration.Display.SECONDS, false, 0, 30);
+		dur = new Duration(Duration.Display.SECONDS, 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"))))
@@ -48,8 +47,6 @@ class StepPanelTest extends SwingTestCase {
 	@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(greaterThanOrEqualTo(1)));

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

@@ -12,7 +12,6 @@
           "preparation": {
             "duration": {
               "displayAs": "MINUTES",
-              "approximate": true,
               "minSeconds": 300,
               "maxSeconds": 600
             }
@@ -28,7 +27,6 @@
               ],
               "duration": {
                 "displayAs": "SECONDS",
-                "approximate": true,
                 "minSeconds": 30,
                 "maxSeconds": 60
               },
@@ -44,7 +42,6 @@
               ],
               "duration": {
                 "displayAs": "MINUTES",
-                "approximate": true,
                 "minSeconds": 300,
                 "maxSeconds": 900
               },
@@ -55,7 +52,6 @@
             "where": "ROOM_TEMPERATURE",
             "duration": {
               "displayAs": "SECONDS",
-              "approximate": false,
               "minSeconds": 0,
               "maxSeconds": 30
             }
@@ -76,7 +72,6 @@
               ],
               "duration": {
                 "displayAs": "SECONDS",
-                "approximate": true,
                 "minSeconds": 30,
                 "maxSeconds": 60
               },
@@ -92,7 +87,6 @@
               ],
               "duration": {
                 "displayAs": "MINUTES",
-                "approximate": true,
                 "minSeconds": 300,
                 "maxSeconds": 900
               },