ATM Guide
This guide walks through building and testing an ATM application with CanJS's Core libraries. It teaches how to do test driven development (TDD) and manage complex state. It takes about 2 hours to complete.
Overview
Checkout the final app:
Notice it has tests at the bottom of the Output tab.
Watch the following video to see it in action:
TODO: VIDEO OF IT IN ACTION
Setup
The easiest way to get started is to clone the following JSBin by clicking the JS Bin button on the top left:
The JSBin 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
cannamespace.Generally speaking, you should not use the global can script and instead should import things directly with a module loader like StealJS, WebPack or Browserify. Read Setting up CanJS on how to setup CanJS in a real app.
Includes the content for a
app-templatecan-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 JS 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 test will be in separate files and loaded by different html pages. But we combine them here to fit within JSBin's limitations.
The CODE section is rendering the app-template with:
document.body.insertBefore(
can.stache.from("app-template")({}),
document.body.firstChild
);
The TESTS section is labeling 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
ATMview-model'sstateproperty 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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the JavaScript tab to:
- Create the
ATMview-model with astateproperty initialized toreadingCardwith 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 outlines the page transitions we're going to make the state
property 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 JS tab to:
- Make the fake data request delay
1msby setting delay to1before every test and restoring it to2safter every test runs. - Write a test that creates a valid card, calls
.verify(), and asserts thestateis"verified". - Write a test that creates a invalid card, calls
.verify(), and asserts thestateis"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 lets make it pass.
Card model
In this section, we will:
- Implement the
Cardmodel so that all tests pass.
Update the JavaScript tab to:
- Simulate the
/verifyCardwith can-fixture. It return a successful response if the request body has anumberandpinand a 400 if not. - Use can-define/map/map to define the
Cardmodel, including:- a
numberandpinproperty. - a
stateproperty initialized tounverifiedthat is not part of the card's serialized data. - a
verifymethod that posts the card's data to/verifyCardand updates thestateaccordingly.
- 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
Accounts. - Design an API for a
Deposittype. - Write out tests for the
Deposittype.
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 JS tab to:
- Create a
depositwith anamountand acard. - Check that the
stateis"invalid"because there is noaccount. - Use
Account.getListto get the accounts for the card and:- set the
deposit.accountsto the first account. - remember the starting
balance.
- set the
- Use on to listen for
statechanges. Whenstateis:"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
Accountmodel. - Implement a base
Transactionmodel and extend it into aDepositandWithdrawalmodel. - Get the Deposit test to pass.
Update the JavaScript tab to:
- Simulate
/accountsto returnAccountdata with can-fixture. - Simulate
/depositto always return a successful result. - Simulate
/withdrawalto always return a successful result. - Define the
Accountmodel to:- have an
idproperty - have a
balanceproperty - have a
nameproperty
- have an
- Define an
Account.Listtype with can-define/list/list - Connect
AccountandAccount.Listtypes to the restful"/accounts"endpoint using can-connect/can/base-map/base-map. - Define the
Transactionmodel to:- have an
accountandcardproperty. - have an
executingandexecutedproperty that track if the transaction is executing or has executed. - have a
rejectedproperty that stores the error given for a failed transaction. - have an abstract
readyproperty thatDepositandWithdrawalwill implement to returntruewhen the transaction is in a state able to be executed. - have a
stateproperty that reads other stateful properties and returns a string representation of the state. - have an abstract
executeStartmethod thatDepositandWithdrawalwill implement to execute the transaction and return aPromisethe resolves when the transaction completes. - have an abstract
executeEndmethod thatDepositandWithdrawalwill implement to update the transactions values (typically theaccountbalance) if the transaction completed successfully. - have an
executemethod that calls.executeStart()andexecuteEnd()and keeps the stateful properties updated correctly.
- have an
- Define the
Depositmodel to:- have an
amountproperty. - implement
readyto returntruewhen the amount is greater than0and there's anaccountandcard. - implement
executeStarttoPOSTthe deposit information to/deposit - implement
executeEndto update the account balance.
- have an
- Define the
Withdrawalmodel to behave in the same way asDepositexcept that itPOSTs 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 ATM Basics test.
Update the HTML tab to:
- Allow a user to call
cardNumberwith 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" ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the JavaScript tab to:
- Declare a
cardproperty. - Derive a
stateproperty that changes to"readingPin"whencardis defined. - Add a
cardNumberthat creates acardwith thenumberprovided.
// ========================================
// 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 ATM Basics test.
Update the HTML tab to:
- Call
pinNumberwith 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://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
accountsPromiseproperty that will contain a list of accounts for thecard. - Define a
transactionsproperty that will contain a list of transactions for this session. - Update
stateto be in the"choosingTransaction"state when thecardis verified. - Define a
pinNumbermethod that updates thecard'spin, calls.verify(), and then initializes theaccountsPromiseandtransactionsproperty.
Update the TESTS section of the JavaScript tab to:
- Test calling
pinNumbermoves thestateto"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 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://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
currentTransactionproperty that when set, adds the previouscurrentTransactionto the list oftransactions. - Update the
stateproperty to"pickingAccount"when there is acurrentTransaction. - Update the
exitmethod to clear thecurrentTransactionproperty. - Define
chooseDepositthat creates aDepositand sets it as thecurrentTransaction. - Define
chooseWithdrawthat creates aWithdrawand sets it as thecurrentTransaction.
Update the TESTS section of the JavaScript tab to:
- Call
.chooseDeposit()and verify 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();
}
});
});
We will define
printReceiptAndExitlater!
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 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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="http://v3.canjs.com/docs/images/loader.gif"/>
Loading Accounts ...
</p>
</div>
{{else}}
<ul>
{{#each accountsPromise.value}}
<li ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://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
stateto check if thecurrentTransactionhas anaccountand update the value to"depositInfo"or"withdrawalInfo"depending on the type of thecurrentTransaction. - Add a
chooseAccountmethod 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 ATM Basics test.
Update the HTML tab to:
- Ask the user how much they would like to deposit into the account.
- Update
currentTransaction.amountwith 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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="http://v3.canjs.com/docs/images/loader.gif"/>
Loading Accounts ...
</p>
</div>
{{else}}
<ul>
{{#each accountsPromise.value}}
<li ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM view model in the JavaScript tab to:
- Change
stateto"successfulTransaction"if thecurrentTransactionwas executed. - Add a
removeTransactionmethod that removes thecurrentTransaction, which will revert state to"choosingTransaction".
Update the ATM basics test in the JavaScript tab to:
- Add an
amountto thecurrentTransaction. - Make sure the
currentTransactionisreadyto be executed. - Execute the
currentTransactionand make sure that thestatestays 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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="http://v3.canjs.com/docs/images/loader.gif"/>
Loading Accounts ...
</p>
</div>
{{else}}
<ul>
{{#each accountsPromise.value}}
<li ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://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
withdrawalInfostate of an atm instance. Consider the progression of states needed to make it to thewithdrawalInfostate. 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. (
printReceiptAndExitwill 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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="http://v3.canjs.com/docs/images/loader.gif"/>
Loading Accounts ...
</p>
</div>
{{else}}
<ul>
{{#each accountsPromise.value}}
<li ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" ($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 ($click)="removeTransaction()">Another transaction</li>
<li ($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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://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 and then start another transaction.
Printing Recipe page and test
In this section, we will:
- Make it possible to see a receipt of all transactions and 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" ($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}}
($enter)="pinNumber(%element.value)"/>
{{#is card.state "verifying"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
verifying
</p>
</div>
{{/is}}
</p>
<a href="javascript://" ($click)="exit()">exit</a>
{{/case}}
{{#case "choosingTransaction"}}
<h2>Choose Transaction</h2>
<p>What would you like to do?</p>
<nav>
<ul>
<li ($click)="chooseDeposit()">Deposit</li>
<li ($click)="chooseWithdraw()">Withdraw</li>
<li ($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="http://v3.canjs.com/docs/images/loader.gif"/>
Loading Accounts ...
</p>
</div>
{{else}}
<ul>
{{#each accountsPromise.value}}
<li ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Deposit
</button>
<a href="javascript://" ($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)}="currentTransaction.amount"/>
</p>
{{#eq currentTransaction.state "executing"}}
<div class='warn'>
<p>
<img src="http://v3.canjs.com/docs/images/loader.gif"/>
executing
</p>
</div>
{{else}}
<button ($click)="currentTransaction.execute()"
{{^eq currentTransaction.state "ready"}}DISABLED{{/eq}}>
Withdraw
</button>
<a href="javascript://" ($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 ($click)="removeTransaction()">Another transaction</li>
<li ($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 ($click)="chooseAccount(.)">{{name}} - ${{balance}}</li>
{{/each}}
</ul>
<div class='warn'>
<p>
<img src="http://v3.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/dist/global/can.all.js"></script>
<div id="qunit"></div>
<link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.12.0.css">
<script src="http://code.jquery.com/qunit/qunit-1.12.0.js"></script>
</body>
</html>
Update the ATM view model in the JavaScript tab to:
- Add a
printingReceiptandreceiptTimeproperty. - Change the
stateto"printingReceipt"whenprintingReceiptis true. - Make
.exitsetprintingReceipttonull. - Add a
printReceiptAndExitmethod that:- clears the current transaction, which will add the currentTransaction to the list of transactions.
- sets
printingReceipttotrueforprintingReceipttime.
Update the ATM basics test in the JavaScript tab to:
- Shorten the default
receiptTimeso the tests move quickly. - Call
printReceiptAndExitand make sure that thestatechanges 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!