ATM Guide
This guide will walk you through building and testing an Automated Teller Machine (ATM) application with CanJS’s Core libraries. You’ll learn how to do test driven development (TDD) and manage complex state. It takes about 2 hours to complete.
Overview
Check out the final app:
Notice it has tests at the bottom of the Output
tab.
Setup
The easiest way to get started is to clone the following JS Bin by clicking the JS Bin button on the top left:
The JS Bin is designed to run both the application and its tests in the OUTPUT
tab. To set this up, the HTML tab:
Loads QUnit for its testing library. It also includes the
<div id="qunit"></div>
element where QUnit’s test results will be written to.Loads can.all.js, which is a script that includes all of CanJS core under a single global
can
namespace.Generally speaking, you should not use the global
can
script, but instead you should import things directly with a module loader like StealJS, WebPack or Browserify. Read Setting Up CanJS for instructions on how to set up CanJS in a real app.Includes the content for an
app-template
can-stache template. This template provides the title for the ATM app and uses the<atm-machine>
custom can-component element that will eventually provide the ATM functionality.
The JavaScript
tab is split into two sections:
CODE
- The ATM’s models, view-models and component code will go here.TESTS
- The ATM’s tests will go here.
Normally, your application’s code and tests will be in separate files and loaded by different html pages, but we combine them here to fit within JS Bin’s limitations.
The CODE
section renders the app-template
with:
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
The TESTS
section labels which module will be tested:
QUnit.module("ATM system", {});
Mock out switching between pages
In this section, we will mock out which pages will be shown as the state
of the ATM
changes.
Update the HTML tab to:
- Switch between different pages of the application as the
ATM
view-model’sstate
property changes with {{#switch(expression)}}.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the JavaScript
tab to:
- Create the
ATM
view-model with astate
property initialized toreadingCard
with can-define/map/map. - Create an
<atm-machine>
custom element with can-component.
// ========================================
// CODE
// ========================================
var ATM = can.DefineMap.extend({
state: {type: "string", value: "readingCard"}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {});
When complete, you should see the “Reading Card” title.
This step includes all the potential pages the state
property can transition between:
- readingCard
- readingPin
- choosingTransaction
- pickingAccount
- depositInfo
- withdrawalInfo
- successfulTransaction
- printingReceipt
Each of those states are present in the following state diagram:
We’ll build out these pages once we build the Card
and Transaction
sub-models that will make building the ATM view model easier.
Card tests
In this section, we will:
- Design an API for an ATM
Card
- Write out tests for the card.
An ATM Card
will take a card number
and pin
. It will start out as
having a state
of "unverified"
. It will have a verify
method
that will change the state
to "verifying"
, and if the response is successful,
state
will change to "verified"
.
Update the JavaScript
tab to:
- Make the fake data request delay
1ms
by setting delay to1
before every test and restoring it to2s
after every test runs. - Write a test that creates a valid card, calls
.verify()
, and asserts thestate
is"verified"
. - Write a test that creates an invalid card, calls
.verify()
, and asserts thestate
is"invalid"
.
// ========================================
// CODE
// ========================================
var ATM = can.DefineMap.extend({
state: {type: "string", value: "readingCard"}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
When complete, you should have a breaking test. Now let’s make it pass.
Card model
In this section, we will:
- Implement the
Card
model so that all the tests pass.
Update the JavaScript
tab to:
- Simulate the
/verifyCard
with can-fixture. It will return a successful response if the request body has anumber
andpin
, or a400
if not. - Use can-define/map/map to define the
Card
model, including:- a
number
and apin
property. - a
state
property initialized tounverified
that is not part of the card’s serialized data. - a
verify
method that posts the card’s data to/verifyCard
and updates thestate
accordingly.
- a
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var ATM = can.DefineMap.extend({
state: {type: "string", value: "readingCard"}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
When complete, all tests should pass.
In this step, you implemented a Card
model that encapsulates the behavior of its own state.
Deposit test
In this section, we will:
- Design an API retrieving
Account
s. - Design an API for a
Deposit
type. - Write out tests for the
Deposit
type.
An Account
will have an id
, name
, and balance
. We’ll use can-connect to add a
getList method that retrieves an account given a card
.
A Deposit
will take a card
, an amount
, and an account
. Deposits will start out having
a state
of "invalid"
. When the deposit has a card
, amount
and account
, the state
will change to "ready"
. Once the deposit is ready, the .execute()
method will change the state
to "executing"
and then to "executed"
once the transaction completes.
Update the JavaScript
tab to:
- Create a
deposit
with anamount
and acard
. - Check that the
state
is"invalid"
because there is noaccount
. - Use
Account.getList
to get the accounts for the card and:- set the
deposit.accounts
to the first account. - remember the starting
balance
.
- set the
- Use on to listen for
state
changes. Whenstate
is:"ready"
,.execute()
the transaction."executed"
, verify the new account balance.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var ATM = can.DefineMap.extend({
state: {type: "string", value: "readingCard"}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
When complete, the Deposit test should run, but error because Deposit is not defined.
Optional: Challenge yourself by writing the Withdrawal test on your own. How is it different than the Deposit test?
Transaction, Deposit, and Withdrawal models
In this section, we will:
- Implement the
Account
model. - Implement a base
Transaction
model and extend it intoDeposit
andWithdrawal
models. - Get the Deposit test to pass.
Update the JavaScript
tab to:
- Simulate
/accounts
to returnAccount
data with can-fixture. - Simulate
/deposit
to always return a successful result. - Simulate
/withdrawal
to always return a successful result. - Define the
Account
model to:- have an
id
property. - have a
balance
property. - have a
name
property.
- have an
- Define an
Account.List
type with can-define/list/list. - Connect
Account
andAccount.List
types to the RESTful/accounts
endpoint using can-connect/can/base-map/base-map. - Define the
Transaction
model to:- have
account
andcard
properties. - have
executing
andexecuted
properties that track if the transaction is executing or has executed. - have a
rejected
property that stores the error given for a failed transaction. - have an abstract
ready
property thatDeposit
andWithdrawal
will implement to returntrue
when the transaction is in an executable state. - have a
state
property that reads other stateful properties and returns a string representation of the state. - have an abstract
executeStart
method thatDeposit
andWithdrawal
will implement to execute the transaction and return aPromise
that resolves when the transaction is complete. - have an abstract
executeEnd
method thatDeposit
andWithdrawal
will implement to update the transactions values (typically theaccount
balance) if the transaction is successfully completed. - have an
execute
method that calls.executeStart()
andexecuteEnd()
and keeps the stateful properties updated correctly.
- have
- Define the
Deposit
model to:- have an
amount
property. - implement
ready
to returntrue
when the amount is greater than0
and there’s anaccount
andcard
. - implement
executeStart
toPOST
the deposit information to/deposit
- implement
executeEnd
to update the account balance.
- have an
- Define the
Withdrawal
model to behave in the same way asDeposit
except that itPOST
s the withdrawal information to/withdrawal
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
state: {type: "string", value: "readingCard"}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
When complete, the Deposit tests will pass.
Reading Card page and test
In this section, we will:
- Allow the user to enter a card number and go to the Reading Pin page.
- Add tests to the ATM Basics test.
Update the HTML tab to:
- Allow a user to call
cardNumber
with the<input>
’svalue
.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the JavaScript
tab to:
- Declare a
card
property. - Derive a
state
property that changes to"readingPin"
whencard
is defined. - Add a
cardNumber
that creates acard
with the providednumber
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
// derived properties
get state(){
if(this.card) {
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
QUnit.start();
});
When complete, you should be able to enter a card number and see the Reading Pin page.
Reading Pin page and test
In this section, we will:
- Allow the user to enter a pin number and go to the Choosing Transaction page.
- Add tests to the ATM Basics test.
Update the HTML tab to:
- Call
pinNumber
with the<input>
’svalue
. - Disable the
<input>
while the pin is being verified. - Show a loading icon while the pin is being verified.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM
view model in the CODE
section of the JavaScript
tab to:
- Define an
accountsPromise
property that will contain a list of accounts for thecard
. - Define a
transactions
property that will contain a list of transactions for this session. - Update
state
to be in the"choosingTransaction"
state when thecard
is verified. - Define a
pinNumber
method that updates thecard
’spin
, calls.verify()
, and initializes theaccountsPromise
andtransactions
properties.
Update the TESTS
section of the JavaScript
tab to:
- Test whether calling
pinNumber
moves thestate
to"choosingTransaction"
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
accountsPromise: "any",
transactions: can.DefineList,
// derived properties
get state(){
if(this.card) {
if (this.card.state === "verified") {
return "choosingTransaction";
}
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
},
pinNumber: function(pin) {
var card = this.card;
card.pin = pin;
this.transactions = new can.DefineList();
this.accountsPromise = card.verify().then(function(card) {
return Account.getList(card.serialize());
});
},
exit: function(){
this.set({
card: null,
accountsPromise: null,
transactions: null
});
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
atm.pinNumber("1234");
ok(atm.state, "readingPin", "remain in the reading pin state until verifyied");
atm.on("state", function(ev, newVal) {
if (newVal === "choosingTransaction") {
QUnit.ok(true, "in choosingTransaction");
QUnit.start();
}
});
});
When complete, you should be able to enter a card and pin number and see the Choosing Transaction page.
Choosing Transaction page and test
In this section, we will:
- Allow the user to pick a transaction type and go to the Picking Account page.
- Add tests to the ATM Basics test.
Update the HTML tab to:
- Have buttons for choosing a deposit, withdrawal, or print a receipt and exit.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM
view model in the CODE
section of the JavaScript
tab to:
- Define a
currentTransaction
property that when set, adds the previouscurrentTransaction
to the list oftransactions
. - Update the
state
property to"pickingAccount"
when there is acurrentTransaction
. - Update the
exit
method to clear thecurrentTransaction
property. - Define
chooseDeposit
that creates aDeposit
and sets it as thecurrentTransaction
. - Define
chooseWithdraw
that creates aWithdraw
and sets it as thecurrentTransaction
.
Update the TESTS
section of the JavaScript
tab to:
- Call
.chooseDeposit()
and verify that the state moves to"pickingAccount"
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
accountsPromise: "any",
transactions: can.DefineList,
currentTransaction: {
set: function(newTransaction) {
var currentTransaction = this.currentTransaction;
if (this.transactions && currentTransaction &&
currentTransaction.state === "executed") {
this.transactions.push(currentTransaction);
}
return newTransaction;
}
},
// derived properties
get state(){
if (this.currentTransaction) {
return "pickingAccount";
}
if(this.card) {
if (this.card.state === "verified") {
return "choosingTransaction";
}
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
},
pinNumber: function(pin) {
var card = this.card;
card.pin = pin;
this.transactions = new can.DefineList();
this.accountsPromise = card.verify().then(function(card) {
return Account.getList(card.serialize());
});
},
exit: function(){
this.set({
card: null,
accountsPromise: null,
transactions: null,
currentTransaction: null
});
},
chooseDeposit: function() {
this.currentTransaction = new Deposit({
card: this.card
});
},
chooseWithdraw: function() {
this.currentTransaction = new Withdrawal({
card: this.card
});
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
atm.pinNumber("1234");
ok(atm.state, "readingPin", "remain in the reading pin state until verifyied");
atm.on("state", function(ev, newVal) {
if (newVal === "choosingTransaction") {
QUnit.ok(true, "in choosingTransaction");
atm.chooseDeposit();
} else if (newVal === "pickingAccount") {
QUnit.ok(true, "in picking account state");
QUnit.start();
}
});
});
Note: We will define
printReceiptAndExit
later!
Picking Account page and test
In this section, we will:
- Allow the user to pick an account and go to either the Deposit Info or Withdrawal Info page.
- Add tests to the ATM Basics test.
Update the HTML tab to:
- Write out a “Loading Accounts…” message while the accounts are loading.
- Write out the accounts when loaded.
- Call
chooseAccount()
when an account is clicked.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
<p>Please pick your account:</p>
{{#if(accountsPromise.isPending)}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
Loading Accounts…
</p>
</div>
{{else}}
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
{{/if}}
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM
view model in the CODE
section of the JavaScript
tab to:
- Change
state
to check if thecurrentTransaction
has anaccount
and update the value to"depositInfo"
or"withdrawalInfo"
, depending on thecurrentTransaction
’s type. - Add a
chooseAccount
method that sets thecurrentTransaction
’saccount
.
Update the TESTS
section of the JavaScript
tab to:
- Call
.chooseAccount()
with the first account loaded. - Verify the state changes to
"depositInfo"
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
accountsPromise: "any",
transactions: can.DefineList,
currentTransaction: {
set: function(newTransaction) {
var currentTransaction = this.currentTransaction;
if (this.transactions && currentTransaction &&
currentTransaction.state === "executed") {
this.transactions.push(currentTransaction);
}
return newTransaction;
}
},
// derived properties
get state(){
if (this.currentTransaction) {
if (this.currentTransaction.account) {
if (this.currentTransaction instanceof Deposit) {
return "depositInfo";
} else {
return "withdrawalInfo";
}
}
return "pickingAccount";
}
if(this.card) {
if (this.card.state === "verified") {
return "choosingTransaction";
}
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
},
pinNumber: function(pin) {
var card = this.card;
card.pin = pin;
this.transactions = new can.DefineList();
this.accountsPromise = card.verify().then(function(card) {
return Account.getList(card.serialize());
});
},
exit: function(){
this.set({
card: null,
accountsPromise: null,
transactions: null,
currentTransaction: null
});
},
chooseDeposit: function() {
this.currentTransaction = new Deposit({
card: this.card
});
},
chooseWithdraw: function() {
this.currentTransaction = new Withdrawal({
card: this.card
});
},
chooseAccount: function(account) {
this.currentTransaction.account = account;
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
atm.pinNumber("1234");
ok(atm.state, "readingPin", "remain in the reading pin state until verifyied");
atm.on("state", function(ev, newVal) {
if (newVal === "choosingTransaction") {
QUnit.ok(true, "in choosingTransaction");
atm.chooseDeposit();
} else if (newVal === "pickingAccount") {
QUnit.ok(true, "in picking account state");
atm.accountsPromise.then(function(accounts){
atm.chooseAccount(accounts[0]);
});
} else if (newVal === "depositInfo") {
QUnit.ok(true, "in depositInfo state");
QUnit.start();
}
});
});
Deposit Info page and test
In this section, we will:
- Allow the user to enter the amount of a deposit and go to the Successful Transaction page.
- Add tests to the ATM Basics test.
Update the HTML tab to:
- Ask the user how much they would like to deposit into the account.
- Update
currentTransaction.amount
with an<input>
’svalue
. - If the transaction is executing, show a spinner.
- If the transaction is not executed:
- show a Deposit button that will be active only once the transaction has a value.
- show a cancel button that will clear this transaction.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
<p>Please pick your account:</p>
{{#if(accountsPromise.isPending)}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
Loading Accounts…
</p>
</div>
{{else}}
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
{{/if}}
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
<p>
How much would you like to deposit
into {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="deposit" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM
view model in the JavaScript
tab to:
- Change
state
to"successfulTransaction"
if thecurrentTransaction
was executed. - Add a
removeTransaction
method that removes thecurrentTransaction
, which will revert state to"choosingTransaction"
.
Update the ATM basics
test in the JavaScript
tab to:
- Add an
amount
to thecurrentTransaction
. - Make sure the
currentTransaction
isready
to be executed. - Execute the
currentTransaction
and make sure that thestate
stays as"depositInfo"
until the transaction is successful. - Verify the state changed to
"successfulTransaction"
.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
accountsPromise: "any",
transactions: can.DefineList,
currentTransaction: {
set: function(newTransaction) {
var currentTransaction = this.currentTransaction;
if (this.transactions && currentTransaction &&
currentTransaction.state === "executed") {
this.transactions.push(currentTransaction);
}
return newTransaction;
}
},
// derived properties
get state(){
if (this.currentTransaction) {
if (this.currentTransaction.state === "executed") {
return "successfulTransaction";
}
if (this.currentTransaction.account) {
if (this.currentTransaction instanceof Deposit) {
return "depositInfo";
} else {
return "withdrawalInfo";
}
}
return "pickingAccount";
}
if(this.card) {
if (this.card.state === "verified") {
return "choosingTransaction";
}
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
},
pinNumber: function(pin) {
var card = this.card;
card.pin = pin;
this.transactions = new can.DefineList();
this.accountsPromise = card.verify().then(function(card) {
return Account.getList(card.serialize());
});
},
exit: function(){
this.set({
card: null,
accountsPromise: null,
transactions: null,
currentTransaction: null
});
},
chooseDeposit: function() {
this.currentTransaction = new Deposit({
card: this.card
});
},
chooseWithdraw: function() {
this.currentTransaction = new Withdrawal({
card: this.card
});
},
chooseAccount: function(account) {
this.currentTransaction.account = account;
},
removeTransaction: function() {
this.currentTransaction = null;
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
atm.pinNumber("1234");
ok(atm.state, "readingPin", "remain in the reading pin state until verifyied");
atm.on("state", function(ev, newVal) {
if (newVal === "choosingTransaction") {
QUnit.ok(true, "in choosingTransaction");
atm.chooseDeposit();
} else if (newVal === "pickingAccount") {
QUnit.ok(true, "in picking account state");
atm.accountsPromise.then(function(accounts){
atm.chooseAccount(accounts[0]);
});
} else if (newVal === "depositInfo") {
QUnit.ok(true, "in depositInfo state");
var currentTransaction = atm.currentTransaction;
currentTransaction.amount = 120;
QUnit.ok(currentTransaction.ready, "we are ready to execute");
currentTransaction.execute();
QUnit.equal(atm.state, "depositInfo", "in deposit state until successful");
} else if (newVal === "successfulTransaction") {
QUnit.ok(true, "in successfulTransaction state");
QUnit.start();
}
});
});
When complete, you should be able to enter a deposit amount and see that the transaction was successful.
Withdrawal Info page
In this section, we will:
- Allow the user to enter the amount of a withdrawal and go to the Successful Transaction page.
Update the HTML tab to:
- Add a Withdraw page that works very similar to the Deposit page.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
<p>Please pick your account:</p>
{{#if(accountsPromise.isPending)}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
Loading Accounts…
</p>
</div>
{{else}}
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
{{/if}}
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
<p>
How much would you like to deposit
into {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="deposit" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
<p>
How much would you like to withdraw
from {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="withdrawl" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
When complete, you should be able to enter a withdrawal amount and see that the transaction was successful.
Optional: Challenge yourself by adding a test for the
withdrawalInfo
state of anatm
instance. Consider the progression of states needed to make it to thewithdrawalInfo
state. How is it different from the ATM basics test we already have?
Transaction Successful page
In this section, we will:
- Show the result of the transaction.
Update the HTML tab to:
- List out the account balance.
- Add buttons to:
- start another transaction, or
- print a receipt and exit the ATM (
printReceiptAndExit
will be implemented in the next section).
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
<p>Please pick your account:</p>
{{#if(accountsPromise.isPending)}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
Loading Accounts…
</p>
</div>
{{else}}
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
{{/if}}
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
<p>
How much would you like to deposit
into {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="deposit" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
<p>
How much would you like to withdraw
from {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="withdrawl" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
<p>
{{currentTransaction.account.name}} has
${{currentTransaction.account.balance}}.
</p>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="removeTransaction()">Another transaction</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
When complete, you should be able to make a deposit or withdrawal, see the updated account balance, then start another transaction.
Printing Recipe page and test
In this section, we will make it possible to:
- See a receipt of all transactions
- Exit the ATM.
Update the HTML tab to:
- List out all the transactions the user has completed.
- List out the final value of all accounts.
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.0 - ATM Guide - Setup">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id='atm-template'>
<div class="screen">
<div class="screen-content">
<div class="screen-glass">
{{#switch(state)}}
{{#case("readingCard")}}
<h2>Reading Card</h2>
<p>Welcome to canATM where there are <strong>never</strong>
fees!</p>
</p>
<p>
Enter Card Number:
<input name="card" on:enter="cardNumber(%element.value)"/>
</p>
{{/case}}
{{#case("readingPin")}}
<h2>Reading Pin</h2>
<p>
Enter Pin Number:
<input name="pin" type="password"
autofocus
{{#is(card.state, "verifying")}}DISABLED{{/is}}
on:enter="pinNumber(%element.value)"/>
{{#is(card.state, "verifying")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" on:click="exit()">exit</a>
{{/case}}
{{#case("choosingTransaction")}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="chooseDeposit()">Deposit</li>
<li on:click="chooseWithdraw()">Withdraw</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("pickingAccount")}}
<h2>Pick Account</h2>
<p>Please pick your account:</p>
{{#if(accountsPromise.isPending)}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
Loading Accounts…
</p>
</div>
{{else}}
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
{{/if}}
{{/case}}
{{#case("depositInfo")}}
<h2>Deposit</h2>
<p>
How much would you like to deposit
into {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="deposit" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("withdrawalInfo")}}
<h2>Withdraw</h2>
<p>
How much would you like to withdraw
from {{currentTransaction.account.name}}
(${{currentTransaction.account.balance}})?
<input name="withdrawl" value:bind="currentTransaction.amount"/>
</p>
{{#eq(currentTransaction.state, "executing")}}
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button on:click="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" on:click="removeTransaction()">cancel</a>
{{/eq}}
{{/case}}
{{#case("successfulTransaction")}}
<h2>Transaction Successful!</h2>
<p>
{{currentTransaction.account.name}} has
${{currentTransaction.account.balance}}.
</p>
<p>What would you like to do?</p>
<nav>
<ul>
<li on:click="removeTransaction()">Another transaction</li>
<li on:click="printReceiptAndExit()">Exit</li>
</ul>
</nav>
{{/case}}
{{#case("printingReceipt")}}
<h2>Printing Receipt</h2>
<h3>Transactions</h3>
<ul>
{{#if(transactions.length)}}
{{#each(transactions)}}
<li>{{actionName(this)}} ${{amount}} {{actionPrep(this)}} {{account.name}}</li>
{{/each}}
{{else}}
<li>None</li>
{{/if}}
</ul>
<h3>Accounts</h3>
<ul>
{{#each(accountsPromise.value)}}
<li on:click="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
<div class='warn'>
<p>
<img src="https://canjs.com/docs/images/loader.gif"/>
printing
</p>
</div>
{{/case}}
{{#default}}
<h2>Error</h2>
<p>Invalid state - {{state}}</p>
{{/default}}
{{/switch}}
</div>
</div>
</div>
</script>
<script type='text/stache' id='app-template'>
<div class="title">
<h1>canATM</h1>
</div>
<atm-machine/>
</script>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="https://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM
view model in the JavaScript
tab to:
- Add a
printingReceipt
andreceiptTime
property. - Change the
state
to"printingReceipt"
whenprintingReceipt
is true. - Make
.exit
setprintingReceipt
tonull
. - Add a
printReceiptAndExit
method that:- clears the current transaction, which will add the currentTransaction to the list of transactions.
- sets
printingReceipt
totrue
forprintingReceipt
time.
Update the ATM basics
test in the JavaScript
tab to:
- Shorten the default
receiptTime
so the tests move quickly. - Call
printReceiptAndExit
and make sure that thestate
changes to"printingReceipt"
and then to"readingCard"
and ensure that sensitive information is cleared from the ATM.
// ========================================
// CODE
// ========================================
can.fixture({
"/verifyCard": function(request, response) {
if (!request.data || !request.data.number || !request.data.pin) {
response(400, {});
} else {
return {};
}
},
"/accounts": function() {
return {
data: [{
balance: 100,
id: 1,
name: "checking"
}, {
balance: 10000,
id: 2,
name: "savings"
}]
};
},
"/deposit": function() {
return {};
},
"/withdrawal": function() {
return {};
}
});
can.fixture.delay = 1000;
var Card = can.DefineMap.extend({
number: "string",
pin: "string",
state: {
value: "unverified",
serialize: false
},
verify: function() {
this.state = "verifying";
var self = this;
return can.ajax({
type: "POST",
url: "/verifyCard",
data: this.serialize()
}).then(
function() {
self.state = "verified";
return self;
},
function() {
self.state = "invalid";
return self;
});
}
});
var Account = can.DefineMap.extend("Account", {
id: "number",
balance: "number",
name: "string"
});
Account.List = can.DefineList.extend("AccountList", {
"*": Account
});
can.connect.baseMap({
url: "/accounts",
Map: Account,
List: Account.List,
name: "accounts"
});
var Transaction = can.DefineMap.extend({
account: Account,
card: Card,
executing: {
type: "boolean",
value: false
},
executed: {
type: "boolean",
value: false
},
rejected: "any",
get ready(){
throw new Error("Transaction::ready must be provided by extended type");
},
get state() {
if (this.rejected) {
return "rejected";
}
if (this.executed) {
return "executed";
}
if (this.executing) {
return "executing";
}
// make sure there’s an amount, account, and card
if (this.ready) {
return "ready";
}
return "invalid";
},
executeStart: function(){
throw new Error("Transaction::executeStart must be provided by extended type");
},
executeEnd: function(){
throw new Error("Transaction::executeEnd must be provided by extended type");
},
execute: function() {
if (this.state === "ready") {
this.executing = true;
var def = this.executeStart(),
self = this;
def.then(function() {
can.batch.start();
self.set({
executing: false,
executed: true
});
self.executeEnd();
can.batch.stop();
}, function(reason){
self.set({
executing: false,
executed: true,
rejected: reason
});
});
}
}
});
var Deposit = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/deposit",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance + this.amount;
}
});
var Withdrawal = Transaction.extend({
amount: "number",
get ready() {
return this.amount > 0 &&
this.account &&
this.card;
},
executeStart: function() {
return can.ajax({
type: "POST",
url: "/withdrawal",
data: {
card: this.card.serialize(),
accountId: this.account.id,
amount: this.amount
}
});
},
executeEnd: function(data) {
this.account.balance = this.account.balance - this.amount;
}
});
var ATM = can.DefineMap.extend({
// stateful properties
card: Card,
accountsPromise: "any",
transactions: can.DefineList,
currentTransaction: {
set: function(newTransaction) {
var currentTransaction = this.currentTransaction;
if (this.transactions && currentTransaction &&
currentTransaction.state === "executed") {
this.transactions.push(currentTransaction);
}
return newTransaction;
}
},
printingReceipt: "boolean",
receiptTime: {
value: 5000,
type: "number"
},
// derived properties
get state(){
if (this.printingReceipt) {
return "printingReceipt";
}
if (this.currentTransaction) {
if (this.currentTransaction.state === "executed") {
return "successfulTransaction";
}
if (this.currentTransaction.account) {
if (this.currentTransaction instanceof Deposit) {
return "depositInfo";
} else {
return "withdrawalInfo";
}
}
return "pickingAccount";
}
if(this.card) {
if (this.card.state === "verified") {
return "choosingTransaction";
}
return "readingPin";
}
return "readingCard";
},
// methods
cardNumber: function(number) {
this.card = new Card({
number: number
});
},
pinNumber: function(pin) {
var card = this.card;
card.pin = pin;
this.transactions = new can.DefineList();
this.accountsPromise = card.verify().then(function(card) {
return Account.getList(card.serialize());
});
},
exit: function(){
this.set({
card: null,
accountsPromise: null,
transactions: null,
currentTransaction: null,
printingReceipt: null
});
},
printReceiptAndExit: function() {
this.currentTransaction = null;
this.printingReceipt = true;
var self = this;
setTimeout(function() {
self.exit();
}, this.receiptTime);
},
chooseDeposit: function() {
this.currentTransaction = new Deposit({
card: this.card
});
},
chooseWithdraw: function() {
this.currentTransaction = new Withdrawal({
card: this.card
});
},
chooseAccount: function(account) {
this.currentTransaction.account = account;
},
removeTransaction: function() {
this.currentTransaction = null;
}
});
can.Component.extend({
tag: "atm-machine",
view: can.stache.from("atm-template"),
ViewModel: ATM,
});
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
// ========================================
// TESTS
// ========================================
QUnit.module("ATM system", {
setup: function() {
can.fixture.delay = 1;
},
teardown: function() {
can.fixture.delay = 2000;
}
});
QUnit.asyncTest("Valid Card", function() {
var c = new Card({
number: "01234567890",
pin: 1234
});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "verified", "card is verified");
QUnit.start();
});
});
QUnit.asyncTest("Invalid Card", function() {
var c = new Card({});
QUnit.equal(c.state, "unverified");
c.verify();
QUnit.equal(c.state, "verifying", "card is verifying");
c.on("state", function(ev, newVal) {
QUnit.equal(newVal, "invalid", "card is invalid");
QUnit.start();
});
});
QUnit.asyncTest("Deposit", 6, function() {
var card = new Card({
number: "0123456789",
pin: "1122"
});
var deposit = new Deposit({
amount: 100,
card: card
});
equal(deposit.state, "invalid");
var startingBalance;
Account.getList(card.serialize()).then(function(accounts) {
QUnit.ok(true, "got accounts");
deposit.account = accounts[0];
startingBalance = accounts[0].balance;
});
deposit.on("state", function(ev, newVal) {
if (newVal === "ready") {
QUnit.ok(true, "deposit is ready");
deposit.execute();
} else if (newVal === "executing") {
QUnit.ok(true, "executing a deposit");
} else if (newVal === "executed") {
QUnit.ok(true, "executed a deposit");
equal(deposit.account.balance, 100 + startingBalance);
start();
}
});
});
QUnit.asyncTest("ATM basics", function() {
var atm = new ATM();
equal(atm.state, "readingCard", "starts at reading card state");
atm.cardNumber("01233456789");
equal(atm.state, "readingPin", "moves to reading card state");
atm.pinNumber("1234");
ok(atm.state, "readingPin", "remain in the reading pin state until verifyied");
atm.on("state", function(ev, newVal) {
if (newVal === "choosingTransaction") {
QUnit.ok(true, "in choosingTransaction");
atm.chooseDeposit();
} else if (newVal === "pickingAccount") {
QUnit.ok(true, "in picking account state");
atm.accountsPromise.then(function(accounts){
atm.chooseAccount(accounts[0]);
});
} else if (newVal === "depositInfo") {
QUnit.ok(true, "in depositInfo state");
var currentTransaction = atm.currentTransaction;
currentTransaction.amount = 120;
QUnit.ok(currentTransaction.ready, "we are ready to execute");
currentTransaction.execute();
QUnit.equal(atm.state, "depositInfo", "in deposit state until successful");
} else if (newVal === "successfulTransaction") {
QUnit.ok(true, "in successfulTransaction state");
atm.receiptTime = 100;
atm.printReceiptAndExit();
} else if (newVal === "printingReceipt") {
QUnit.ok(true, "in printingReceipt state");
} else if (newVal === "readingCard") {
QUnit.ok(true, "in readingCard state");
QUnit.ok(!atm.card, "card is removed");
QUnit.ok(!atm.transactions, "transactions removed");
QUnit.start();
}
});
});
When complete, you have a working ATM! Cha-ching!