Her ligger løsningsforslaget til konteeksamen 2020. I kombinasjon med kildekoden vil vi beskrive hva oppgaven spurt etter, samt ofte forekommende feil og misforståelser.

gitlab finner dere full kode til alle deler i oppgaven - lenker nedover i denne teksten refererer til filer her. Løsningsforslaget ligger inne som en egen gren kalt 'lf'.

Eksamen ble kortere enn vårens, så kandidatene fikk nok vist mer hva de kunne. Koden var mer oppdelt, så en ikke hadde mulighet til følgefeil. Eksempelvis ble fxml trukket ut til stuff.

Sensuren viser at for mange har kodet uten å bruke utviklingsverktøyet til noe fornuftig. Når jeg åpner koden til en klasse som bruker en ArrayList i koden, men ArrayList ikke engang er importert, da forteller det meg at koden er skrevet i blinde. Jeg forventer av studenter som har bestått øvingsopplegget på egenhånd at de skal kunne forholde seg til en utviklingsomgivelse - jeg kan ikke forstå at det går an å gjøre så mange øvinger i et verktøy og så gi det opp når det teller som mest. Her gikk det mye tid tapt for enkelte. (sad) Jeg vet også at det eksisterer en kokekultur. TDT4100 er et fag som det er umulig å beherske godt ved kriselesing i to uker. Det er et forståelsesfag, ikke et huskefag. 

Eksamensinformasjon

Eksamenen er delt i to, med flere oppgaver i hver del:

  • Del 1 inneholder separate oppgaver, som hver for seg tester ulik kunnskap i pensum. Del 1 er tenkt å dekke 40% av eksamen.
  • Del 2 inneholder en større programmeringsbit, med fire deloppgaver. Disse teller 60% av eksamen.

Hvis du ikke skulle klare å implementere en metode i en del kan du selvfølgelig bruke denne videre som om den virket (som i tidligere 'papireksamener').

Oppgavene har en tekstbeskrivelse, men denne er ikke alltid utfyllende. De mest utfyllende kravene til en metode står i dens javadoc-beskrivelse, altså en kommentar som står før metoden selv i kildekoden. Mange av metodene har fått lagt til 'dummy' returverdier - dette er gjort slik at kildekoden vil kompilere. Disse må naturlig nok endres for at metodene skal fungere som beskrevet.

I pakken stuff vil du finne en del mindre oppgaver. Disse henger ikke sammen. De tre deloppgavene gir 10-15%, totalt 40% av eksamen.

I stuff finner du skallet til to klasser, Movie og MovieRegister. Les klassebeskrivelsen under nøye.

Movie har følgende interne tilstand (her vil du ikke finne noe beskrivelse i javadocen):

  • tittelen til filmen. Denne kan ikke være null, og skal kunne hentes ut med metoden getTitle().
  • hvor mange ganger den er sett, skal kunne hentes ut med metoden getTimesWatched(). Økes med en hver gang man har sett filmen, oppdatert med metoden watch().
  • hvor god var filmen, heltall fra 1-6. Alle filmer trenger ikke ha en rating. Skal kunne hentes ut med metoden getRating()

Det er ikke nødvendig å implementere flere metoder enn de som trengs for fylle kravene gitt over. Main-metoden i Movie viser noen eksempler på bruk av tester mot passende kode.

MovieRegister inneholder en samling med Movie-objekter. Følgende metoder finnes:

  • addMovie(Movie movie): Mulighet til å legge til nytt Movie-objekt
  • filterMovies(Predicate pred): Returnere filmene som tilfredsstiller kravene beskrevet i predikatet pred.
  • findMovie(String title): Returnere filmen med denne tittelen, eller null hvis filmen ikke finnes.
  • watch(String title): Se filmen med denne tittelen. Øker antallet ganger filmen er sett med 1. Hvis tittelen ikke finnes i registeret skal metoden utløse en IllegalStateException.
Movie
package stuff;

import static org.junit.Assert.assertEquals;

public class Movie {

	// See the README file for a description of what is required for this file.
	private String title;
	private int timesWatched;
	private Integer rating;
	
	public Movie(String title) {
		if (title == null) throw new IllegalArgumentException("Title must not be null");
		this.title = title;
		this.timesWatched = 0;
		this.rating = null;
	}
	
	public String getTitle() {
		return title;
	}
	
	public int getTimesWatched() {
		return timesWatched;
	}
	
	public Integer getRating() {
		return rating;
	}
	
	public void setRating(Integer rating) {
		this.rating = rating;
	}
	
	public void watch() {
		timesWatched += 1;
	}

	public static void main(String[] args) {

//		Movie db = new Movie("Das Boot");
//		assertEquals(0, db.getTimesWatched());
//		assertEquals("Das Boot", db.getTitle());
//		
//		db.watch();
//		assertEquals(1, db.getTimesWatched());
//		
//		assertEquals(null, db.getRating());
//		db.setRating(4);
//		assertEquals(4, (int)db.getRating());
	}

}
MovieRegister
package stuff;

import java.util.ArrayList;
import java.util.Collection;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class MovieRegister {


	// Add internal variables
	private Collection<Movie> movies = new ArrayList<>();


	/**
	 * Add movie to register
	 * @param movie
	 */
	public void addMovie(Movie movie) {
		movies.add(movie);
	}

	/**
	 * 
	 * @param title
	 * @return the movie with matching title, or null if no such movie exists.
	 */
	Movie findMovie(String title) {
		return movies.stream().filter(m -> m.getTitle().equals(title)).findAny().orElse(null);
	}

	/**
	 * Filter all registered movies based on a Predicate, and return them as a Collection.
	 * @param pred is the filter for which movies to watch
	 * @return A collection of movies testing true to pred.
	 */
	Collection<Movie> filterMovies(Predicate<Movie> pred) {
		return movies.stream().filter(pred).collect(Collectors.toList());
	}

	/**
	 * Watch movie 'title'.
	 * @param title
	 * @throws IllegalStateException if the title does not exist.
	 */
	public void watch(String title) {
		try {
			findMovie(title).watch();
		} catch (NullPointerException e) {
			throw new IllegalStateException("Movie does not exsits!");
		}
	}

	/**
	 * Small example of use of the class. Does NOT necessarily cover all uses of methods specified in assignment. 
	 * @param args
	 */
	public static void main(String[] args) {

				MovieRegister cb = new MovieRegister();
				cb.addMovie(new Movie("Das Boot"));
				cb.watch("Das Boot");
				System.out.println("Should be 1: " + cb.findMovie("Das Boot").getTimesWatched());

	}

}


Her skulle kandidatene vise at de behersket basisbegrepene i Java og objektorientering. Det var et par ulike småting som kompliserte bildet. Movie skulle holde koll på tre ting: tittelen, rating og hvor mange ganger en har sett på den. Disse tre verdiene hadde ulike bruksmåter. Tittel MÅTTE man ha - så da var det lurt å bare ha en konstruktør som tok inn en tittel. Rating skulle være 1 - 6, men også null dersom man ikke hadde sett den. Her var det ulike løsninger, noen valgte strenger mens andre brukte Integer. Like bra, vi ga ingen krav her. Hvor mange ganger en hadde sett den ble løst ved en teller som starter med 0.

I MovieRegister la vi også inn predikat, i en slik typisk filtreringsmetode. Her var det helt greit å løse den med for-løkker, men som vanlig sparer en mye tid hvis en bruker Collection sin stream.

Klassen AverageComputer lar en registrere mange heltallsverdier og beregne gjennomsnittet av dem. Det følger med en testklasse, AverageComputerTest, som tester et enkelt tilfelle (beregning av snittet av tallene 3, 4 og 5).

Tilsynelatende virker klassen fint, men den inneholder to feil, den ene knyttet til beregning av gjennomsnitt, den andre til innkapslingen. Ingen av disse fanges opp av testklassen. Oppgaven går ut på å forbedre testklassen slik at begge feilene rapporteres på en hensiktsmessig måte. Du kan endre den eksisterende testmetoden og evt. legge til nye.

AverageComputerTest
package stuff;

import static org.junit.Assert.assertEquals;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.junit.Assert;
import org.junit.Test;

public class AverageComputerTest {

	private static final double epsilon = 1e-8;

	@Test
	public void testComputeAverage() {
		final AverageComputer ac = new AverageComputer(Arrays.asList(3, 4, 5));
		Assert.assertEquals(4.0, ac.computeAverage(), epsilon);
	}

	// We can manipulate the list by adding to it from outside after 
	// it has been added to the method.
	@Test
	public void testNewComputerNotModifiedByChangingList() {
		List<Integer> intVals = new ArrayList<>();
		intVals.addAll(Arrays.asList(2, 3, 1, 4));
		AverageComputer avg = new AverageComputer(intVals);

		assertEquals(2.5, avg.computeAverage(), epsilon);
		intVals.add(5);
		assertEquals(2.5, avg.computeAverage(), epsilon);
	}

