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
cannamespace.Generally speaking, you should not use the global
canscript, 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-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 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
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://unpkg.com/can@4/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
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", default: "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
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 an invalid card, calls
.verify(), and asserts thestateis"invalid".
// ========================================
// CODE
// ========================================
var ATM = can.DefineMap.extend({
state: {type: "string", default: "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
Cardmodel so that all the tests pass.
Update the JavaScript tab to:
- Simulate the
/verifyCardwith can-fixture. It will return a successful response if the request body has anumberandpin, or a400if not. - Use can-define/map/map to define the
Cardmodel, including:- a
numberand apinproperty. - 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: {
default: "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", default: "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 JavaScript 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: {
default: "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", default: "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 intoDepositandWithdrawalmodels. - 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/accountsendpoint using can-connect/can/base-map/base-map. - Define the
Transactionmodel to:- have
accountandcardproperties. - have
executingandexecutedproperties 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 an executable state. - 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 aPromisethat resolves when the transaction is complete. - have an abstract
executeEndmethod thatDepositandWithdrawalwill implement to update the transactions values (typically theaccountbalance) if the transaction is successfully completed. - have an
executemethod that calls.executeStart()andexecuteEnd()and keeps the stateful properties updated correctly.
- have
- 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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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", default: "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
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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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://unpkg.com/can@4/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
cardproperty. - Derive a
stateproperty that changes to"readingPin"whencardis defined. - Add a
cardNumberthat creates acardwith 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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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
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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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://unpkg.com/can@4/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
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 initializes theaccountsPromiseandtransactionsproperties.
Update the TESTS section of the JavaScript tab to:
- Test whether 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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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.assign({
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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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://unpkg.com/can@4/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
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 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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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.assign({
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
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 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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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(this)">{{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://unpkg.com/can@4/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
stateto check if thecurrentTransactionhas anaccountand update the value to"depositInfo"or"withdrawalInfo", depending on thecurrentTransaction’s type. - 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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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.assign({
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.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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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(this)">{{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://unpkg.com/can@4/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
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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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.assign({
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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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(this)">{{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://unpkg.com/can@4/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
withdrawalInfostate of anatminstance. 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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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(this)">{{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://unpkg.com/can@4/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>
Enter Card Number:
<input name="card" on:enter="cardNumber(scope.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(scope.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(this)">{{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(this)">{{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://unpkg.com/can@4/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
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: {
default: "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",
default: false
},
executed: {
type: "boolean",
default: 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.queues.batch.start();
self.assign({
executing: false,
executed: true
});
self.executeEnd();
can.queues.batch.stop();
}, function(reason){
self.assign({
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: {
default: 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.assign({
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!