2012-04-21

TDD utflykt i OpenLaszlo land, del 2

En utmaning när man börjar med något nytt är att tänka i andra banor. Om man bara har arbetat med hammare och spik och ska börja använda skruvmejsel och skruvar så tycker man att det är väldigt svårt att banka in skruvarna med skruvmejseln.

När det handlar om mjukvaruutveckling finns det mycket som kan orsaka problem. Det kan till exempel finnas medvetna begränsningar för att underlätta andra saker, det kan vara bristfällig dokumentation eller för mycket dokumentation. Och ibland handlar det om att använda fel verktyg för problemet.




Även om man inte ska tänka för mycket framåt och försöka tvinga fram en design när man TDD:ar så kan jag inte låta bli att fundera på det som är gjort hittills. Det är för mycket begärt att någon ska kunna placera myntet på exakt rätt position. Det behövs lite vingelutrymme. Övriga mynt ska aldrig flyttas, vilket innebär att oavsett var de placeras så är det fel position. Det går förvisso att lösa med en position väldigt långt borta och hoppas att myntet aldrig hamnar där.

Istället för att belasta Coin-klassen med detta vore det snyggare om en annan klass tog hand om det. Då skulle det också vara enkelt att lösa problemet med mynten som inte ska flyttas. Detta kräver dock att Coin-klassen får tag på objektet. Dependency injection via konstruktorn brukar vara det vanligaste sättet, men det går även med en initieringsmetod.

För att kunna testa dependency injection underlättar det om det är möjligt att instansiera objekt i respektive testfall (metod). Därför försöker jag lösa problemet med objektinstansiering innan jag går vidare med ytterligare test.

Efter att ha tittat lite i dokumentationen hittade jag att man kan skapa objektinstanser genom att använda new.

var coin = new Coin( ... );

Efter ca en timmes experimenterande, utan att få det att fungera, var jag nära att ge upp. Oavsett hur jag gjorde klagade kompilatorn på att jag försökte anropa en odefinierad metod Coin. Jag kunde inte heller hitta något exempel i dokumentationen på hur man skulle göra.

Till slut, efter att ha sökt genom den exempelkod som följer med vid installationen, hittade jag vad som var problemet. Tydligen ska man skriva "lz." framför klassnamnet, dvs new lz.Coin( ... ).

Det här med att jag inte kunde hitta något exempel i dokumentationen på hur man instansierar objekt med new gör mig lite tveksam. Det är tydligt att man kan göra det, men det betyder nödvändigtvis inte att man ska göra det. Nåja, jag fortsätter att hamra vidare så får tiden utvisa om det är en hammare eller skruvmejsel jag har i handen.




För att underlätta har jag lagt själva instansieringen i en egen metod. Samtliga testfall modifieras enligt nedan.

<method name="createCoin" args="argX, argY"> return new lz.Coin(this, { x : argX, y : argY }); </method> <method name="testInitialPosition"> var coin = createCoin(10, 20); assertEquals("10", coin.x); assertEquals("20", coin.y); </method>

Vidare mot klassen som ska avgöra om en position är tillräckligt nära en annan. Jag börjar med ett enkelt test.

<library> <include href="TargetPosition.lzx" /> <class name="TargetPositionTests" extends="TestCase"> <TargetPosition name="targetPosition" x="100" y="100" /> <method name="testNearTarget"> assertTrue(targetPosition.isClose(100, 100)); </method> <method name="addTests"> this.addTest("testNearTarget"); </method> </class> </library>

Efter att ha skapat en ny klass TargetPosition och hårdkodat isClose() att returnera sant går testet igenom. Lättaste sättet att definiera ett område inom vilket myntet måste flyttas är att ange ett avstånd från en position. I det här sammanhanget skulle det kanske inte göra så mycket att hårdkoda avståndet. Problemet är om det senare skulle visa sig att man behöver ändra på avståndet. Då finns det en risk att testerna slutar fungera. Det är främsta anledningen till att jag väljer att kunna ange avståndet explicit, testerna blir oberoende av avståndet.

Implementationen av det hela är inte speciellt svår. Det handlar bara om att applicera avståndsformeln samt ett par test nära och några långt ifrån positionen. Det är inte så mycket att TDD:a fram. Slutresultatet blev TargetPosition nedan.