	// No cast from integer, thereby messing up those numbers...
	@Test
	public void testRetValIsCastBeforeDivide() {
		AverageComputer ac = new AverageComputer(Arrays.asList(3, 4));

		assertEquals(3.5, ac.computeAverage(), epsilon);
	}
}

Her var det i utgangspunktet to ulike feil vi var ute etter:

  • Mangel på innkapsling: Man kunne sende inn en liste, og så manipulere denne fra utsiden etterpå.
  • Mangel på cast til double ved beregning av gjennomsnitt: En typisk feil, da ulike programmeringsspråk gjør dette litt ulikt. I Java må du caste for å ikke få et avrundet svar.

I den grad det ble definert andre typer feil, så kunne det også gi poeng.

Filen Math.fxml definerer et grensesnitt der brukeren skal kunne:

  • skrive inn to flyttall i to tekstfelt
  • velge en regningsmetode fra en nedtrekksmeny (pluss, minus, gange, dele)
  • trykke på en knapp som så beregner resultatet av regnestykket og skriver svaret inn i et felt
  • trykke på en annen knapp som legger inn tilfeldige heltall mellom 1 og 100 i begge tekstfeltene

Du skal gjøre utvidelser og endringer i filen MathController.java som gjør at kravene nevnt over oppfylles. Er alle metoder som må finnes allerede definert? Grensesnittet kan sees ved å kjøre filen MathApp.java.

Slik ser FXML-applikasjonen ut:




MathController
package stuff;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Random;

import javafx.fxml.FXML;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

public class MathController {
	
	Random rand;

	@FXML
	private ComboBox<Character> typeSelector;

	@FXML
	private TextField firstField;

	@FXML
	private TextField secondField;
	
	@FXML
	private TextArea resultArea;

	@FXML
	private void initialize() {
		rand = new Random();
		Collection<Character> tmp = new ArrayList<>();
		tmp.add('+');
		tmp.add('*');
		tmp.add('/');
		tmp.add('-');
		typeSelector.getItems().addAll(tmp);
		typeSelector.getSelectionModel().select(0);
	}


	/**
	 * Gather doubles from two textfields, apply a mathematical method, and update a text component.
	 */
	@FXML
	private void onCalculate() {
		double res;
		double val1 = Double.parseDouble(firstField.getText());
		double val2 = Double.parseDouble(secondField.getText());
		switch (typeSelector.getValue()) { // Could use chained if-elseif-else instead of switch
			case '+':
				res = val1 + val2;
				break;
			case '-':
				res = val1 - val2;
				break;
			case '*':
				res = val1 * val2;
				break;
			case '/':
				res = val1 / val2;
				break;
			default:
				throw new IllegalArgumentException("Unexpected value: " + typeSelector.getValue());
		}
		
		resultArea.setText(Double.toString(res));
	}

	
	// Is there a method missing here? Where could it be?
	@FXML
	private void randomizeNumbers() {
		firstField.setText(Integer.toString(rand.nextInt(100) + 1));
		secondField.setText(Integer.toString(rand.nextInt(100) + 1));
	}
}

Her var en god del ferdig laget. En detalj var at det var et hint om at noe manglet i MathController - en måtte gå inn i fxml-filen, se at det manglet en metode for randomizeNumbers. Så den måtte lages, og inni der måtte en altså legge to tilfeldige heltall inn i firstField og secondField. Selve oppskriften på tilfeldig tall burde være grei (i en åpen bok-eksamen, spesielt!). Når det gjelder onCalculate, så var det mange måter å filtrere hvordan en skal håndtere regneartene. Alle var like gode, så lenge de faktisk virket. Men en måtte altså huske på å caste verdiene fra firstField og secondField før en bruker dem.

Le Petite Chef goes takeaway

Restauranten Le Petite Chef har tilpasset seg de nye tidene. De lager matretter som så selges for å nytes hjemme. På grunn av stadige tilbud for å lokke til seg kunder har de gått over til variable priser på matrettene. Til de som tok eksamen i vår: Det er ingen kodemessig kobling mellom de to eksamenene, annet enn i navnet.

Dette dokumentet beskriver klassene i korte trekk. Vi refererer til dokumentasjonen av metoder og klasser i selve koden for de virkelige kravene om spesifikke metoder etc.

Viktig: i pakken food vil dere finne en underpakke food.def. Her ligger det noen grensesnitt som du bare skal bruke, men ikke endre på. Du vil for eksempel se at klassene Customer og Kitchen implementerer grensesnitt som ligger her - dette påvirker ikke deres løsning av oppgavene 2.1 og 2.2. Du vil også finne grensesnittet PriceProvider som skal brukes i oppgave 2.3.

Også viktig: Oppgave 2.4 - observatør-observert omhandler delvis å implementere en klasse ObserveToPrintTopRevenue. Denne klassen kan implementeres uten å ha løst noen av oppgavene 2.1 - 2.3, eller å ha gjort selve observatør-inngrepene i Kitchen.

  • MealOrder: Denne klassen inneholder informasjon om et salg. Den lagrer navnet på retten, og hvilken pris retten ble solgt for. Disse verdiene må kunne hentes ut med metodene String getMeal() og double getPrice().

  • Customer: En som kjøper retter. Kunden har et navn, en oversikt over hvilke retter denne har kjøpt og til hvilken pris. Se javadoc i kildekoden for de definerte kravene.

package food;

/**
 * A wrapper class for keeping track of a bought meal.
 *
 * The class needs to store the name of meal, as well as the price
 * it was sold for. And a way of providing these to external users.
 * 
 */
public class MealOrder {
	
	// Consult the README for a description of requirements.
	
	private String meal;
	private double price;
	
	public MealOrder(String meal, double price) {
		this.meal = meal;
		this.price = price;
	}
	
	public String getMeal() {
		return meal;
	}
	
	public double getPrice() {
		return price;
	}
	
}

MealOrder var en ren hjelpeklasse, for å kunne holde styr på hvor mye ett spesifikt måltid ble kjøpt for. Grunnen til dette er jo, som vi skal se senere, at prisen kan variere avhengig av hvilke rabatter som utløses. Ikke veldig mye spennende eller utfordrende med klassen. Det som derimot var utfordrende var at klassen ikke inneholdt noe som helst. En måtte altså kunne lese seg til i oppgaven hva klassen skulle gjøre, og hvilke metoder som skulle implementeres.

package food;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import food.def.ICustomer;

public class Customer implements ICustomer {


	// Add internal variables here:
	private String name;
	private List<MealOrder> orders; 
	
	
	/**
	 * Create a new customer
	 * 
	 * @param name The name of the customer
	 */
	public Customer(String name) {
		orders = new ArrayList<>();
		this.name = name;
	}
	
	
	/**
	 * 
	 * @return A list containing all meals bought by this customer
	 */
	public Collection<MealOrder> getMealsOrdered() {
		return new ArrayList<>(orders); 
	}

	/**
	 * Add a bought meal to this customer
	 * 
	 * @param meal The name of the meal
	 * @param price The price the customer paid for the meal
	 */
	@Override
	public void buyMeal(String meal, double price) {
		orders.add(new MealOrder(meal, price));
	}
	
	
	/**
	 * @return The number of meals ordered by this customer
	 */
	@Override
	public int getNumberOfOrderedMeals() {
		return orders.size();
	}
	
	/**
	 * @return The name of this customer
	 */
	@Override
	public String getName() {
		return name;
	}

	/**
	 * @return A String on the form "<name>: <number of meals ordered>"
	 */
	@Override
	public String toString() {
		return name + ": " + Integer.toString(getNumberOfOrderedMeals());
	}

	/**
	 * @return The most recent meal bought by this customer
	 * If no meal is ordered, return null.
	 */
	@Override
	public MealOrder getLastOrderedMeal() {
		if (getNumberOfOrderedMeals() == 0) {
			return null;
		}
		return orders.get(orders.size() - 1);
	}
	
	/**
	 * Get the number of times the customer has eaten the given meal
	 * 
	 * @param meal The name of the meal
	 * 
	 * @return The number of times this customer has eaten the given meal
	 */
	@Override
	public int timesEaten(String meal) {
		return (int)(orders.stream().filter(m -> m.getMeal().equals(meal)).count()); // Can easily be done by a for-loop as well
	}
	
	public static void main(String[] args) {
		Customer customer = new Customer("Frank");
		customer.buyMeal("pancakes", 100);
		customer.buyMeal("pancakes", 75);
		System.out.println("Skal være 2 kjøp: " + customer.getMealsOrdered().size());
		System.out.println("Skal være pris 75: " + customer.getLastOrderedMeal().getPrice()); // Som definert i README.
	}
}

Customer skulle kjenne til hva hen hadde kjøpt, til hvilken pris. Basert på denne informasjonen skal en kunne finne ut de resterende tingene, som hva en kjøpte sist, hvor mange av en bestemt rett og så videre. Her var valget av implementasjon viktig. En liste med MealOrder kunne forsvares med tanke på at en skulle kjenne til den siste, men det er jo mulig å gjøre det med en Collection også. Her var det mulig å bruke noen Collection.stream() om en ønsket. Main-metoden er her for å vise viss bruk av klassen, men ikke nødvendigvis alt.

 

Kjøkket har som nevnt tidligere forenklet oppskriftene siden sist. Kitchen-objekt har følgende tilstand:

  • En samling av oppskrifter. Disse vil innimellom bli referert til som matretter.
  • En samling av kunder (fra oppgave 2.1).
  • Omsetningen så langt - det betyr summen av alle salg som er gjort til nå.

Følgende metoder eksisterer i Kitchen:

  • provideMeal(String meal, double price, String name): Kunden name skal registrere kjøp av rett meal til pris price.
  • addCustomer(Customer customer): Legge til en ny kunde.
  • addRecipe(String recipe): Legge til en ny oppskrift.
  • getRecipes(): Returnere en kopi av oppskriftene som er lagret.
  • getTurnover(): Returnere omsetningen så langt.
  • getCustomer(String name): Returnere kunden med navnet name, eller null hvis kunden ikke finnes i systemet.
Kitchen
package food;

import java.util.ArrayList;
import java.util.Collection;

import food.def.IKitchen;
import food.def.KitchenObserver;
import food.def.PriceProvider;

// Important: There is no similarity between Kitchen in the exam v2020 and this one.
public class Kitchen implements IKitchen {

	// Add internal variables here:
	private Collection<String> recipes;
	private Collection<Customer> customers;
	
	private Collection<PriceProvider> priceProviders;
	private Collection<KitchenObserver> observers;

	private double turnover;
	
	public Kitchen() {
		super();
		recipes = new ArrayList<>();
		customers = new ArrayList<>();
		priceProviders = new ArrayList<>();
		observers = new ArrayList<>();
		turnover = 0;
	}
	
	
	/**
	 * Add a customer
	 * @param customer The customer to add
	 * 
	 * @throws IllegalArgumentException if the customer is already registered
	 */
	@Override
	public void addCustomer(Customer customer) {
		if (customers.contains(customer)) throw new IllegalArgumentException("Customer already registered");
		customers.add(customer);
	}

	/**
	 * Add a recipe
	 * @param recipe The recipe to add
	 */
	@Override
	public void addRecipe(String recipe) {
		recipes.add(recipe);
	}
	
	/**
	 * @return The turnover of this kitchen - price of all sold meals added together
	 * If the restaurant has sold for 50, 75 and 100, the turnover is 225.
	 * (Norsk: omsetning)
	 */
	@Override
	public double getTurnover() {
		return turnover;
	}

	
	/**
	 * @return A collection of this kitchen's recipes
	 */
	@Override
	public Collection<String> getRecipes() {
		return new ArrayList<>(recipes);
	}
	
	/**
	 * @param name The name of the customer to get
	 * 
	 * @return The customer with the given name, or null if no such customer is registered
	 */
	public Customer getCustomer(String name) {
		return customers.stream()
				.filter(c -> c.getName().equals(name))
				.findAny()
				.orElse(null);
	} // A nice exercise would be to make this with a for loop instead.

	/**
	 * Make a meal, with a given (standard)price and to a given customer.
	 * 
	 * This method needs to check that the kitchen knows the given recipe
	 * and has the given customer registered.
	 * (Task 2.3): rebates need to be considered
	 * Finally, data about the sale must be registered in all appropriate places.
	 * 
	 * @param meal The name of the meal to make
	 * @param price The standard price of the meal
	 * @param customerName The name of the customer that buys the meal
	 * 
	 * @throws IllegalStateException if a meal is not successfully made (somehow)
	 */
	@Override
	public void provideMeal(String meal, double price, String customerName) {
		Customer customer = getCustomer(customerName);
		if (customer == null || !recipes.contains(meal)) throw new IllegalStateException("Couldn't make meal!");
		
		price = computeActualPrice(meal, price, customer); // Added for task 2.3
		turnover += price; // updating the turnover, ya'know.
		customer.buyMeal(meal, price);
		
		for (var obs : observers) {
			obs.mealOrder(meal, price); // For task 2.4: inform all observers!
		}
}
		
	/**
	 * Exercise 2.3 - Delegation
	 * Calculate the total rebate of the given meal, using the PriceProviders (said 
	 * priceDelegates in the exam, fixed afterwards) of this Kitchen
	 * If more than one rebate exist, each of them applies. See README for example.
	 * 
	 * @param meal The name of the meal
	 * @param price The standard price of the meal
	 * @param customer The customer buying the meal
	 * @return The resulting price after all rebates have been considered.
	 */
	double computeActualPrice(String meal, double price, Customer customer) {
		double rebate = 1;
		for (PriceProvider priceProvider : this.priceProviders) {
			rebate = rebate * priceProvider.providePrice(meal, price, customer);
		}
		return rebate * price;
	}
	
	// Exercise 2.3 - Delegation - these may not be all methods you need to create!
	@Override
	public void addPriceProvider(PriceProvider pp) {
		priceProviders.add(pp);
	}

	public void removePriceProvider(PriceProvider pp) {
		priceProviders.remove(pp);
	}

	// Exercise 2.4 - Observerer - these may not be all methods you need to create!
	@Override
	public void addObserver(KitchenObserver ko) {
		observers.add(ko);
	}

	
	public static void main(String[] args) {
		Kitchen k = new Kitchen();
		k.addRecipe("pancakes");
		k.addRecipe("waffles");
		k.addRecipe("taco");
		k.addRecipe("spam");
		Customer per = new Customer("per");
		k.addCustomer(per);
//		k.addCustomer(per); // IllegalArgumentException
		k.addCustomer(new Customer("ida"));
		k.provideMeal("pancakes", 99.50, "per");
		System.out.println(k.getTurnover());
		k.provideMeal("pancakes", 50, "ida");
		System.out.println(k.getTurnover());
	}
}

Kitchen var den største av klassene, og den som ble jobbet med over flere oppgaver. Main-metoden viste mulig, men ikke fulstendig, oppførsel. I oppgave 2.2 var den største utfordringen hvordan en skule holde rede på kunder, matretter, og salget av dem. I tillegg skulle Kitchen holde rede på omsetningen totalt. I LF er denne en double som oppdateres hver gang. I ettertid har jeg innsett at dette er 'premature optimization'. Dette betyr at en mye heller burde regne ut omsentning basert på alle registrerte kunder sine kjøp (de kjenner dem, sant!). Og så får en heller skrive det om hvis det viser seg at det blir en flaskehals. Så lær dette: gjør det enkelt i starten!

Ellers hadde oppgaven spor av slike 'les nøye-ting'... standard sjekker for om en kunde eller matrett faktisk finnes før en forsøker å selge den etc... Kan også nevne at dersom dere droppet innkapsling generelt, så ble det ikke trukket hele tiden. getRecipes() her er en typisk slik kandidat, der dere bør VITE at en skal levere ut en kopi. Slikt skal en slippe å si til dere nå. (smile)

For å finne nye kunder har Le Petite Chef begynt med et avansert rabattsystem. Det finnes ulike typer rabatter, som gir prosentavslag på prisen. Tanken er at rabatter kan utløses av mange faktorer, basert på elementene ved salget: pris, kunde og oppskrift. De ulike rabattene teller sammen på følgende måte:

  • Gitt at et produkt egentlig skal selges for 100 kroner.
  • Rabatt1 gir 20% avslag. Prisen blir da 100 * 0.8 = 80 kroner.
  • Rabatt2 gir 50% avslag. Sluttprisen blir da 80 * 0.5 = 40 kroner. Legg merke til at rabatt2 tar utgangspunkt i prisen fra rabatt1. De virker altså oppå hverandre.

Du skal lage følgende:

  • Rabattklassene skal implementere grensesnittet PriceProvider som du vil finne i pakken food.def. Denne skal ikke endres.
  • Rabattklassen RebateEveryFifthBuyFromSameCustomer gir 50% rabatt på hvert femte kjøp, men med rabatt på første kjøp (lokkemiddel). Det betyr at kunden skal få rabatt på kjøp nummer 0, 5, 10, 15 osv.
  • Rabattklassen RebateFreeEveryThousandSale skal gi full rabatt hvert tusende salg, uavhengig av hvilken kunde som har kjøpt rettene. Den skal ikke gi rabatt til første salg, men altså for salg 1000, 2000 og så videre.
  • Kitchen skal nå kunne legge til og fjerne et vilkårlig antall rabatter.
  • Kitchen.computeActualPrice(String meal, double price, Customer customer): Denne metoden skal gå igjennom alle mulige rabatter som finnes, og returnere faktisk sluttpris. En viktig faktor er at hvis et salg utløser to rabatter, en med 20% avslag og en med 50% avslag, så ganges disse faktorene med hverandre og metoden returnerer 0.4 * innpris. Eksempel: En matrett til 200 kroner som har to rabatter på 30% og 50% vil ende opp med en sluttpris på 70 kroner. Legg merke til at denne metoden er en hjelpemetode for provideMeal.
  • Gjør de nødvendige endringene som trengs i Kitchen for at vurdering av alle rabatter tas med i registrering av salg.

Endringene i Kitchen (du vil finne koden til dette i forrige deloppgave) som måtte til for å støtte dette var å finne en passende samling for delegatene (en Collection virker passende), og metoder for å legge til og fjerne disse. Til slutt måtte en bruke delegatene inne i provideMeal - hjertet av Kitchenklassen. Dette ble løst litt ulikt. Oppgavene spesifiserte at en delegat skulle ta inn (blant annet) en pris, og så returnere et tall mellom 0 og 1 der 0 var 100% rabatt og 1 var ingen rabatt. Noen endte opp med å gjøre slik at delegatet i stedet returnerte den nye prisen, så et kjøp på 100 kroner ved 20% rabatt ga retur 80.

RebateEveryFifthBuyFromSameCustomer
package food;

import food.def.PriceProvider;

/**
 * A rebate where a customer will get 50% off on every fifth purchase - 
 * Note: you get a rebate on the first buy, so in essence you end up with rebates on the
 * 0th, 5th, 10th, 15th etc buys if you start with 0.
 */
public class RebateEveryFifthBuyFromSameCustomer implements PriceProvider {

	/**
	 * Every fifth time a customer buys a meal, including the first time, 
	 * its price should be reduced to half, return 0.5
	 * If not, it should return 1 (no rebate)
	 */
	@Override
	public double providePrice(String meal, double price, Customer customer) {
        if(customer.getMealsOrdered().size()%5==0 || customer.getMealsOrdered().size()==0) {
            return 0.5;
        }
        return 1.0;
	}
}

RebateEveryFifthBuyFromSameCustomer var laget for at kandidaten kan vise hvordan man ved delegering kan hente ut informasjon fra klassen som sendes ved som parameter. Det er her altså ikke nødvendig å ha noen intern tilstand å holde rede på. En liten detalj var kravene for reduksjon i pris: hver femte gang, men også den første gangen. Det stod meget tydelig forklart i javadocen til klassen. (Obs: i LF på gitlab står det faktisk feil løsning inntil videre. Sensorene var derimot klar over forskjellen. Ikke at de trakk enormt for dette.)

RebateFreeEveryThousandSale
package food;

import food.def.PriceProvider;

/**
 * A rebate where every thousandth purchase (regardless of meal, price, or customer)
 * is given away for free. Not the first buy!
 */
public class RebateFreeEveryThousandSale implements PriceProvider {
	
	// Need an internal counter. We don't need Kithchen to keep track of number
	// sales, since providePrice is called once every sale!
	int counter;
	
	@Override
	public double providePrice(String meal, double price, Customer customer) {

		
		if (counter++ != 0 && counter % 1000 == 0) {
			return 0;
		}
		return 1;

		
		//		return counter++ > 0 && (counter % 1000 == 0) ? 0.0 : 1.0; // This is rather ugly, should perhaps be done on several lines for legibility...
	}
}

I motsetning til den andre delegaten så trenger man i RebateEveryThousandSale å holde rede på en intern tilstand - hvor mange måltider har restauranten solgt totalt. Nå var det en kandidat som påpekte at den ikke egentlig kunne holde rede på dette, siden restauranten kunne ha solgt måltider før delegaten ble lagt til. Dette er en god kommentar. Jeg vil allikevel si at hvis en kommer til en slik problemstilling, da er det nok lurest å skrive en linje om at en velger å forholde seg til at delegaten legges til med en gang. Og det viser hvor utrolig mange måter det er å tolke oppgaver på, og dermed også hvor krevende det er å lage en eksamen som er perfekt på alle måter!

Når en først har skjønt hva en skal gjøre her, da er selve koden ganske så enkel. Poenget var altså å se om dere hadde forstått at delegater også kan ha interne tilstander.

Nå skal Kitchen utvides til å støtte lyttere:

  • Lyttere må implementere grensesnittet KitchenObserver som du finner i pakken food.def. Dette grensesnittet skal ikke endres!
  • Kitchen skal kunne legge til og fjerne lyttere som følger grensesnittet KitchenObserver, som ligger i pakken 'food.def'.
  • Ved et salg i Kitchen skal alle lytterne kalles med metoden MealOrder(meal, price). Det er altså kun matrett og faktisk pris etter eventuelle rabatter som skal sendes videre til lytterne.

Du skal også implementere en lytter: klassen ObserveToPrintTopRevenue, med metodene mealOrder(String meal, double price) og getTopSellers(). Denne klassen skal skrive ut detaljer om den eller de matrettene som har solgt for høyest totalsum (omsetning) hver gang det skjer et nytt salg.

Viktig: Det er ikke nødvendig å ha klart Kitchen eller Recipe-oppgavene for å gjøre denne siste oppgaven - den forholder seg kun til matretter/oppskrifter som strenger og priser som double.

---

Som oppgaven med delegering er denne delvis løst med litt kode i Kitchen, og litt utenfor. I Kitchen skulle en holde styr på observatører, og kalle metoden gitt i grensesnittet når det passet. Det passet i metoden provideMeal. Koden for dette kan sees i 2.2 over.

ObserveToPrintTopRevenue
package food;

import static org.junit.Assert.assertEquals;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import food.def.KitchenObserver;

/**
 * The main point of this implementation of KitchenObserver is to keep track of revenue
 * from all meals sold. Every time a new meal is sold (and mealOrder is called), 
 * information about the meal(s) with the highest revenue is printed to System.out.
 *
 */
public class ObserveToPrintTopRevenue implements KitchenObserver {

	// Internal variables go here:
	Map<String, Double> meals = new HashMap<>();

	
	/**
	 * 
	 * @return A string that contains the string 'meal: price' for all meals that have the highest revenue, 
	 * separated by a newline. If more than one meal they are sorted in alphabetical order.
	 * If no meal has been sold: returns an empty string.
	 */
	public String getTopSellers() {
		if (meals.size() == 0) return "";
		List<String> highestMeals = new ArrayList<>();
		double highestPrice = 0.0;
		
		for (var entry : meals.entrySet()) {
			if (entry.getValue() > highestPrice) {
				highestMeals.clear();
				highestMeals.add(entry.getKey());
				highestPrice = entry.getValue();
			} else if (entry.getValue() == highestPrice) {
				highestMeals.add(entry.getKey());
			}
		}
		Collections.sort(highestMeals);
		
		StringBuilder sb = new StringBuilder();
		for (String meal : highestMeals) {
			sb.append(meal);
			sb.append(": ");
			sb.append(highestPrice);
			sb.append("\n");
		}
		return sb.substring(0, sb.length() - 1).toString();
	}
	
	/**
	 * When triggered, updates the revenue of 'meal' with 'price'.
	 * Should then print (System.out) the meal(s) with highest revenue (in alphabetical order), see the method 
	 * getTopSellers.
	 */
	@Override
	public void mealOrder(String meal, double price) {
		meals.put(meal, meals.getOrDefault(meal, 0.0) + price);
		System.out.println(getTopSellers());
	}
	

	// A basic use of the class. No need to use Kitchen to make it work. 
	public static void main(String[] args) {
		ObserveToPrintTopRevenue test = new ObserveToPrintTopRevenue();
		System.out.println("> Only waffles: 50.0");
		test.mealOrder("waffles", 50.0);
		assertEquals("waffles: 50.0", test.getTopSellers().trim());
		System.out.println("> Only waffles: 100.0");
		test.mealOrder("waffles", 50.0);
		System.out.println("> Pancakes and waffles (two lines): 100.0");
		test.mealOrder("pancakes", 100.0);
		System.out.println("> Only waffles: 150.0");
		test.mealOrder("waffles", 50.0);
		
		
	}
}

Det fantes to metoder som skulle implementeres her: metoden fra grensesnittet, mealOrder, og så getTopSellers. I mealOrder var det ikke så mye annet å gjøre enn å lagre det nye salget inn i en passende struktur. Siden målet var å holde rede på alle matretter og hvor mye total omsetning var for hver av dem, da var en Map en passende kandidat. I tillegg måtte mealOrder kalle getTopSellers og skrive ut resultatet! I getTopSellers skulle en finne ut hvilke(n) matrett(er) som hadde solgt for mest, og returnere denne. Her var det åpent hvordan en løste det internt, men i LF brukte vi en liste for å sikre at flere 'vinnere' kan lagres. Listen nulles ut hver gang en matrett med høyere omsetning finnes. Etter dette var det litt standard formatering av streng.