Schritt-für-Schritt - Implementierung und Test

Vorbemerkungen

Dieser Abschnitt des Tutorials befasst sich mit der Realisierung der Ergebnisse, die in der Entwurfsphase erzielt wurden, wobei nur ein Teil der Implementation in dieser Form dokumentiert wird, um den Zeitaufwand der Studierenden nicht überzustrapazieren. Dennoch soll er einen grundsätzlichen Einblick in die Vorgehensweise geben und den Einstieg beim Programmieren mit dem Framework SalesPoint erleichtern. Es geht in erster Linie darum, den schrittweisen Aufbau einer SalesPoint-Applikation nachzuvollziehen und Strategien, die sich im Laufe der Implementation einer solchen Anwendung ständig wiederholen, zu begreifen. Es wird sich auf die Implementation des Kundeneinkaufs und die dafür notwendigen Voraussetzungen beschränkt. Daraus resultierende Unterschiede zwischen dem hier vorgestellten Teil und dem gesamten Programm sollten berücksichtigt werden.

Ein wichtiger Bestandteil der Implementierungsphase ist das Testen des entstandenen Codes. Der Abschnitt über das Testen steht zwar am Ende dieses Kapitels, es soll an der Stelle aber eindringlich darauf hingewiesen werden, dass das Testen stets eine die Implementierung begleitende Aktivität sein muss! Das Motto beim Programmieren lautet also: testen, testen, testen...

Hinweis:
Um die Übersicht zu bewahren und den Leser nicht durch ellenlange Quellcodes abzuschrecken, taucht in den Code-Beispielen gelegentlich ein Ersetzungszeichen (...) auf, welches signalisieren soll, dass an dieser Stelle bereits zuvor erläuterter Quellcode stehen sollte. Auf import-Anweisungen wurde aus demselben Grund ganz verzichtet, es wird hiermit noch einmal darauf verwiesen, dass Klassen aus anderen Packages durch import-Anweisung eingebunden werden müssen, so auch die Klassen des Frameworks.

Konventionen:
Da die vollständige Version des Programms, obwohl hier nicht bis zum Ende erläutert, als Referenz für das Implementieren mit SalesPoint nützlich sein soll, wurde versucht eine Hilfe zum Auffinden von ähnlichen Problemfeldern zu schaffen. Erreicht werden soll dies, indem alle die Klassen, welche direkt oder transitiv von SalesPoint-Klassen abgeleitet wurden, den Namen der jeweiligen Elternklasse als Kürzel im eigenen Klassennamen tragen.
Ein Beispiel: eine neue Klasse, die von CatalogItem abgeleitet ist und die Artikel des Marktes repräsentiert, lautet: CIArticle. Ähnlich wurden Variablen und Konstanten benannt. Diese Codekonvention soll es ermöglichen, bei Problemen mit dem Framework zunächst nach einer entsprechenden Klasse in der Großmarkt-Anwendung suchen zu können und dort ggf. eine Lösung zu finden.


Implementation des Shops

Das zentrale Element einer jeden SalesPoint-Applikation stellt die Klasse Shop dar. Jede Anwendung, die auf dem Framework basiert, braucht eine (und zwar ausschließlich eine) Instanz der Klasse Shop, welche über die statischen Methoden setTheShop(Shop s) und getTheShop() der Klasse Shop gesetzt und wiedergegeben werden kann. Diese eine Instanz verwaltet sämtliche die Anwendung betreffenden Datenbestände, wie Kataloge (Catalog) und Bestände (Stock), alle SalesPoints und führt das Speichern und Laden von Spielständen durch. Aufgrund der fundamentalen Bedeutung dieser Klasse wird mit der Implementation derselben begonnen. Dafür wird eine neue Klasse namens SMarket deklariert, von Shop abgeleitet und ein Konstruktor eingefügt, in welchem die neu erzeugte Instanz zum zentralen Shop erklärt und außerdem die Größe und die Titelleiste des Shopfensters angepasst wird.

public class SMarket extends Shop{
   // ID für Serialisierung
   private static final long serialVersionUID = 8972722234332831551L;

   public SMarket() {
	super();
        // Die neue Instanz von SMarket wird als der neue Shop gesetzt.
        setTheShop(this);
        // Die Größe des Shopfensters wird gesetzt.
        setShopFrameBounds(new Rectangle(50,50,400,300));
        // Die Titelleiste des Shopfensters wird gesetzt.
        setShopFrameTitle("Großmarkt Sohn & Sohn");
   }
}
        

Um den neuen SMarket austesten zu können, wird eine neue Klasse MainClass mit der main-Methode der Anwendung implementiert, natürlich könnte man die main-Methode auch in SMarket deklarieren:

public class MainClass {

   public static void main(String[] args){
        SMarket market = new SMarket();
        // Der Shop wird gestartet.
        market.start();
   }
}
        

Da man möglicherweise nicht jedesmal, wenn der SMarket geschlossen wird, den Stand der Anwendung speichern will, wird die geerbte Methode quit() in SMarket, die in ihrer Standardimplementation das Speichern vorschreibt, wie folgt überschrieben:

public class SMarket extends Shop{
...
   public void quit(){
        /**
        * Durch Übergabe von false wird signalisiert, dass der derzeitige Zustand
        * des SMarket nicht persistent gemacht werden soll. Gibt shutdown true zurück,
	* so war das Schließen erfolgreich.
        */
        if(shutdown(false)){
		System.exit(0);
        }
   }
}
        

Nun wird der SMarket ohne Nachfragen geschlossen. Um das Erscheinungsbild des Markts zu vervollständigen, muss das Menü des Shopfensters erweitert werden. Hierfür definiert man ein neues MenuSheet in der Methode createShopMenuSheet() von SMarket:

public class SMarket extends Shop{
...
   public MenuSheet createShopMenuSheet(){
	MenuSheet msMenuBar = new MenuSheet("Menubar");
        // Das MenuSheet: Shop wird samt Funktionalität beibehalten.
        MenuSheet msShop = (MenuSheet)super.createShopMenuSheet().getTaggedItem(
        	Shop.SHOP_MENU_TAG, false);
        // Das neue MenuSheet, über welches sich die verschieden Nutzer anmelden können.
        MenuSheet msLogOn = new MenuSheet("Anmeldung");

        // Die Funktionalität des MenuSheetItems wird später hinzugefügt.
        MenuSheetItem msiCustomer = new MenuSheetItem("Kunde", null);
        msLogOn.add(msiCustomer);

        msMenuBar.add(msShop);
        msMenuBar.add(msLogOn);
        return msMenuBar;
   }
}
        

Das Erscheinungsbild des Marktes ist vorerst vollständig. Um die Bearbeitung des SMarket abzuschließen, ist es jedoch dringend erforderlich im Konstruktor ein globales Logfile zu definieren, in das Informationen geschrieben werden können. Auf diese Datei wird von verschiedenen Klassen des Packages log des Frameworks zugegriffen, ist keine Datei gesetzt so kommt es zu Fehlermeldungen:

public class SMarket extends Shop{
...
   public SMarket() {
        super();
        Log.setGlobalLogFile("marketlog.txt", true, false);
        setTheShop(this);
        setShopFrameBounds(new Rectangle(50,50,400,300));
        setShopFrameTitle("Großmarkt Sohn & Sohn");
   }
...
}
        

Die Bearbeitung von SMarket ist damit abgeschlossen, im nächsten Schritt wird mit der Implementation des Nutzermanagements begonnen.


Nutzerverwaltung

Wie aus der Entwurfsphase bekannt, existieren im Großmarkt zwei grundsätzliche Typen von Nutzern, nämlich Angestellte und Kunden, wobei sich diese in einigen Attributen wiederum gleichen. Da in diesem Tutorial nur der Einkauf des Kunden behandelt werden soll, wird sich auf die Implementation desselben beschränkt. Dennoch erscheint es sinnvoll die verbindenden Attribute von Kunden und Angestellten zunächst in einer eigenen Klasse zu implementieren:

public class UPerson extends User{

   // ID für Serialisierung
   private static final long serialVersionUID = -6949039852904696724L;

   private String salutation;
   private String name;
   private String firstName;
   private String street;
   private int postcode;
   private String city;
   private String telephone;
}
        

Außerdem benötigt man noch einen Konstruktor, sowie get- und set-Methoden für die einzelnen Attribute. Hierbei gilt es vorsichtig zu sein: so darf die Methode getName() nicht aus Versehen überschrieben werden, will man sich beispielsweise den Familiennamen zurückgeben lassen. Der Übersichtlichkeit halber hier nur ein Ausschnitt, mit der Telefonnummer als einzigem Attribut:

public class UPerson extends User{
   ...
   private String telephone;
   ...

   public UPerson(String sKey) {
        super(sKey);
   }
   public String getTelephone() {
        return telephone;
   }
   public void setTelephone(String telephone) {
        this.telephone = telephone;
   }
   ...
}
        

Aufbauend auf UPerson lässt sich nun die Klasse UCustomer implementieren, deren zusätzliche Attribute der Firmenname, in deren Auftrag der Kunde den Markt besucht, sowie sein Einkaufskorb sind. Der Einkaufskorb wird, wie in der Entwurfsphase beschlossen, als Instanz der Klasse CountingStockImpl implementiert:

public class UCustomer extends UPerson{

   // ID für Serialisierung
   private static final long serialVersionUID = 8256899920218903123L;
   
   // Firmenname
   private String company;
   // Einkaufskorb
   private CountingStockImpl csi_shoppingBasket;

   public UCustomer(String s) {
		super(s);
   }

   public String getCompany() {
        	return company;
   }

   public void setCompany(String company) {
        	this.company = company;
   }

   public CountingStockImpl getShoppingBasket() {
        	return csi_shoppingBasket;
   }
}
        

Der Einkaufskorb erhält keine set-Methode, die Initialisierung des CountingStockImpl wird später im Konstruktor von UCustomer nachgetragen, vorerst fehlt hierfür jedoch die Grundlage (Angabe eines Catalog als Argument erforderlich!). Um die Kunden und in der weiterführenden Implementation auch die Angestellten zu erzeugen, zu verwalten und zu filtern, stellt das Framework die Klasse UserManager bereit. Die abgeleitete Implementation des UserManagers für den Großmarkt wird zunächst lediglich eine createUser- und isUser-Methode beinhalten, über welche sich User erzeugen und überprüfen lassen:

public class UMUserBase extends UserManager{

   // ID für Serialisierung
   private static final long serialVersionUID = 8476169105291116432L;

   public static final int CUSTOMER = 1;

    	/**
     	* Gibt zurück, ob ein User mit dem übergebenen String als Key existiert.
     	*/

    	public static boolean isUser(String userName) {

        /**
        * getUser(userName) liefert null zurück,
        * wenn kein User mit userName als Key im UserManager verzeichnet ist.
        */
        	if(UserManager.getGlobalUM().getUser(userName)==null){
            	   return false;
        	}
        	   return true;
    	}

