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 SalesPoint
s 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
UserManager
s 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
Stock
s (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 Stock
s 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 Value
s
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 JLabel
s 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
FormButton
s, 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
FormSheet
s 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
Action
s 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
MenuSheetItem
s 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 FormSheet
s 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 CountingStock
s der beiden Tabellen ist der DataBasket
.
DataBasket
s sind fundamental wichtig für das Verschieben von Elementen
zwischen den verschiedenen Datenstrukturen des Frameworks. Eine add
- oder
remove
-Methode mit Übergabe eines DataBasket
s 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 DataBasket
s 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 CountingStock
s 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
TwoTableFormSheet
s 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 DataBasket
s aus welchen Gründen auch immer im
SaleProcess
erfolgen, so sollte dies auf keinen Fall im Konstruktor passieren!).
Die Initialisierung des benötigten DataBasket
s 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 TwoTableFormSheet
s 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 TableEntryDescriptor
s 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 TwoTableFormSheet
s 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 FormButton
s 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
SPCustomer
s 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
FormSheet
s 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 AffirmationGate
s 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.
- daysAdvanced < 0. Das hieße es wäre eine negative Anzahl von Tagen vergangen. Da Zeitreisen in unserer Realität nicht möglich sind, sollte hier also ein false zurückgegeben werden.
- daysAdvanced = 0. Wir wären also keinen Tag weitergegangen. Dies impliziert, dass auch kein Monatswechsel stattgefunden haben kann. Also auch hier sollte false zurückgegeben werden.
- daysAdvances > 0. Das ist der eigentlich interessante Fall. Das Intervall ist sicherlich nach oben offen, da in der Simulation auch sehr große Datumssprünge möglich sind. Hier müssen wir also genauer hinschauen.
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:
- Programm starten
- Kunden anmelden
- Einkaufen, 10 Bürostühle auswählen und in den Warenkorb verschieben
- 2 mal mit Kaufen bestätigen
- Kassierer anmelden
- Kunden aus der Warteschlange auswählen
- Abrechnen und Zahlen
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.
Durchführung der Entwurfsphase | Die Wartungsphase |