<class name="TargetPosition"> <attribute name="dist" /> <method name="isClose" args="posX, posY" > return Math.sqrt(Math.pow(posX - this.x, 2) + Math.pow(posY - this.y, 2)) &lt;= this.dist; </method> </class>

Det är med blandade känslor jag fortsätter. Trots att jag är säker på att klassen för avståndsberäkning kommer att vara användbar så kan det här vara ett typiskt fall av YAGNI.

Med klassen för avståndsberäkning på plats är det dags att använda den. Jag lägger till en ny instansieringsmetod och låter den skapa en instans av TargetPosition och knyta till Coin.

<method name="createCoinTarget" args="argX, argY, tposX, tposY"> var coin = createCoin(argX, argY); var targetPos = new lz.TargetPosition(this, { x : tposX, y : tposY, dist : 5 }); coin.setTarget(targetPos); return coin; </method>

Coin ändras till att använda TargetPosition istället.

<class name="Coin"> <attribute name="targetPos" /> <method name="setTarget" args="targetPos"> this.targetPos = targetPos; </method> <method name="checkMove"> if (this.targetPos.isClose(this.x, this.y)) this.parent.doCorrectMove(); else this.parent.doIncorrectMove(); </method> ...

Jag är inte helt nöjd med den nya, relativt komplicerade instansieringsmetoden. Jag misstänker att när det är dags att skapa de "riktiga" mynten, dvs de som ska användas i mysteryn, kommer en liknande metod behövas. Men jag tar det problemet när det kommer.




I OpenLaszlo finns det inbyggt stöd för "dragging", alltså när ett objekt följer muspekaren. Det är två saker som behövs. Dels skapa ett specifikt dragstate-element. Dels slå på och slå av tillståndet genom att anropa lämpliga skript vid musknapp ned respektive upp. Nedan är ett minimalt exempel som illustration. Lägg märke till att skriptet vid musknapp upp även anropar metoden stopDrag().

<view onmousedown="dragging.setAttribute('applied', true)" onmouseup="dragging.setAttribute('applied', false); stopDrag()"> <dragstate name="dragging"/> <method name="stopDrag"> // Do something... </method>

I vårt fall kommer stopDrag() motsvaras av checkMove() i Coin. När jag testade detta märkte jag, och det är väl egentligen ganska självklart, att stopDrag() anropades oavsett om objektet hade flyttats eller inte. Detta betyder att ett klick på ett mynt tolkas som en flytt, vilket både kan vara förvirrande och irriterande.

Därför tänker jag inte kontrollera resultatet av en flytt om myntet inte har flyttats. Men innan jag gör det ska jag skapa ett test som kontrollerar att dragstate sätts korrekt i samband med musknapp ned och upp.

<method name="testVerifyDragstate"> var coin = createCoinTarget(10, 20, 20, 30); assertFalse(coin.moving.applied); coin.onmousedown.sendEvent(); assertTrue(coin.moving.applied); coin.onmouseup.sendEvent(); assertFalse(coin.moving.applied); </method>

Jag kallar tillståndet "moving" eftersom det känns mer beskrivande. Vidare visade det sig vara förvånadsvärt lätt att explicit avfyra händelser, bara anropa sendEvent().

I Coin har jag valt att använda handler-element istället för att ange skripten i attribut som i exemplet ovan, det blir tydligare kod då.

<class name="Coin"> <dragstate name="moving" /> <handler name="onmousedown"> this.moving.applied = true; </handler> <handler name="onmouseup"> this.moving.applied = false; </handler> ...

Därefter anropar jag checkMove() från onmouseup. Jag ändrar testfallen till att avfyra händelsen istället för att anropa checkMove() direkt.

Tillbaka till att ta hand om klick utan flytt. Det som ska göras är enkelt. Jämför den nuvarande positionen med ursprungspositionen. Om de är samma, ignorera musklicket, annars kontrollera resultatet av flytten. Testfallet är enkelt, bara verifiera att varken doCorrectMove() eller doIncorrectMove() blir anropade.

<method name="testIgnoreClickWithoutMove"> var coin = createCoinTarget(10, 20, 20, 30); coin.onmouseup.sendEvent(); assertFalse(this.incorrectlyMoved); assertFalse(this.correctlyMoved); </method>

För att kunna avgöra om myntet har flyttats eller ej måste vi spara undan dess ursprungsposition. Jag väljer därför att först kommentera bort testfallet ovan (för att inte ha ett fallerat test innan jag börjar arbeta med ett annat). Därefter modifierar jag det första testfallet att verifiera värdet av två nya attribut.