    	/**
     	* Das Argument qualification ist an dieser Stelle bedeutungslos,
     	* es wird in der weiterführenden Implementation für die Angestellten benötigt.
     	*/

    	public static User createUser(String sName, int type, String qualification) {

        /**
        * Überprüft ob der Nutzer schon existiert, es dürfen keine 2 Nutzer
        * mit derselben ID dem UserManager zugefügt werden.
        */
        	if(isUser(sName)) return null;
        	   User user;
        	   if(type==CUSTOMER) user = new UCustomer(sName);
        	   /**
        	   * Hier würde in der Vollversion ein Angestellter erzeugt werden,
        	   * anstelle von UPerson.
        	   */
        	   else user = new UPerson(sName);

        /**
        * Der neue Nutzer wird dem UserManager zugefügt und
        * kann mit getUser(sName) wiedergegeben werden
        */

        	UserManager.getGlobalUM().addUser(user);
        	   return user;
   	}
}
        

Nachdem UMUserBase fertiggestellt wurde, muss noch eine Instanz dieser neuen Klasse als der globale UserManager gesetzt werden. Dazu dient eine statische Variable in der Klasse UserManager, die über UserManager.setGlobalUM(UserManager um) und UserManager.getGlobalUM() gesetzt und wiedergegeben werden kann. Also wird der main-Methode in MainClass folgende Zeile hinzugefügt:

public class MainClass {

   public static void main(String[] args){
        SMarket market = new SMarket();
        UserManager.setGlobalUM(new UMUserBase());
        market.start();
   }
}
        

Jetzt sind alle Vorraussetzungen erfüllt, um Kunden zu erzeugen und über den UserManager wieder zugänglich zu machen.


Kataloge und Bestände

Damit der Kunde einkaufen kann, wird ein Katalog(Catalog) und Bestand(Stock) von Artikeln benötigt. Es wird mit der Implementation des Katalogs begonnen, da Bestände sich immer auf einen vorhandenen Katalog beziehen. Der Produktkatalog wird als Instanz von CatalogImpl im SMarket realisiert, die Initialisierung erfolgt im Konstruktor. Um auf den Produktkatalog auch außerhalb von SMarket zugreifen zu können, wird sein Name benötigt (durch Shop.getCatalog(String name)), daher wird dieser als statische Konstante deklariert:

public class SMarket extends Shop{

   ...
   
   // Der Name des Produktkatalogs als statische Konstante.
   public static final String CAT_ARTICLECATALOG = "Produktkatalog";
   // Deklaration des Produktkatalog.
   private CatalogImpl c_articleCatalog;

   public SMarket() {
        super();
        setTheShop(this);
        setShopFrameBounds(new Rectangle(50,50,400,300));
        setShopFrameTitle("Großmarkt Sohn & Sohn");
        // Initialisierung des Produktkatalogs.
        c_articleCatalog = new CatalogImpl(CAT_ARTICLECATALOG);
        // Damit der Katalog verfügbar ist, muss er dem SMarket zugefügt werden.
        addCatalog(c_articleCatalog);
   }
   ...
}
	

Jetzt kann der Bestand des Marktes hinzugefügt und der Einkaufskorb des Kunden initialisiert werden. Der neugeschaffene noch hinzufügt werden müssen, die Grundlage, auf der beide Stocks (Bestand und Einkaufskorb) aufbauen. Also wird die Klasse SMarket wie folgt ergänzt:

public class SMarket extends Shop{

   ...

   public static final String CAT_ARTICLECATALOG = "Produktkatalog";

   // Der Name des Bestands als statische Konstante.
   public static final String STK_OFFER = "Angebot";

   private CatalogImpl c_articleCatalog;

   // Deklaration des Bestands
   private CountingStockImpl cs_offer;

   public SMarket() {
	super();
        setTheShop(this);
        setShopFrameBounds(new Rectangle(50,50,400,300));
        setShopFrameTitle("Großmarkt Sohn & Sohn");
        c_articleCatalog = new CatalogImpl(CAT_ARTICLECATALOG);
        addCatalog(c_articleCatalog);
        // Initialisierung des Bestandes mit dem Produktkatalog als Argument
        cs_offer = new CountingStockImpl(STK_OFFER, c_articleCatalog);
        // Damit der Bestand verfügbar ist, muss er dem SMarket zugefügt werden
        addStock(cs_offer);
    }
    ...
}
	

Und dem Konstruktor von UCustomer wird folgende Zeile hinzugefügt:

public class UCustomer extends UPerson{
   ...
   public UCustomer(String s) {
	super(s);
        // Initialisierung des Einkaufskorbs mit dem Produktkatalog als Argument
        csi_shoppingBasket = new CountingStockImpl(s,
        	(CatalogImpl)Shop.getTheShop().getCatalog(SMarket.CAT_ARTICLECATALOG));
   }
   ...
}
	 

Der Katalog und der Bestand sind nun implementiert, jedoch fehlen noch die dazugehörigen Artikel. Die Artikel sollen (siehe Analyse & Entwurf) einen Einkaufspreis für den Markt und einen Verkaufspreis für den Kunden haben, außerdem eine Kategorie, der sie angehören (wie z.B. Haushaltswaren), einen Namen und eine Beschreibung des Artikels. Die Artikel sollen dem Produktkatalog zugefügt werden, so dass sie von den Stocks die auf den Produktkatalog verweisen genutzt werden können. Dementsprechend wird der Artikel als CatalogItemImpl implementiert. Der Name, die Beschreibung und die Kategorie des Artikels müssen als gesonderte Attribute angegeben werden.

public class CIArticle extends CatalogItemImpl{
   // ID für Serialisierung
   private static final long serialVersionUID = -492742791645542590L;

   private String name;
   private String category;
   private String[] description;
}
	

Obwohl CatalogItemImpl nur ein Attribut vom Typ Value hat, dessen Wert dem Konstruktor übergeben wird, können Ein- und Verkaufspreis diesem Attribut zugewiesen werden, indem auf die Klasse QuoteValue des Frameworks zurückgegriffen wird. QuoteValue ist ebenfalls ein Value, das jedoch zwei Values repräsentiert, wie der Name schon sagt und zwar ein bid-Value (Verkaufswert) und ein offer-Value (Einkaufswert). Also wird der Konstruktor der neuen Klasse CIArticle wie folgt definiert:

public class CIArticle extends CatalogItemImpl{
   // ID für Serialisierung
   private static final long serialVersionUID = -492742791645542590L;

   private String name;
   private String category;
   private String[] description;

   public CIArticle(String id, String name, String category,
      IntegerValue bid, IntegerValue offer) {
	super(id, new QuoteValue(bid, offer));
	this.name = name;
	this.category = category;
  }
}
	

Abschließend muss noch die Methode getShallowClone() implementiert werden, die in CatalogItemImpl abstract definiert ist und ein identisches CatalogItemImpl zurückliefern soll:

public class CIArticle extends CatalogItemImpl{
   ...
   protected CatalogItemImpl getShallowClone() {
	QuoteValue qv = (QuoteValue)this.getValue();
        return new CIArticle(this.getName(),
        this.name,
        this.category,
        (IntegerValue)qv.getBid(),
        (IntegerValue)qv.getOffer());
   }
}
	

Außerdem müssen noch get-Methoden geschrieben werden, die Namen, Kategorie und Beschreibung zurückliefern und eine set-Methode zum Setzen der Beschreibung. Wie sich der Implementation von getShallowClone entnehmen lässt, ist es möglich Ein- und Verkaufspreis über die schon in CatalogItemImpl und QuoteValue implementierten Methoden getValue() und getOffer() bzw. getBid() abzurufen, daher werden hierfür keine zusätzlichen Rückgabemethoden gebraucht.


Der SalesPoint

Um den Bestand anzeigen zu können bzw. dem Kunden überhaupt eine Interaktionsmöglichkeit zu schaffen, wird im nächsten Schritt der SalesPoint des Kunden implementiert. Dazu wird eine neue von SalesPoint abgeleitete Klasse SPCustomer deklariert und ein Konstruktor eingefügt:

public class SPCustomer extends SalesPoint{
   // ID für Serialisierung
   private static final long serialVersionUID = -4380520617845209584L;
   
   public SPCustomer(UCustomer customer) {
   // Als Titelleiste des Fensters setzen wir den Namen des Kunden
	super("Kundenterminal - "
           + customer.getFirstName() + " "
           + customer.getSurName());
   // Der Kunde wird dem SalesPoint zugewiesen und kann über getUser() abgerufen werden
   	attach(customer);
   // Die Größe des SalesPoint wird gesetzt
	setSalesPointFrameBounds(new Rectangle(0,0,640,540));
   // Der neue SalesPoint wird dem Shop zugefügt, erst dann ist er nutzbar
	Shop.getTheShop().addSalesPoint(this);
   }
}
	 

Der SPCustomer ist so zwar lauffähig, wird aber nur das vorimplementierte Standard-FormSheet anzeigen, also ist es notwendig ein neues FormSheet zu implementieren und die Methode getDefaultFormSheet() in# SPCustomer zu überschreiben. Das neue FormSheet wird in einer gesonderten Klasse deklariert:

public class FSCustomerDefault extends FormSheet{
   // ID für Serialisierung
   private static final long serialVersionUID = 2724307602307416882L;

   public FSCustomerDefault(){
   /**
   * Bei der Erzeugung eines neuen FormSheets wird ein Titel, und wahlweise
   * eine JComponent oder ein FormSheetContentCreator, der den Inhalt des
   * FormSheets gestaltet, benötigt. Der boolsche Wert zeigt an ob dies
   * FormSheet geschlossen werden muss, bevor das Display, auf welchem dieses
   * FormSheet anzeigt wird, ein neues FormSheet setzt.
   */
	super("Willkommen",
 	   new FormSheetContentCreator(){
 	        // ID für Serialisierung
 	        private static final long serialVersionUID = -1871828005291990066L;
 	        
		protected void createFormSheetContent(FormSheet fs) {
	  	   JPanel jp = new JPanel();
                   JLabel jl = new JLabel("Willkommen im Sohn & Sohn Großmarkt");
                   jp.add(jl);
                   fs.setComponent(jp);
                }
          },
          false);
   }
}
	

Durch das neue FormSheet wird ein einfacher Willkommensgruß mittels eines JLabels in einem JPanel angezeigt. Grundsätzlich können alle swing-Elemente über einen FormSheetContentCreator in das FormSheet eingebaut werden, der FormSheetContentCreator sorgt dafür, dass das FormSheet und seine Elemente persistent gehalten werden.

Was nun noch fehlt sind Buttons mit denen weiterführende Aktionen verknüpft werden können. In der Klasse FormSheet sind lediglich ein OK- und ein Cancel-Button vorimplementiert, die für FSCustomerDefault jedoch nicht gebraucht werden. Daher werden diese entfernt und zwei neue eingefügt, ein Button für den Kaufprozess und einer zur Änderung der Kundendaten. Das Framework bietet hierfür ein eigenes Konstrukt an, die sogenannten FormButtons, mit einer Beschriftung, einer ID und einer Action, die ausgeführt wird, wenn der FormButton gedrückt wurde. Die ID muss ein Wert vom Typ int sein und dient der eindeutigen Identifikation, daher dürfen zwei Buttons ein und desselben FormSheets nie dieselbe ID besitzen. Um sich nicht jedesmal Gedanken darüber machen zu müssen, welche ID an welchen Button zu vergeben ist, kann man sich einige IDs als Konstanten in einer eigenen Klasse vordefinieren:

public abstract class ButtonIDs {

   public static final int BTN_OK      = -100;
   public static final int BTN_ACCEPT  = -250;
   public static final int BTN_BUY     = -300;
   public static final int BTN_BACK    = -350;
   public static final int BTN_CANCEL  = -400;
   public static final int BTN_EDIT    = -450;
}
	

Als Nächstes werden im FormSheetContentCreator von FSCustomerDefault die vorhandenen Buttons entfernt und zwei neue: "Einkaufen" und "Persönliche Daten" eingefügt. Die Actions werden leer gelassen und später im SPCustomer gesetzt, um eine Trennung zwischen Oberfläche und Funktion zu gewährleisten:

public class FSCustomerDefault extends FormSheet{
   // ID für Serialisierung
   private static final long serialVersionUID = 2724307602307416882L;

   public FSCustomerDefault(){
	super("Willkommen",
	   new FormSheetContentCreator(){
 	        // ID für Serialisierung
 	        private static final long serialVersionUID = -1871828005291990066L;
	   
		protected void createFormSheetContent(FormSheet fs) {
		   JPanel jp = new JPanel();
                   JLabel jl = new JLabel("Willkommen im Sohn & Sohn Großmarkt");
                   jp.add(jl);
                   fs.setComponent(jp);
                   fs.removeAllButtons();
                   fs.addButton("Einkaufen", ButtonIDs.BTN_BUY, null);
                   fs.addButton("Persönliche Daten", ButtonIDs.BTN_EDIT, null);
                }
            },
	false);
   }
}
	

FSCustomerDefault ist jetzt vollständig und kann von SPCustomer benutzt werden. Hierfür wird die Methode getDefaultFormSheet() in SPCustomer überschrieben:

public class SPCustomer extends SalesPoint{
   ...
   protected FormSheet getDefaultFormSheet() {
	FormSheet fs = new FSCustomerDefault();
	   return fs;
   }
}
	

Um SPCustomer samt frischer Anzeige betrachten zu können, fehlt noch die Möglichkeit ihn aufzurufen. Da noch keine Login-Möglichkeit existiert, über die der Kunde zu seinem SalesPoint gelangt, behilft man sich indem man den Aufruf eines neuen SPCustomer, sowie die Erzeugung eines Testkunden, der dem Konstruktor von SPCustomer übergeben werden muss, im Menü des SMarket unterbringt. Man kann natürlich auch einen SPCustomer samt Kunden in der main()-Methode der Anwendung initialisieren, jedoch wird durch die erstgenannte Vorgehensweise zusätzlich gezeigt, wie man die Funktion eines MenuSheetItems implementiert. Also wird im default-Menü des SMarket die Action des MenuSheetItem "Kunde" wie folgt definiert:

public class SMarket extends Shop{
   ...
   public MenuSheet createShopMenuSheet(){
	...
        /**
        * Es wird eine neue Action definiert, man schreibt sale.Action()
        * um einer Verwechslung mit javax.swing.Action() vorzubeugen.
        */
        MenuSheetItem msiCustomer = new MenuSheetItem("Kunde", new sale.Action(){
           // ID für Serialisierung
           private static final long serialVersionUID = -6709968809343202422L;
           
           // sale.Action() ist ein Interface, doAction muss daher implementiert werden.
           public void doAction(SaleProcess process, SalesPoint point) throws Throwable {

        	/**
        	* Der neue Kunde wird erzeugt,
        	* falls der Kunde schon existierte wird null zurückgeliefert.
        	*/
        	UCustomer uc = (UCustomer)UMUserBase.createUser("Testnutzer",
        	   UMUserBase.CUSTOMER, null);
		     /**
                     * Wurde wirklich ein neuer Kunde erzeugt,
                     * werden Vor- und Nachnamen gesetzt.
                     */
                     if(uc != null){
			uc.setFirstName("Markus");
                        uc.setSurName("Mustermann");
                     }
                     /**
                     * Falls der Kunde schon existierte, wird der existente Kunde
                     * vom globalen UserManager zurückgeliefert.
                     */
                     else uc = (UCustomer)UserManager.getGlobalUM().getUser("Testnutzer");

                     // Der neue SPCustomer wird initialisiert mit dem Kunden als Argument.
                     SPCustomer spc = new SPCustomer(uc);
	   }
	});
   ...
   }
}
	

Was sofort auffällt, wenn man SPCustomer austestet ist, dass er sich bislang nur durch Schließen des Fensters beenden lässt, aber keine Abmeldemöglichkeit in der Anzeige existiert. Daher wird noch die Methode getDefaultMenuSheet in SPCustomer überschrieben, so dass ein MenuSheet zurückgeliefert wird, welches ein Abmelden-item beinhaltet.

public class SPCustomer extends SalesPoint{
   ...
   protected MenuSheet getDefaultMenuSheet() {
	MenuSheet ms = new MenuSheet("Abmeldung");
        ms.add(new MenuSheetItem("Abmelden", new sale.Action(){
           // ID für Serialisierung
           private static final long serialVersionUID = 708299138129393449L;
           
	   public void doAction(SaleProcess process, SalesPoint point) throws Throwable {

		/**
                * Über point kann auf den aktuellen SalesPoint zugegriffen werden,
                * über process auf den aktuellen SaleProcess, sofern einer läuft.
                * Der SalesPoint wird vom Shop abgemeldet
                */
                Shop.getTheShop().removeSalesPoint(point);

                 // Der SalesPoint wird geschlossen
                 point.quit();
	   }
	}));
        return ms;
   }
}
	

Um die lästige Nachfrage beim Schließen des SalesPoint zu umgehen, überschreibt man die Methode onCanQuit() in SPCustomer:

public class SPCustomer extends SalesPoint{
   ...
   // Kann geschlossen werden, wenn kein SaleProcess läuft
   protected boolean onCanQuit() {
	return getCurrentProcess() == null;
   }
}
	

Jetzt sind alle Vorraussetzungen erfüllt um den eigentlichen Prozess des Einkaufs implementieren zu können.


Der SaleProcess

In der Entwurfsphase wurde bereits der Prozessaufbau des Einkaufs über ein Zustandsdiagramm detailliert beschrieben. Dieser Entwurf soll im letzten Teil dieses Abschnitts möglichst genauso umgesetzt werden.

Im Framework SalesPoint werden Zustände durch das Interface Gate und Zustandsübergänge durch das Interface Transition realisiert. Zudem existiert eine Klasse SaleProcess, deren Instanzen sich im Grunde wie Automaten mit einer Menge von Zuständen und Zustandsübergängen verhalten. Der Einkaufsprozess wird folgerichtig in Form eines SaleProcess implementiert. Es wird eine neue Klasse SProcessCustomer von SaleProcess abgeleitet und die Gates: SelectionGate, CommitGate und ConfirmationGate als Instanzen der Klasse UIGate hinzugefügt. Die Gates CommitGate, RollbackGate, LogGate und StopGate können in der Form, wie sie in der Klasse SaleProcess bereits vorimplementiert sind, beibehalten werden. Außerdem erhält die neue Klasse einen Konstruktor (mit Übergabe des Kunden, der den Prozess ausführt) und eine Implementation von getInitialGate(), eine Methode, die in SaleProcess abstract deklariert ist. In einem SaleProcess liefert getInitialGate() das Gate, zu welchem der Prozess springt, nachdem er gestartet wurde, im Fall des Einkaufsprozesses also das SelectionGate:

public class SProcessCustomer extends SaleProcess{

   // ID für Serialisierung.
   private static final long serialVersionUID = -4273929088320316793L;   
   
   /**
   * UIGate ist eine vom Framework bereitgestellte Klasse, die Gate implementiert.
   * Einem UIGate kann ein FormSheet und ein MenuSheet zugewiesen werden,
   * die angezeigt werden, wenn der SaleProcess sich am jeweiligen UIGate befindet.
   * Bei der Initialisierung der Gates werden Form- und MenuSheets hier zunächst
   * weggelassen.
   */
   private UIGate uig_selection = new UIGate(null, null);
   private UIGate uig_affirmation = new UIGate(null, null);
   private UIGate uig_ok = new UIGate(null, null);

    // Der Kunde, der den SaleProcess ausführt, wird ebenfalls gespeichert.
    private UCustomer uc_customer;

	/**
        * Dem Konstruktor wird der Kunde, der den neuen SaleProcess aufruft, übergeben.
        * Der Kunde wird dem Attribut uc_customer zugewiesen und sein Name
        * dem Konstruktor in SaleProcess übergeben
        */
        public SProcessCustomer(UCustomer customer) {
            super(customer.getName());
            uc_customer = customer;
        }

        // getInitialGate() liefert das Start-Gate des Einkaufsprozess
        protected Gate getInitialGate() {
            return uig_selection;
   }
}
	

Im nächsten Schritt soll die Anzeige des SelectionGate implementiert werden, hierfür wird ein neues FormSheet benötigt, welches das Angebot des Marktes und den Einkaufskorb des Kunden darstellt und die Möglichkeit bietet, Waren zwischen Bestand und Einkaufskorb auszutauschen. Da derartige Bedürfnisse immer wieder in Verkaufsanwendungen auftauchen, bietet das Framework folgerichtig auch hierfür ein Konstrukt an namens TwoTableFormSheet. Dabei handelt es sich um ein FormSheet, das zwei Tabellen zum Austausch von Datenelementen anbietet. Der Austausch kann zwischen den verschiedenen Datenstrukturen des Frameworks, wie Catalog, CountingStock, StoringStock und DataBasket erfolgen. Die Erzeugung der Tabellen wird nicht direkt über den Aufruf des Konstruktors getätigt, die Klasse bietet aber verschiedene create-Methoden an, die das gewünschte FormSheet zurückliefern. Im Falle der SelectionGate-Anzeige handelt es sich um zwei CountingStockImpl, nämlich Bestand und Einkaufskorb. Die Erzeugungsmethode des FormSheets wird in eine neue Klasse geschrieben:

public class FSCustomerOffer {

   /**
   * Die neue Erzeugungsmethode, welche das FormSheet für SelectionGate zurückgibt
   */
   public static TwoTableFormSheet getOfferFormSheet(
	CountingStock offer,    // Der Bestand des Marktes
        CountingStock shoppingBasket,    // Der Einkaufskorb des Kunden
        DataBasket db,    // Der DataBasket relativ zu den Transaktionen
        UIGate uig){    // Das Gate, an dem dieses FormSheet angezeigt wird

        /**
        * Das neue Instanz wird über die statische Methode create(...)
        * in der Klasse TwoTableFormSheet initialisiert.
        */
        final TwoTableFormSheet ttfs = TwoTableFormSheet.create(
	   "Produktauswahl", // Die Titelleiste des FormSheets
           offer,            // Die Quelle der linken Tabelle
	   shoppingBasket,   // Die Quelle der rechten Tabelle
	   db,      // Der DataBasket relativ zu den Transaktionen
	   uig,     // Das Gate, an dem dieses FormSheet angezeigt wird
	   null,    // Comparator links
	   null,    // Comparator rechts
	   false,   // gibt an, dass links keine Einträge mit Anzahl=0 angezeigt werden
	   false,   // gibt an, dass rechts keine Einträge mit Anzahl=0 angezeigt werden
	   null,    // TableEntryDescriptor links
	   null,    // TableEntryDescriptor rechts
	   null);   // CSCSStrategy

	// Die vorhandenen OK- und Cancel-Buttons werden entfernt.
	ttfs.removeAllButtons();

	// Zwei neue Buttons werden hinzugefügt.
	ttfs.addButton("Kaufen", ButtonIDs.BTN_BUY, null);

	ttfs.addButton("Zurück", ButtonIDs.BTN_BACK, null);
   }
}
	

Bei der Betrachtung des Aufrufs von TwoTableFormSheet.create(...) fallen sofort drei Typen von Argumenten auf, die einer näheren Erläuterung bedürfen: DataBasket, TableEntryDescriptor und CSCSStrategy. Ein vierter Typ dürfte für den ein oder anderen Leser Comparator sein, ein Interface, welches jedoch nicht dem Framework, sondern dem Package java.util entstammt und wie der Name schon sagt, Objekte auf der Grundlage einer Vergleichsfunktion(compare) in eine Ordnung bringt. Im Fall von FSCustomerOffer wird auf die Implementation von Comparatoren verzichtet, wen es interessiert, der möge sich die verschiedenen Implementationen von Comparator im Package market.swing der Vollversion des Großmarkts anschauen. Wesentlich für die Transaktionen zwischen den CountingStocks der beiden Tabellen ist der DataBasket. DataBaskets sind fundamental wichtig für das Verschieben von Elementen zwischen den verschiedenen Datenstrukturen des Frameworks. Eine add- oder remove-Methode mit Übergabe eines DataBaskets bewirkt, dass die jeweiligen Elemente auf die sich die beiden Methoden beziehen nur vorläufig dem Catalog oder Stock hinzugefügt bzw. aus ihm entfernt werden und das durch ein auf den betreffenden DataBasket bezogenes Rollback die Elemente problemlos wieder zurückgesetzt werden können. Für alle Betrachter, die den entsprechenden DataBasket nicht als Argument verwenden, sind die verschobenen Elemente in Quelle und Ziel unsichtbar. So wären in diesem Beispiel Waren die aus dem Angebot in den Einkaufskorb verschoben wurden, für alle anderen Betrachter sowohl im Angebot als auch im Einkaufskorb unsichtbar. Diese Eigenschaft der Transaktionen mit Hilfe von DataBaskets soll Konsistenz der Daten gewährleisten.

Der zweite Typ TableEntryDescriptor bestimmt die Form des Inhalts der Tabelle, worauf aber noch später näher eingegangen wird. Das letzte Argument in der verwendeten create-Methode, das übergeben werden muss, ist vom Typ CSCSStrategy. Diese Klasse ist dafür verantwortlich wie die Elemente zwischen den in der Tabelle angezeigten CountingStocks verschoben werden und wie mit Fehlern (z.B. mehr Elemente als in der Quelldatenstruktur vorhanden, sollen verschoben werden) umgegangen wird. Übergibt man, statt einer CSCSStrategy, null, wird ein einfaches Objekt in der vorimplementierten Form von CSCSStrategy erzeugt.
Um sich das vorläufige Ergebnis des neuen SaleProcess und TwoTableFormSheets anzeigen lassen zu können, muss der Start des SProcessCustomer mit dem Button Einkauf des Kunden-SalesPoint verknüpft werden. Dazu muss die Action des FormButton in der getDefaultFormSheet-Methode von SPCustomer gesetzt werden:

public class SPCustomer extends SalesPoint{
   ...
   protected FormSheet getDefaultFormSheet() {
	FormSheet fs = new FSCustomerDefault();

        /**
        * Das Setzen der Action des FormButtons wird innerhalb
        * eines FormSheetContentCreators erledigt, da dieser
        * Persistenz gewährleistet und somit die Action auch nach
        * Speichern und Laden des Shops verfägbar ist.
        */
        fs.addContentCreator(new FormSheetContentCreator(){
           // ID für Serialisierung
           private static final long serialVersionUID = 1146704984018039866L;
           
	   protected void createFormSheetContent(FormSheet sheet) {

		/**
                * Der Button wird über seine ID wiedergegeben
                * und die neue Action gesetzt.
                */
                sheet.getButton(ButtonIDs.BTN_BUY).setAction(new sale.Action(){
                   // ID für Serialisierung
                   private static final long serialVersionUID = 3439946422782505800L;
                
		   public void doAction(SaleProcess process, SalesPoint point)
		      throws Throwable {
			// Der Kunde der mit diesem SPCustomer verknüpft ist.
                        UCustomer uc = (UCustomer)point.getUser();
                        /**
                        * Ein neuer SProcessCustomer wird auf dem SPCustomer gestartet,
                        * der Kunde des SPCustomers wird übergeben
                        */
                        point.runProcess(new SProcessCustomer(uc));
		   }
		});
	   }
	});
	return fs;
   }
   ...
}
	

Außerdem muss das neuimplementierte TwoTableFormSheet im SProcessCustomer initialisiert und dem SelectionGate zugewiesen werden. Da bei der Initialisierung ein DataBasket benötigt wird muss dieser zunächst verfügbar gemacht werden. Hierzu wiederum eine kurze Erläuterung:
Ein DataBasket lässt sich einem SaleProcess zuweisen. Wechselt der Prozess während seiner Ausführung zum Rollback- oder CommitGate, so wird auf dem zugewiesenem DataBasket ein Rollback oder Commit ausgeführt (d.h. die Transaktionen relativ zum DataBasket werden zurückgesetzt oder bestätigt). Da im Beispiel des Einkaufsprozesses das vorimplementierte RollbackGate benutzt werden soll, ist es notwendig den DataBasket, der dem TwoTableFormSheet übergeben wird, zuvor dem SProcessCustomer zuzuordnen. Die Zuordnung sollte aber möglichst nicht im SaleProcess direkt erfolgen. Beim Start eines SaleProcess durch einen SalesPoint(SalesPoint.runProcess(...)), wird nämlich ein dem SalesPoint zuvor zugeordneter DataBasket auch dem gestarteten SaleProcess zugewiesen. Es ist günstiger die Zuweisung des DataBasket gleich im SalesPoint vorzunehmen.(Dies soll als Faustregel genügen. Muss die Zuweisung des DataBaskets aus welchen Gründen auch immer im SaleProcess erfolgen, so sollte dies auf keinen Fall im Konstruktor passieren!).
Die Initialisierung des benötigten DataBaskets wird dementsprechend im Konstruktor von SPCustomer vorgenommen:

public class SPCustomer extends SalesPoint{
   ...
   public SPCustomer(UCustomer customer) {
	super("Kundenterminal - "
		+ customer.getFirstName() + " "
                + customer.getSurName());
	attach(customer);

        /**
        * Dem SPCustomer wird eine neue Instanz von DataBasketImpl zugeordnet.
        * DataBasketImpl ist eine vom Framwork bereitgestellte Implementation
        * des Interface DataBasket
        */
        attach(new DataBasketImpl());
        setSalesPointFrameBounds(new Rectangle(0,0,640,540));
        Shop.getTheShop().addSalesPoint(this);
   }
   ...
}
	

Der DataBasket von SPCustomer ist nun auch im SProcessCustomer verfügbar, die Initialisierung des in FSCustomerOffer implementierten TwoTableFormSheets kann jetzt erfolgen:

public class SProcessCustomer extends SaleProcess{
   ...
   protected Gate getInitialGate() {
	FormSheet fs = FSCustomerOffer.getOfferFormSheet(
	   (CountingStock)Shop.getTheShop().getStock(SMarket.STK_OFFER), // Der Bestand
	    uc_customer.getShoppingBasket(), // Der Einkaufskorb des Kunden
	    getBasket(),        // Der zugewiesene DataBasket
            uig_selection);     // Das Gate, an dem das neue FormSheet angezeigt wird

            // Das initialisierte FormSheet muss noch dem UIGate zugewiesen werden.
            uig_selection.setFormSheet(fs);
            return uig_selection;
   }
}
	

Ein erster Test des neuen Prozesses zeigt, dass der Bestand des Markts noch völlig leer ist. Also fügt man ein paar Testdaten ein, indem man die MainClass wie folgt ergänzt:

public class MainClass {

   public static void main(String[] args){
	SMarket market = new SMarket();
	UserManager.setGlobalUM(new UMUserBase());
	market.start();
	// Die neue Methode (siehe unten) wird aufgerufen.
	addArticles();
   }

   /**
   * Methode zur Erzeugung einiger Artikel und
   * zum Hinzufügen der neuen Artikel zum Produktkatalog und Bestand.
   */
   public static void addArticles(){
	Catalog c_articles =
	  Shop.getTheShop().getCatalog(SMarket.CAT_ARTICLECATALOG);
	    for(int i=0; i<20; i++){
		c_articles.add(
		   new CIArticle(String.valueOf(i),
                   new String("Artikel "+i),
                   new String("Kategorie "+i),
                   new IntegerValue(i+2),
                   new IntegerValue(i)), null);
	}

	CountingStock cs_offer =
	  (CountingStock)Shop.getTheShop().getStock(SMarket.STK_OFFER);
	     for(int i=1; i<20; i++){
		cs_offer.add(String.valueOf(i), i, null);
        }
   }
}
	

Eine erneute Ausführung des SProcessCustomer verdeutlicht, dass ein eigens implementierter TableEntryDescriptor für eine sinnvolle Anzeige der Artikel unabdingbar ist. Ohne einen solchen werden nämlich lediglich die Werte der Standardvariablen der StockItems name, value und count angezeigt, was in diesem Fall wenig aussagekräftig ist. Ein TableEntryDescriptor gibt für eine Tabelle an, wieviele Spalten die Tabelle besitzt, welchen Typ die Einträge der Spalten haben, wie die Überschriften der Spalten lauten und wie die Werte der Einträge sind.
Die Tabellen für Bestand und Einkaufskorb sollen identisch aufgebaut sein, daher ist die Implementation eines TableEntryDescriptors ausreichend. Es wird direkt in FSCustomerOffer eine statische Erzeuger-Methode getTED() geschrieben:

public class FSCustomerOffer {
   ...
   private static TableEntryDescriptor getTED(){

	return new AbstractTableEntryDescriptor(){
	   // ID für Serialisierung
	   private static final long serialVersionUID = -3039507143399314727L;

	   /**
           * Gibt an welcher Wert in welche Spalte gehört.
           * i ist die Spaltennummer
           * o ist das jeweilige Zeilenelement
           */
           public Object getValueAt(Object o, int i) {
		CIArticle article =
		   (CIArticle)((CountingStockTableModel.Record)o).getDescriptor();
		int count = ((CountingStockTableModel.Record)o).getCount();
           	   switch (i) {
		   case 0: return article.getArticleName();
		   case 1: return ((QuoteValue)article.getValue()).getBid();
		   case 2: return new Integer(count).toString();
	   	}
	   	return null;
	   }

	   // Gibt zurück von welchem Typ die Elemente in der Spalte i sind.
	   public Class getColumnClass(int i) {
	 	  return (new Class[] {String.class, Number.class, Number.class}) [i];
	   }

	   // Gibt zurück wie die Überschrift der Spalte i lautet.
	   public String getColumnName(int i) {
	 	  return (new String[]{ "Artikel", "Preis", "Anzahl"}) [i];;
	   }

	   // Gibt die Spaltenanzahl wieder.
	   public int getColumnCount() {
		   return 3;
	   }
	};
   }
}
	

Der neue TableEntryDescriptor muss noch den beiden Tabellen des TwoTableFormSheets zugewiesen werden:

public class FSCustomerOffer {

   public static TwoTableFormSheet getOfferFormSheet(
	CountingStock offer,
	CountingStock shoppingBasket,
	DataBasket db, UIGate uig){

	   final TwoTableFormSheet ttfs = TwoTableFormSheet.create(
		"Produktauswahl",
		offer,
		shoppingBasket,
		db,
		uig,
		null,
		null,
		false,
		false,
		getTED(), // TED für die linke Tabelle
		getTED(), // TED für die rechte Tabelle
		null);
	   ...
   }
   ...
}
	

Das FSCustomerOffer ist jetzt bis auf eine Kleinigkeit vollständig. Versucht man eine größere Anzahl von Artikeln als im Bestand oder Einkaufskorb enthalten auf die jeweils andere Seite zu schieben, so tritt ein Fehler auf und der SaleProcess wird beendet. Für die Behandlung derartiger Fehler ist die MoveStrategy, in diesem Fall die abgeleitete Klasse CSCSStrategy, zuständig. Durch Ersetzen des default-ErrorHandler in einer MoveStrategy lässt sich dieses Problem beheben. In der Klasse MoveStrategy findet man einen ErrorHandler namens MSG_POPUP_ERROR_HANDLER, der die Fehlermeldung in einem extra-Fenster anzeigt und den Prozess nicht beendet. Möchte man eine schönere oder verständlichere Anzeige, kann man auch seinen eigenen ErrorHandler implementieren, für dieses Beispiel ist der oben genannte ausreichend:

public class FSCustomerOffer {

   public static TwoTableFormSheet getOfferFormSheet(
	CountingStock offer,
	CountingStock shoppingBasket,
	DataBasket db,
	UIGate uig){

	   // Die MoveStrategy in diesem Fall CSCSStrategy wird initialisiert.
           CSCSStrategy cscss = new CSCSStrategy();
           // Der ErrorHandler wird neu gesetzt.
           cscss.setErrorHandler(MoveStrategy.MSG_POPUP_ERROR_HANDLER);
           final TwoTableFormSheet ttfs = TwoTableFormSheet.create(
		"Produktauswahl",
		offer,
		shoppingBasket,
		db,
		uig,
		null,
		null,
		false,
		false,
		getTED(),
		getTED(),
		cscss);  // Die CSCSStrategy mit neuem ErrorHandler wird übergeben.

            ttfs.removeAllButtons();
            ttfs.addButton("Kaufen", ButtonIDs.BTN_BUY, null);
            ttfs.addButton("Zurück", ButtonIDs.BTN_BACK, null);
            return ttfs;
   }
   ...
}
	

Im nächsten Schritt sollen die FormButtons des FSCustomerOffer am SelectionGate mit Funktionalitäten ausgestattet werden. Bei Drücken von Zurück müssen mögliche Transaktionen zwischen dem Bestand des Marktes und dem Einkaufskorb rückgängig gemacht und der Prozess beendet werden, so dass der Kunde zur Standardansicht des SPCustomers zurückkehrt. Genau diese Funktion übernimmt das RollbackGate. Wechselt der SaleProcess zum RollbackGate, werden Transaktionen widerrufen, indem der DataBasket des Prozesses die Methode rollback() ausführt. Anschließend wird der SaleProcess automatisch zum LogGate, wo Informationen über den Prozess in das globale Logfile geschrieben werden und von dort zum StopGate geleitet, an welchem er terminiert.
In der Action des Zurück-Buttons wird dementsprechend eine Transition gesetzt, welche zum RollbackGate wechselt. Solche Transitions, die lediglich zu einem der vorimplementierten Gates führen, bietet das Framework als statische Konstanten in der Klasse GateChangeTransition:

public class SProcessCustomer extends SaleProcess{
   ...
   protected Gate getInitialGate() {
	FormSheet fs = FSCustomerOffer.getOfferFormSheet(
	   (CountingStock)Shop.getTheShop().getStock(SMarket.STK_OFFER),
            uc_customer.getShoppingBasket(),
            getBasket(),
            uig_selection);
	fs.addContentCreator(new FormSheetContentCreator(){
	   // ID für Serialisierung
	   private static final long serialVersionUID = 4901773519416313359L;
	
	   protected void createFormSheetContent(FormSheet sheet) {
	      sheet.getButton(ButtonIDs.BTN_BACK).setAction(new sale.Action(){
	        // ID für Serialisierung
	        private static final long serialVersionUID = -1435650383336503583L;
	        
		public void doAction(SaleProcess process, SalesPoint point)
		  throws Throwable {
		     /**
		     * Als nächste Transition des SelectionGates wird
		     * eine Transition gesetzt, die zum RollbackGate wechselt.
   		     */
		     uig_selection.setNextTransition(
		       GateChangeTransition.CHANGE_TO_ROLLBACK_GATE);
		}
	      });
	   }
	});
	uig_selection.setFormSheet(fs);
	return uig_selection;
   }
}
	

Nach Betätigen des Buttons "Kaufen" soll dem Kunden der Gesamtpreis der gewählten Waren angezeigt werden mit der Bitte um Bestätigung. Die Anzeige wird am AffirmationGate präsentiert. Die Methode getAffirmationGate wird daher wie folgt implementiert:

public class SProcessCustomer extends SaleProcess{
   ...
   public Gate getAffirmationGate() {
	/**
	* Um eine einfache Meldung anzuzeigen, braucht nicht unbedingt jedesmal
	* ein FormSheet neu implementiert werden, MsgForm ist eine Implementation
	* eines FormSheets dem eine Titelleiste und eine einfache Meldung
	* übergeben werden kann.
	*/
	FormSheet fs = new MsgForm("Auswahl bestätigen!",
	   // Der Betrag muss noch hinzugefügt werden
	   "Sie haben Artikel im Wert von:  Euro ausgewählt.\n"+
	   "Mit Kaufen können Sie die Auswahl bestätigen!\n"+
	   "Mit Zurück können Sie die Auswahl korrigieren!");
	fs.addContentCreator(new FormSheetContentCreator(){
	   // ID für Serialisierung
	   private static final long serialVersionUID = -1885726762783615518L;
	
	   protected void createFormSheetContent(FormSheet sheet) {
		// Der vorhandene OK-Button wird entfernt
		sheet.removeAllButtons();
		/**
		* Ein neuer Zurück-Button wird hinzugefügt, er führt
		* zum SelectionGate zurück.
		* Eine neue Instanz von GateChangeTransition mit einem Gate als Argument
		* ist eine Transition die zum übergebenen Gate führt.
		*/
		sheet.addButton("Zurück", ButtonIDs.BTN_BACK, new sale.Action(){
		   // ID für Serialisierung
		   private static final long serialVersionUID = 58356241234988407218L;
		
		   public void doAction(SaleProcess process, SalesPoint point)
		     throws Throwable {
			uig_affirmation.setNextTransition(
			  new GateChangeTransition(getInitialGate()));
		   }
		});
	   }
	});
	// Das neue FormSheet wird dem Gate zugewiesen
	uig_affirmation.setFormSheet(fs);
	return uig_affirmation;
   }
}
	

Um den Betrag, den der Kunde zu zahlen hat, einfach ausrechnen zu können, ohne über alle Elemente im Einkaufskorb iterieren zu müssen, gibt es die Methode sumStock(...) für die Klasse StockImpl (und damit auch ihren Unterklassen). Diese Methode benötigt ein Objekt der Klasse CatalogItemValue, welches angibt, welcher Wert eines CatalogItems (auf die ein StockItem sich bezieht) benutzt wird um die Rechnung zu vollziehen. Das Errechnen der Endsumme wird in einer eigenen Methode in SProcessCustomer implementiert:

public class SProcessCustomer extends SaleProcess{
   ...
   private Value getAmount(){
	/**
	* Die überschriebene Methode in CatalogItemValue: getValue(CatalogItem ci)
	* gibt nun den Verkaufspreis des Artikels aus
	*/
	CatalogItemValue civ = new CatalogItemValue(){
	   public Value getValue(CatalogItem item) {
		return ((QuoteValue)super.getValue(item)).getBid();
	   }
	};
	return uc_customer.getShoppingBasket().sumStock(
	   getBasket(),  // Der DataBasket des Prozesses, ohne ihn wäre das Ergebnis: 0
	   civ,   // der neue CatalogItemValue, der die Preise für die Artikel liefert
	   new IntegerValue(0));  // Der Startwert, auf den die Summe auf addiert wird
   }
}
	

Der Betrag kann nun im MsgForm angezeigt werden:

public class SProcessCustomer extends SaleProcess{
   ...
   public Gate getAffirmationGate() {
	FormSheet fs = new MsgForm("Auswahl bestätigen!",
	   "Sie haben Artikel im Wert von: "+
	      getAmount().toString()+    // Der Betrag als String
	      " Euro ausgewählt.\n"+
	      "Mit Kaufen können Sie die Auswahl bestätigen!\n"+
	      "Mit Zurück können Sie die Auswahl korrigieren!");
	...
   }
   ...
}
	

Dem Button "Kaufen" im FormSheet des SelectionGate muss nun noch die Action, die zum neuen AffirmationGate führen soll, zugefügt werden. Dazu kann wiederum auf die Klasse GateChangeTransition zurückgegriffen werden. Es handelt sich bei dieser Klasse um eine Implementation von Transition, welche lediglich zu einem anderen Gate wechselt, dazu muss das Gate dem Konstruktor von GateChangeTransition übergeben werden:

public class SProcessCustomer extends SaleProcess{
   ...
   protected Gate getInitialGate() {
	...
	fs.addContentCreator(new FormSheetContentCreator(){
	   // ID für Serialisierung
	   private static final long serialVersionUID = -1885726345783615518L;
	   
	   protected void createFormSheetContent(FormSheet sheet) {
		sheet.getButton(ButtonIDs.BTN_BUY).setAction(new sale.Action(){
	           // ID für Serialisierung
	           private static final long serialVersionUID = 1885726762778915518L;
	           
		   public void doAction(SaleProcess process, SalesPoint point)
		     throws Throwable {
			// Das AffirmationGate wird dem Konstruktor übergeben.
			uig_selection.setNextTransition(
			  new GateChangeTransition(getAffirmationGate()));
		   }
		});
	   ...
	});
	...
   }
   ...
}
	

Um eine Bestätigung des erfolgreichen Kaufvorgangs anzeigen zu können, muss noch das ConfirmationGate (uig_ok) fertiggestellt werden. Hat der Kunde die Bestätigung gelesen und "OK" gedrückt, so soll der SaleProcess zum CommitGate wechseln, um die Transaktionen zwischen Bestand und Einkaufskorb festzusetzen. Vom CommitGate wechselt der Prozess dann automatisch über das LogGate zum StopGate, wo er terminiert.

public class SProcessCustomer extends SaleProcess{
   ...
   public Gate getOkGate(){
	FormSheet fs = new MsgForm("Auswahl bestätigt!",
	  "Bitte begeben Sie sich zur Kasse!");
	fs.addContentCreator(new FormSheetContentCreator(){
	   // ID für Serialisierung
           private static final long serialVersionUID = -1812326762783615518L;
	
	   protected void createFormSheetContent(FormSheet sheet) {
		// Dem Standard OK-Button des MsgForm wird eine Action zugewiesen
		sheet.getButton(FormSheet.BTNID_OK).setAction(new sale.Action(){
	           // ID für Serialisierung
	           private static final long serialVersionUID = 4565726762783615518L;
	           
 		   public void doAction(SaleProcess process, SalesPoint point)
 		     throws Throwable {
			// Der SaleProcess wechselt zum CommitGate und terminiert
			uig_ok.setNextTransition(
			  GateChangeTransition.CHANGE_TO_COMMIT_GATE);
		   }
		});
	   }
	});
	// Das MsgForm wird dem Gate zugewiesen
	uig_ok.setFormSheet(fs);
	return uig_ok;
   }
   ...
}
	

Jetzt muss eigentlich nur noch eine Transition zum ConfirmationGate führen, wenn der "Kaufen"-Button des FormSheets am AffirmationGate gedrückt wurde. In diesem Fall wird jedoch keine GateChangeTransition ausreichen. Das Hinzuaddieren des Einkaufsbetrages zum Konto des Markts muss innerhalb dieser Transition stattfinden. Da der Markt aber noch gar kein Datum besitzt, welches sein Konto repräsentiert, muss dies zuerst geschehen.
Das Geld des Großmarkts wird durch eine einfache Variable vom Typ IntegerValue verwaltet. Außerdem wird eine statische Methode zum Hinzuaddieren von Werten dem SMarket hinzugefügt:

public class SMarket extends Shop{
   ...
   private IntegerValue iv_account = new IntegerValue(0);
   ...
   public static void addToAccount(Value money) {
	// addAccumulating addiert einen gegebenen Value zum Value dazu
	((SMarket)Shop.getTheShop()).iv_account.addAccumulating(money);
   }
}
	

Die Transition kann nun geschrieben werden. Transition ist ein Interface mit einer Methode namens perform, welche das Gate zurückliefert, zu dem SaleProcess springen soll:

public class SProcessCustomer extends SaleProcess{
   ...
   private Transition changeToOkGate(){
	return new Transition(){
           // ID für Serialisierung
           private static final long serialVersionUID = 9133713371337133717L;

	   public Gate perform(SaleProcess process, User user) {
	      // Die Endsumme der eingekauften Artikel wird dem Konto des Markts zuaddiert
	      SMarket.addToAccount(getAmount());
	      // Gibt das ConfirmationGate zurück
	      return getOkGate();
	   }
	};
   }
   ...
}
	

Die Transition, die changeToOkGate() zurückgibt, wird in der Action des Buttons "Kaufen" im FormSheet des AffirmationGates gesetzt:

public class SProcessCustomer extends SaleProcess{
   ...
   public Gate getAffirmationGate() {
	...
	 fs.addContentCreator(new FormSheetContentCreator(){
           // ID für Serialisierung
           private static final long serialVersionUID = 9733173317331733171L;
           
	   protected void createFormSheetContent(FormSheet sheet) {
		sheet.removeAllButtons();
		sheet.addButton("Kaufen", ButtonIDs.BTN_ACCEPT, new sale.Action(){
	           // ID für Serialisierung
	           private static final long serialVersionUID = 1234567890123456789L;
	           
		   public void doAction(SaleProcess process, SalesPoint point)
		     throws Throwable {
			uig_affirmation.setNextTransition(changeToOkGate());
		   }
		});
	   ...
	   }
	});
	...
   }
}
	

Damit ist der Kaufprozess abgeschlossen.


Sonstiges

Neben den in den vorigen Abschnitten beschriebenen zentralen Salespoint-Konzepten folgen hier jetzt einige kleinere Problemlösungsansätze für Probleme, die in vielen Salespoint-Projekten auftreten. Sie sind keineswegs die einzig möglichen Lösungen, sondern sollen nur einen Denkanstoß geben, wie man auf solche Probleme in der Implementierungsphase reagieren kann.

Move Strategies

Sehr viele, wenn nicht die meisten Salespoint-Anwendungen werden in irgendeiner Form ein TwoTableFormsheet einsetzen. Meist geht es hier um das Verschieben von Waren oder das Auffüllen von Beständen. Da der Benutzer leider nicht immer voll konzentriert das Programm bedient, und Bedienfehler unvermeidlich sind, muss das Programm möglichst sinvoll mit den Fehleingaben umgehen. Ein Beispiel dafür ist, dass der Benutzer mehr Waren kaufen möchte als der Markt von diesem Artikel vorrätig hat. In der Standardimplementierung eines TwoTableFormsheets würde eine NotEnoughElements-Exception geworfen. Diese dem Benutzer in ihrer reinen Form zu präsentieren ist sicher nicht die feine englische Art. Die drei einfachsten Reaktionsmöglichkeiten wären, entweder den Benutzer in einem Dialog über seine Fehleingabe zu informieren, die Fehleingabe einfach komplett zu ignorieren oder ihm die noch vorhandenen Exemplare zu verkaufen.

Die beiden letzteren Möglichkeiten werden wir hier kurz betrachten, und dabei gleich auf zwei Varianten eingehen, wie man mit MoveStrategies umgehen kann. MoveStrategies werden verwendet, um das Verschieben von Objekten zwischen CountingStocks, StoringStocks, Databaskets und Catalogs via TwoTableFormsheet zu erleichtern. Ein häfiger Anwendungsfall ist z.B. das Verschieben von Artikeln aus einem CountingStock in den Databasket des Kunden. Um dabei bestimmte Bedingungen zu überprüfen (z.B. die Anzahl der Artikel) kann eine MoveStrategy verwendet werden.
Aber zuerst kurz dazu, wie man eine MoveStrategy überhaupt einsetzt. Im Konstruktor unseres TwoTableFormsheets gibt es einen Parameter, der ein Objekt einer MoveStrategy-Implementierung erwartet. MoveStrategy ist lediglich eine abstrakte Oberklasse, von der SalesPoint schon einige Standard-Implementierungen zur Verfügung stellt.
Die MoveStrategy-Implementierungen folgen alle einer Namenskonvention:
<LinkerTeil><RechterTeil>Strategy
Linker Teil und Rechter Teil stehen dabei für die Inhalte eures TwoTableFormsheets. Für den ersten Fall, den wir hier betrachten, haben wir auf beiden Seiten einen CountingStock. Auf der linken Seite z.B. den Bestand des Markts und auf der rechten den Inhalt des Warenkorbs des Kunden. Somit ist die passende Standardimplementierung für uns die CSCSStrategy. Was wir also machen, ist dem TwoTableFormsheet-Konstruktor ein Objekt dieser Klasse zu übergeben.

CSCSStrategy cscss = new CSCSStrategy();
...
final TwoTableFormSheet ttfs = TwoTableFormSheet.create(
   "Produktauswahl",
   offer,
   shoppingBasket,
   db,
   uig,
   null,
   null,
   false,
   false,
   getTED(),
   getTED(),
   cscss);
	 

Wenn wir unser Formsheet so darstellen, und ein paar Transaktionen durchführen, merken wir schnell, dass immer noch der oben beschriebene Fehler auftritt, wir bekommen eine Exception geworfen, und der Benutzer bekommt diese zu Gesicht. Das ist ja nicht das, was wir wollten.
Also erweitern wir das ganze ein wenig. Nach der Erstellung unseres Strategy-Objektes fügen wir folgenden Code ein:

cscss.setErrorHandler(new FormSheetStrategy.ErrorHandler(){
   // ID für Serialisierung
   private static final long serialVersionUID = 9002356431043143409L;

   public void error(SaleProcess p, int nErrorCode) {
	if(nErrorCode==FormSheetStrategy.ErrorHandler.NOT_ENOUGH_ELEMENTS_ERROR){
	   p.getCurrentGate();
	}
	else p.error(nErrorCode);
   }
});
	

Was genau machen wir hier? Wir sagen unserem Strategy-Objekt, dass wir gerne selbst auf einen möglichen Fehler reagieren möchten. Wir übergeben der setErrorHandler-Methode also einen eigenen ErrorHandler, hier in Form einer anonymen Klasse. In dieser Klasse implementieren wir die Methode error(...). Diese wird aufgerufen, falls ein Fehler innerhalb der MoveStrategy auftritt. Der von uns nicht erwünschte Fehler ist der NOT_ENOUGH_ELEMENTS_ERROR. Für den Fall, dass dieser Fehler auftritt, machen wir nichts anderes als in unserem Zustand zu verharren, also auf unser momentanes Gate zu verweisen. Alle anderen Fehler-Codes leiten wir an unseren Prozess weiter, der diese dann verarbeiten kann. Somit haben wir zumindestens unser Minimalziel erreicht, der Fehler wird unserem Benutzer nicht mehr angezeigt.

Nun muss der Benutzer jedoch per Hand den Betrag korrigieren, damit er die noch vorhandenen Exemplare kaufen kann. Dass dies auch anders geht, zeigen wir an einem anderen Beispiel. Diesmal geht es um ein Verschieben zwischen einem Catalog und einem CountingStock. Unsere Ausgangsstrategie ist also CCSStrategy. Der Manager kann Waren aus einem Katalog kaufen. Das Problem tritt dann auf, wenn der Manager seine Einkaufsmenge korrigieren will. Er will mehr Artikel "zurückgeben" als er vorher bestellt hatte. Dies führt normalerweise zur gleichen Exception wie im obigen Beispiel. Wir könnten das Problem also genauso behandeln wie vorhin.
Jedoch wollen wir dem Manager einen gewissen Komfort gönnen ;-). Wir gehen also davon aus, dass wenn der Manager eine zu große Menge zurückgeben will, er eigentlich die noch vorhandene Menge zurückgeben wollte. Diesmal reicht es nicht, den ErrorHandler zu ändern. Wir wollen ja die Menge beim Verschieben der Artikel ändern. Also folgen wir den Prinzipien der objektorientierten Software-Entwicklung und leiten unsere eigene Strategy von CCSStrategy ab:

public class CCSStrategyMarket extends CCSStrategy {
   // ID für Serialisierung
   private static final long serialVersionUID = 4656404208314215041L;
   
   public CCSStrategyMarket() {
	super();
   }
   protected void moveToSource (SaleProcess p, SalesPoint sp, Catalog cSource,
	CountingStock csDest, DataBasket db, CatalogItem ci, int nCount) {
	   int count = csDest.countItems(ci.getName(),db);
	   count = Math.min(count, nCount);
	   try {
		csDest.remove (ci.getName(), count, db);
 	   }
	   catch (NotEnoughElementsException nee) {
		error (p, NOT_ENOUGH_ELEMENTS_ERROR);
	   }
	   catch (data.events.VetoException ve) {
		error (p, REMOVE_VETO_EXCEPTION);
	   }
	   catch (Exception e) {
		System.out.println(e);
	   }
   }
}

	

Die einzige Methode, die wir in unserem Fall überschreiben müssen, ist die moveToSource, also die Bewegung von rechts nach links. Wir ermitteln zuerst, wie groß der Bestand des gewünschten Artikels noch ist. Aus diesem und den vom Benutzer eingegebenen Wert ermitteln wir das Minimum und entfernen diese Anzahl aus dem CountingStock. Falls trotzdem noch Exceptions auftreten sollten, geben wir diese entweder an die oben beschriebene error-Funktion weiter, oder geben diese als Fehler in die Log-Datei bzw. auf die Console aus.
Welche der beiden vorgestellten Möglichkeiten nun für den jeweiligen Anwendungsfall besser geeignet ist, bleibt euch überlassen.


Die Testsphase

In vielen Phasenmodellen (auch in dem für dieses Projekt gewählten Wasserfallmodell) ist dem Testen eine eigene Phase gewidmet. Der unbedarfte Leser könnte jetzt annehmen, dass der Code erstmal geschrieben wird. Und zwar komplett. Danach schauen wir mal ob das ganze auch wirklich so funktioniert wie wir das wollen. Das dies nur in den aller seltensten Fällen auch wirklich funktioniert dürfte jedem, der schonmal etwas programmiert hat, schnell klar werden.
Daher werden wir den Test unter zwei Gesichtspunkten betrachten. Zum einen das Unit-Testing, also das Testen der einzelnen Funktionseinheiten (z.B. ein Package oder auch nur eine Klasse), und zum anderen der Integrationstest, auf den wir später eingehen werden. Beim Unit-Testing wird zuerst auf syntaktische Korrektheit (das macht der Compiler für uns) und später auch auf semantische Korrektheit überprüft. Die Semantik lässt sich wieder in zwei Teile unterteilen. Einerseits muss überprüft werden, ob der Code das, was er macht, richtig macht (- Verifikation), zum anderen, ob er denn auch wirklich das macht, was er machen soll. (- Validation)

Grundsätzlich unterscheidet man bei den Testmethoden zwischen WhiteBox- und BlackBox-Testing.
Der Begriff BlackBox ist sicher den meisten ein Begriff. Es geht hier nur darum die Funktion oder das Programm von außen zu betrachten. Also die Korrektheit einzig anhand der Ein- und Ausgaben zu überprüfen. Das WhiteBox-Testing hingegen versucht durch Analyse des Quelltextes und Abdeckungstests die Korrektheit zu prüfen. Wir werden im folgenden Teil nur auf den BlackBox-Teil eingehen. Weitergehende Informationen zum gesamten Kapitel (auch zum WhiteBox-Testing) finden sich im "Lehrbuch der Softwaretechnik Band 2" (Balzert, ab Seite 391).

Wie können wir also die Korrektheit im Rahmen eines BlackBox-Testes überprüfen?
Dazu müssen wir sogenannte Testfälle oder Testszenarien aufstellen.
Das haben wir im großen Rahmen schon für das Pflichtenheft gemacht. Dort haben wir ein paar globale Testfälle festgehalten, die das grundlegende Verhalten der Anwendung beschreiben.

Man kann diese Testfälle jedoch auch bis auf die Funktionsebene definieren.
Nehmen wir als Beispiel eine Funktion floatDivision(int dividend, int divisor);
Wir erwarten von dieser Funktion, dass sie den Dividenden durch den Divisor dividiert. Zurückgeben soll sie das Ergebnis der Division. Jetzt sollte man annehmen, dass das relativ trivial ist. Schließlich macht in fast allen Programmiersprachen der /-Operator genau dies.
Jedoch dürfen wir einen Spezialfall nicht vergessen, nämlich wenn der Divisor genau 0 ist. Dann ist die Division nicht definiert. Wenn wir also eine solche Funktion schreiben, müssen wir vorher festlegen, was für diesen Spezialfall passieren soll. Denkbar wäre das Werfen einer Exception oder ähnliches. Wenn wir dies vorher festgehalten haben, können wir unseren Testfall so formulieren, dass er genau dieses Verhalten auch überprüft.
Einige zu testende Fälle für obige Funktion wären also z.B. (0,1) (1,0) (1,1) (-1,1) usw., die erwarteten Ergebnisse wären (0,Exception,1,-1). Wir brauchen also nur noch die Funktion mit den Werten zu füttern und die Ergebnisse mit unseren Erwartungen zu vergleichen.

Ein Nachteil an den Testfällen ist, dass wir auch wirklich nur die Fehler finden, für die wir auch einen Testfall gefunden haben. Wir haben also trotz des Aufwandes keine Garantie dafür, dass wir alle Fehler im Code durch das Testen mit Testfällen finden. Wirklich sicher gehen könnten wir nur, wenn wir alle möglichen Eingaben in die Funktion in allen möglichen Kombinationen ausprobieren würden. Da dies schon bei unserem obigen Beispiel sehr aufwändig werden könnte ( jede int bietet immerhin 2³² Möglichkeiten, und was machen wir z.B. bei string-Parametern? ), bedient man sich meist sogenannter Eingabeklassen (Äquivalenzklassen). Wir überprüfen also nur die Fälle, die sich ein wenig von den anderen unterscheiden. Welche Klassen könnten wir für unser obiges Beispiel finden? Eine Klasse wären z.B. alle geraden Zahlen, oder alle ungeraden Zahlen. Der 0 werden wir eine eigene Klasse geben, nach positiv und negativ werden wir vielleicht noch unterscheiden. Und zuletzt nimmt man meist noch die Grenzen des Werteraums der Variablen. Also z.B. die kleinste und die größte mögliche int-Zahl. Somit haben wir das Problem schon auf relativ wenige Fälle begrenzt. Jedoch ist es relativ zeitaufwändig, die Funktion per Hand mit den Werten zu füttern, und danach die Ergebnisse zu vergleichen.
Doch auch hier gibt es ein wenig Hilfe durch den Rechner. Es existieren Unit-Test-Frameworks (bekanntestes Beispiel ist JUnit), die dem Anwender einen gewissen Teil der Arbeit abnehmen, indem sie viele Schritte automatisieren. Sie können dem Benutzer jedoch nicht das Suchen/Finden von Testfällen abnehmen.
Ein möglicher Weg, um auf Testfälle zu kommen kann zum Beispiel sein, sich den dümmsten anzunehmenden Benutzer (in unserem Fall Programmierer) vorzustellen, der mit dem von uns geschriebenen Code arbeiten muss. Wenn wir selbst unseren Code verwenden, wissen wir ganz genau, welche Werte sinvoll als Parameterübergabe an unsere Funktionen sind. Für den unbedarften und nicht eingearbeiteten Programmierer ist dieses jedoch nicht immer ersichtlich. Wenn man sich in seine Lage versetzt, und überlegt was man denn alles mit den von uns angeboteten Funktionen anstellen könnte, wird man einen größeren Teil der Fehler finden, als wenn man nur mit "normalen" Eingabewerten arbeitet. Dies erfordert anfangs etwas Fantasie, mit etwas Übung wird man aber auch hier zum Meister.

Wir haben bis jetzt immer nur von einzelnen Funktionen gesprochen. Die nächst größere Unit ist die Klasse, die jedoch meist nur eine Sammlung von Funktionen ist. Wenn also alle Funktionen das machen, was man von ihnen erwartet (und das auch korrekt), so macht auch die Klasse das was sie soll (und auch korrekt).

Nachdem wir alle Klassen einzeln für sich überprüft haben folgt der Integrationstest. Hier wird das Zusammenspiel der einzelnen Units überprüft. Von der Theorie her sollte eigentlich alles funktionieren, da ja die Schnittstellen zwischen den Klassen vor der Implementierung im Entwurf festgehalten wurden. Leider sind die wenigsten Entwürfe perfekt, und auch in der Implementierung haben sich Fehler eingeschlichen, die durch das Unit-Testing nicht gefunden werden konnten.
Zudem können Nebeneffekte auftreten, die nur im Zusammenspiel der einzelnen Teile auftreten (- Threading). Daher müssen die Programmteile nicht nur in ihrer Isolation, sondern auch in ihrem Zusammenwirken getestet werden.
Die oben beschriebenen Testmethoden können hier nur relativ schwer angewendet werden, da die Benutzung des Programms doch hauptsächlich aus einer Reihenfolge von Benutzereingaben besteht, die in den seltensten Fällen deterministisch ist ;).
Auch hier könnten wir natürlich alle Fälle durchtesten, die kombinatorisch machbar sind, jedoch würde dies die Praktikumszeit bei weitem überschreiten. Also müssen wir hier etwas anders vorgehen.
Ausgangspunkt sind auch hier wieder Testfälle. Da diese Testfälle nicht vom Himmel fallen, müssen wir sie uns erarbeiten. Ausgangspunkt sind die in der Analyse erstellten Use-Case-Diagramme. Sie beschreiben in einer Diagrammform die typischen Anwendungsfälle unserer Anwendung. Der normale Benutzer wird also genau diese Funktionen in unserem Programm ausführen. Wenn diese fehlerfrei funktionieren, haben wir schon einiges gewonnen. Zwar haben wir damit noch kein garantiert fehlerfreies Programm, aber eine gute Ausgangsposition, um später die versteckten Fehler zu finden. Damit ihr euch nicht zum ersten Mal beim Testen über diese Fälle Gedanken macht, solltet ihr im Pflichtenheft ein paar globale Testfälle aus den Use-Cases erarbeiten. Diese werden jetzt zur Anwendung kommen. Die Testfälle beschreiben jetzt typische Benutzervorgänge, wie z.B. das Einloggen, oder das Ausführen einer Kaufhandlung. Auch diese verknüpfen wir mit einer Erwartungshaltung (z.B. sollte nur das Einloggen mit einem gültigen Benutzernamen möglich sein). Nach dem Aufstellen der Testfälle ist es also nun an uns, die für das Erfüllen des Falles nötigen Schritte im Programm auszuführen, und die Ergebnisse mit unseren Erwartungen zu vergleichen.
Dabei hat sich die Aufstellung eines Testprotokolls als sehr hilfreich herausgestellt.
Dafür werden für jeden Testfall die benötigten Ausführungsschritte, das erwartete Ergebnis und das reale Ergebnis notiert.
Der Vorteil eines solchen Protokolls ist die Möglichkeit es wiederholt zu benutzen. Nachdem ein Fehler behoben wurde, kann man die gleichen Testfälle noch einmal testen, um zu sehen ob man, zum einen den Fehler auch wirklich behoben hat, und viel wichtiger, ob alle anderen Testfälle immer noch funktionieren. Wenn wir nur den Testfall noch einmal prüfen würden, wo wir den Fehler gerade behoben haben, dann hätten wir den in der Einleitung beschriebenen Effekt, dass wir sich neu eingeschlichene Fehler nicht finden würden.

Neben den hier beschriebenen systematischen Testverfahren ist auch ein weniger systematisches Testen vielfach sehr hilfreich. Hier geht es dann meist darum andere Benutzungsabläufe als die des Programmierers zu testen. Der Entwickler nimmt meist die selben Wege, um zu einem Ergebnis zu kommen. Jemand der das Programm nicht kennt, wird vielleicht andere Wege nehmen, die der Entwickler weniger oder gar nicht getestet hat. Man sollte also sein Programm (sobald es relativ stabil ist) von Leuten testen lassen, die mit der Entwicklung nichts zu tun hatten. Dabei kann man ihnen Ziele vorgeben (z.B. kaufe 5 Einheiten von Produkt A etc.), oder sie einfach eine Weile lang am Programm herumspielen lassen. Als Nebeneffekt kann man gleich noch sehen, ob die Benutzerführung halbwegs intuitiv ist, bzw. sein Benutzerhandbuch auf seine Anwendbarkeit testen.

Um ein wenig von den abstrakten Beschreibungen weg zu kommen, und dem geneigten Leser deutlich zu machen, wie ein solches Testen denn z.B. im Großmarkt-Projekt ausgesehen hat, folgen hier jetzt zwei Beispiele, welche die unterschiedlichen Aspekte des Testens noch einmal deutlich machen sollen.

Den Funktionstests kommt im Rahmen des Softwarepraktikums eine eher geringe Bedeutung zu, da man relativ wenige Funktionen schreiben wird, die aus einer Parametereingabe etwas berechnen sollen. Meist wird man auf dem vorhandenen Daten etwas berechnen, bzw. neue Daten in die diversen Kataloge und Bestände hinzufügen.
Ab und zu ersparen einem aber Funktiontests spätere Nervenzusammenbrüche.

private boolean hasMonthChanged(int daysAdvanced) {
   int dayOfMonth = getTime().get(Calendar.DAY_OF_MONTH);
	return daysAdvanced > dayOfMonth;
}
	

Betrachten wir also die gegebene Funktion. Sie soll aus einem aktuellen Datum (welches mit getTime() ermittelt wird) und der Anzahl der vergangenen Tage in der Simulation (wenn der Manager die Zeit weitergedreht hat) berechnen, ob ein Monatswechsel zwischen den beiden Daten liegt. Die Funktionalität wird für diverse Statistikberechnungen benötigt.
Nehmen wir an, dass nicht der Schreiber dieser Funktion sie testen soll. Wie geht er vor?
Zuerst einmal überlegen wir uns, welche Werte daysAdvanced denn annehmen könnte, und wie unsere Funktion darauf reagieren sollte.

Die zweite Eingabegröße ist das neue Datum. Dieses kann alle gültigen Daten annehmen, die die Calendar-Klasse erlaubt.
Wir müssen also die kritischen Kombinationen der beiden Eingabegrößen finden, die uns Probleme bringen könnte.
Beginnen wir mit einem einfachen Fall. Nehmen wir an wir haben vom 15.Juli auf den 15.August des selben Jahres weitergeschaltet. Unser aktuelles Datum ist also der 15.August. Die vergangenen Tage sind 31. Nach obiger Funktion berechnen wir also zuerst dayOfMonth, welches für unser Beispiel 15 ergibt. Wir prüfen, ob daysAdvanced (31) größer ist als dayofMonth (15). Dies ist der Fall, also lag ein Monatswechsel dazwischen.
Als nächstes versuchen wir den Negativ-Fall. Wir stellen vom 4.Februar auf den 16.Februar vor.
dayAdvanced=12; dayofMonth=16. Der Vergleich ergibt diesmal also ein false. Es lag also kein Monatswechsel zwischen den beiden Daten.
Bis jetzt scheint unsere Funktion ja zu tun was sie soll. Wie man sich aber vielleicht schon denken kann, zeigen wir hier keine Funktion zum Testen, die genau das macht, was sie soll.
Der aufmerksame Leser hat vielleicht den Problemfall schon gesehen.
Nehmen wir als weiteren Fall den eigentlichen Monatswechsel, also das "Weiterschalten" von nur einem Tag, wie es uns aus der Realität am vertrautesten ist.
Für das Beispiel vom 31.März auf den 1.April. daysAdvanced ist 1. dayofMonth ist ebenfalls 1. Somit würde unsere Funktion ein false zurückgeben. Aus unserer Erfahrung wissen wir jedoch, dass März und April verschiedene Monate sind. Unsere Funktion hat also einen Fehler.
Die Behebung desselben ergibt sich direkt aus der Fehleranalyse. Wir haben festgestellt, dass wenn daysAdvanced und dayofMonth gleich sind, auch ein Monatswechsel stattgefunden hat. Also müssen wir unser

return daysAdvanced > dayOfMonth;
	

in ein

return daysAdvanced >= dayOfMonth;
	

umwandeln.
Nach einer Änderung der Funktion müssten wir nun eigentlich den kompletten Test wiederholen. Da unsere Testfälle jedoch (bis auf den Fehlerfall) die Gleichheit der beiden Eingangsgrößen nie ergeben haben, bleibt unser Testprotokoll gültig.
Der noch nicht überzeugte Leser oder Tester mag jetzt noch weitere Fälle ausprobieren. Wenn man an Kalender denkt, fallen einem sofort noch ein paar Spezialfälle ein (Jahreswechsel, Schaltjahre etc.). Um den Umfang des Tutorials nicht zu sprengen, haben wir hier auf die Beschreibung dieser Fälle verzichtet, sie zeigen aber die Korrektheit der Funktion.
Was man vielleicht neben dem Testverfahren auch noch ganz gut sehen kann, ist wie schwierig und fehleranfällig die korrekte Behandlung der <,>,<=,>= Operatoren ist. Sie sind wahrscheinlich eine der häufigsten Fehlerquellen in sehr vielen Programmen. Wenn also eine Funktion beim Testen ihre Funktionalität nicht erfüllt, sind die größer-kleiner-Operatoren ein erster Hinweis. Dies gilt insbesondere für Verzweigungs- oder Schleifenbedingungen.

Der zweite Testfall ist sicherlich der häufigere im Salespoint-Praktikum.
Der hier betrachtete Testfall wurde aus dem Use-Case-Diagramm "Kunde" (hier dann ein Link auf das Diagramm) gewonnen. Er ist nur einer von sehr vielen.
Die in diesem Testfall abgedeckten Aktionen sind "wähle Waren aus" und "bezahle Einkauf".
Eine Testfallbeschreibung dazu könnte folgendermaßen aussehen:
"Ein Kunde betritt den Großmarkt, wählt sich aus dem Sortiment 10 Bürostühle aus, und fügt sie seinem (elektronischen) Warenkorb hinzu. Er geht danach zur Kasse und bezahlt seine 10 Stühle."
Um diesen Testfall zu testen müssen wir also folgende Teilschritte im Programm durchführen:

Danach ist der Testfall abgeschlossen. Diese Vorgänge können wir in eine Tabelle schreiben, und beim Testen im Programm abhaken bzw. mit einer Fehlerbeschreibung versehen. Nach einer Fehlerbeseitigung ist das gleiche Protokoll dann erneut durchzuführen.

Im Testprotokoll (Exceltabelle "Kunde I") ist genau dies verzeichnet. Es geht alles glatt.
Nun erweitern wir den Testfall ein wenig. (Kunde I a)). "Während er an der Kasse ist, fällt dem Kunden noch siedendheiß ein, dass er zuwenige Stühle gekauft hat. Er geht also schnell noch einmal in den Markt und kauft noch 5 Stühle nach. An der Kasse wird aber zuerst seine erste Bestellung (die 10 Stühle) abgerechnet, danach sein Nachkauf (das habe technische Gründe, sagt der Kassiererer). Zufrieden verlässt der Kunde den Laden".
Es werden ein paar mehr Schritte benötigt, um den Testfall durchzuspielen (siehe Testprotokoll). Aus dem Protokoll wird auch ersichtlich, dass nicht alles so geklappt hat wie es sollte. Es kommt zu einem Fehler, der einem bei purer Code-Betrachtung wohl nur sehr schwer aufgefallen wäre, weil er ein komplexes Zusammenspiel der einzelnen Komponenten benötigt.
Umso komplexer ist auch die Fehlerbehandlung, auf die wir hier nicht weiter eingehen wollen. Der Fehler ist mit Absicht auch noch im Code vorhanden, damit ihr einmal seht, wie sich der Fehler im Programmablauf auswirkt.
Das hier beschriebene Testprotokoll ist sicher sehr umfangreich, und würde auf alle Testfälle angewandt den Zeitrahmen des Praktikums sprengen. Aber zumindest für die globalen Testfälle, die im Pflichtenheft beschrieben sind, ist ein solches Protokoll zu erstellen.


previous Durchführung der EntwurfsphaseDie Wartungsphase next



by Thomas Ryssel