<method name="testInitialPosition"> var coin = createCoin(10, 20); assertEquals("10", coin.x); assertEquals("20", coin.y); assertEquals("10", coin.startX); assertEquals("20", coin.startY); </method>

Det finns en speciellt händelse, oninit, som anropas när ett objekt initieras. Jag låter den kopiera värdena av x och y.

Implementationen av klickkontrollen är enkel nu när vi har ursprungspositionen.

<handler name="onmouseup"> this.moving.applied = false; if (hasMoved()) checkMove(); </handler> <method name="hasMoved"> return this.x != this.startX || this.y != this.startY; </method>

Men när jag nu kör testfallen så fallerar de två testen för flytt till rätt resp. fel position. Anledningen till detta är hur själva testen är gjorda. Eftersom ingen faktisk flytt av myntet görs förblir x och y samma som ursprungspositionen. Jag råder bot på detta genom att lägga till en hjälpfunktion som simulerar en flytt ocn anropar den i respektive testfall.

<method name="moveCoin" args="coin, newX, newY"> coin.onmousedown.sendEvent(); coin.x = newX; coin.y = newY; coin.onmouseup.sendEvent(); </method>

En sista sak innan Coin-klassen kan betraktas som färdig, i alla fall ur ett TDD-perspektiv. Jag har ju redan en klass för att avgöra om en position är nära en annan position. Jag tänker låta den ersätta attributen startX och startY. Skillnaden syns nedan. Dessutom tar jag bort metoden setTarget(), det går lika bra att tilldela attributet direkt.

<class name="Coin"> <TargetPosition name="startPos" dist="2" /> <handler name="oninit"> this.startPos.x = this.x; this.startPos.y = this.y; </handler> <method name="hasMoved"> return !this.startPos.isClose(this.x, this.y); </method>


Nu är det dags att prova Coin-klassen i en tillämpning. Nedan följer en minimal applikation. Resultatet av en flytt visas i en modal dialog. Anledningen till detta är att efter en flytt ska det inte vara möjligt att flytta ytterligare mynt. För alla mynt utom ett används ett objekt som säger att alla positioner är fel. På så sätt kan mynten aldrig flyttas till korrekt position. Endast det rätta myntet har en riktig slutposition.

<canvas width="500" height="400"> <include href="Coin.lzx" /> <class name="InvalidPosition"> <method name="isClose" args="posx, posy"> return false; </method> </class> <modaldialog name="correctMove" title="Grattis" closeable="false"> <text>Grattis, du har löst problemet!</text> </modaldialog> <modaldialog name="incorrectMove" title="Tyvärr" closeable="false"> <text>Otur, men det var tyvärr fel. Ladda om sidan och försök igen.</text> </modaldialog> <InvalidPosition name="invalidPos" /> <TargetPosition name="endPos" x="200" y="200" dist="5" /> <Coin x="100" y="100" resource="coin.png" targetPos="${this.parent.endPos}" /> <Coin x="200" y="100" resource="coin.png" targetPos="${this.parent.invalidPos}" /> <Coin x="300" y="100" resource="coin.png" targetPos="${this.parent.invalidPos}" /> <Coin x="300" y="200" resource="coin.png" targetPos="${this.parent.invalidPos}" /> <method name="doCorrectMove"> this.correctMove.open(); </method> <method name="doIncorrectMove"> this.incorrectMove.open(); </method> </canvas>

När jag provade applikationen för första gången ville mynten inte flytta på sig. Det visade sig beror på att dragstate inte aktiverades. Detta för att jag satte attributet applied direkt. När man gör det generaras inga händelser, vilket verkar behövas. Lösningen var att sätta attributet med metoden setAttribute() istället.

Sedan visade det sig att vissa av mynten hamnade under andra när man flyttade dem. Det hade att göra med vilken ordning de skapats i, då den ordningen också avgjorde deras z-order. Detta löstes genom att anropa BringToFront() på det mynt som flyttas.

Den som vill prova applikationen ovan kan klicka här.




Jag har mycket kvar att lära om OpenLaszlo, men någonting säger mig att det kommer att dröja tills nästa gång. Att göra en mer avancerad applikation hade säkert varit mer givande. Men mitt mål med att skapa en mystery är i alla fall uppnått.

Inga kommentarer: