Skip to content

Instantly share code, notes, and snippets.

@brianhsu
Last active October 28, 2015 02:54
Show Gist options
  • Save brianhsu/0785868ea6cc1530a140 to your computer and use it in GitHub Desktop.
Save brianhsu/0785868ea6cc1530a140 to your computer and use it in GitHub Desktop.
import akka.actor._
import akka.testkit._
import org.scalatest._
import scala.concurrent.duration._
object VendingMachine {
// 自動販賣機會用到的資料:目前投了多少錢,有什麼產品,使用者選了什麼產品
case class Product(price: Int, stock: Int)
case class MoneyAndProducts(currentMoneyInside: Int, products: Map[String, Product], selectedItem: Option[String] = None)
// 自動販賣機可能的狀態
sealed trait State
case object Idle extends State
case object Selection extends State
case object Dispending extends State
// 自動賦販賣機可以接受的事件
case class InsertCoins(coins: Int)
case class Selection(productName: String)
case object Refund
case object Done
// 送回給 UI 的資料
case class SendProduct(item: String)
case class Change(coins: Int)
case class SelectableProducts(currentMony: Int, items: Map[String, Product])
// 一開始的產品價格和資料
val defaultStocks = Map(
"可口可樂" -> Product(25, 100),
"雪碧" -> Product(25, 100),
"生活紅茶" -> Product(10, 200),
"無糖綠茶" -> Product(20, 200),
"伯朗咖啡" -> Product(30, 50)
)
}
class VendingMachine extends FSM[VendingMachine.State, VendingMachine.MoneyAndProducts] {
import VendingMachine._
// 一開始自動販賣機在 Idle 的狀態,沒有人投錢,商品是滿的
startWith(Idle, MoneyAndProducts(0, defaultStocks))
/**
* 檢查使用者投的錢是否夠買某個商品和該商品庫存是否足夠
*
* @param item 使用者想買的商品
* @param currentData 目前自動販賣機裡投了多少錢,有什麼商品
* @return 若商品有庫存,而且機器裡的錢也夠則為 true
*/
def productIsAavaliable(item: String, currentData: MoneyAndProducts): Boolean = {
currentData.products.get(item) match {
case Some(Product(price, stock)) if (stock > 0 && currentData.currentMoneyInside >= price) => true
case _ => false
}
}
/**
* 買了商品之後的新狀態:
*
* - 商品庫存數量減一
* - 自動販賣機裡的錢為要找零的錢(原本投的金額-商品標價)
*
* @param currentData 原本的自動販賣機資料
* @param item 要買的商品
* @return 新的自動販賣機資料
*/
def newData(currentData: MoneyAndProducts, itemName: String) = {
val newProduct = currentData.products.get(itemName).map(x => x.copy(stock = x.stock -1)).get
val newProductList = currentData.products.updated(itemName, newProduct)
currentData.copy(
currentMoneyInside = currentData.currentMoneyInside - newProduct.price,
products = newProductList,
selectedItem = Some(itemName)
)
}
// 當自動販賣機在 Idle 狀態
when(Idle) {
// 接收到投幣事件時,轉到 Selection 狀態,並且把使用者投的錢加上去
case Event(InsertCoins(inserted), currentData) =>
val totalMoney = currentData.currentMoneyInside + inserted
goto(Selection) using currentData.copy(currentMoneyInside = totalMoney)
}
// 當在 Selection 狀態
when(Selection) {
// 繼續投幣的話就繼續停在 Selection 狀態
case Event(InsertCoins(inserted), currentData) =>
val totalMoney = currentData.currentMoneyInside + inserted
goto(Selection) using currentData.copy(currentMoneyInside = totalMoney)
// 按下商品按鈕,而且可以買的話,就轉到出貨狀態
case Event(Selection(item), currentData) if productIsAavaliable(item, currentData) =>
goto(Dispending) using newData(currentData, item)
// 如果按下退幣鈕,那就回到 Idle 的狀態,販賣機的投幣金額應該也要歸零。
case Event(Refund, currentData) =>
goto(Idle) using currentData.copy(currentMoneyInside = 0)
}
// 如果是在出貨狀態
when(Dispending) {
// 收到出完貨的訊息,那就回到 Idle 等下一次的投幣,已投金額也歸零
case Event(Done, currentData) => goto(Idle) using currentData.copy(currentMoneyInside = 0)
}
// 從某個狀態轉換到另一個狀態,應該告知 UI 做一些事
onTransition {
// 如果是從 Idle 轉到投幣,或使用者持續投幣,
// 那要告知使用者現在投了多少錢,可以選什麼商品(錢夠而且有庫存)。
case Idle -> Selection | Selection -> Selection =>
val MoneyAndProducts(currentMoneyInside, products, _) = nextStateData
val avaliableItems = products.filter{case (name, product) => product.price <= currentMoneyInside}
sender ! SelectableProducts(currentMoneyInside, avaliableItems)
// 如果從 Selection 轉到 Idle(按下退幣)
// 那就把原來投的零錢還給使用者
case Selection -> Idle =>
val MoneyAndProducts(currentMoneyInside, products, _) = stateData
sender ! Change(currentMoneyInside)
// 如果從 Selection 轉到 Dispending,那就是出貨,
// 先把商品送出,然後找零,最後再發出出貨完成的事件給自動販賣機自己。
case Selection -> Dispending =>
val MoneyAndProducts(newMoneyInside, _, Some(selectedItem)) = nextStateData
println(newMoneyInside)
sender ! SendProduct(selectedItem)
sender ! Change(newMoneyInside)
self ! Done
}
}
class VendingMachineSpec(system: ActorSystem) extends TestKit(system)
with ImplicitSender with FunSpecLike
with Matchers with BeforeAndAfterAll {
import VendingMachine._
describe("一台自動販賣機") {
it ("要能接受硬幣而且回傳正確的可選商品列表") {
val vendingMachine = system.actorOf(Props[VendingMachine])
vendingMachine ! InsertCoins(10)
expectMsg(SelectableProducts(10, Map("生活紅茶"-> Product(10,200))))
vendingMachine ! InsertCoins(15)
expectMsg(SelectableProducts(25, Map("生活紅茶" -> Product(10,200), "可口可樂" -> Product(25,100), "無糖綠茶" -> Product(20,200), "雪碧" -> Product(25,100))))
}
it ("要可以在使用者按下退幣鈕後退正確的金額給使用者") {
val vendingMachine = system.actorOf(Props[VendingMachine])
vendingMachine ! InsertCoins(10)
vendingMachine ! InsertCoins(15)
vendingMachine ! Refund
expectMsgType[SelectableProducts]
expectMsgType[SelectableProducts]
expectMsg(Change(25))
vendingMachine ! InsertCoins(100)
vendingMachine ! Refund
expectMsgType[SelectableProducts]
expectMsg(Change(100))
}
it ("要可以出貨使用者選的商品並找零") {
val vendingMachine = system.actorOf(Props[VendingMachine])
vendingMachine ! InsertCoins(50)
vendingMachine ! InsertCoins(10)
vendingMachine ! InsertCoins(5)
expectMsgType[SelectableProducts]
expectMsgType[SelectableProducts]
expectMsgType[SelectableProducts]
vendingMachine ! Selection("可口可樂")
expectMsgAllOf(10.second, SendProduct("可口可樂"), Change(40))
}
it ("出貨和找零後,販賣機要回到 Idle 狀態,而且投幣金額要為 0 且商品數量被更新") {
import akka.testkit.TestFSMRef
implicit val actorSystem = system
val vendingMachine = TestFSMRef(new VendingMachine)
vendingMachine ! InsertCoins(65)
vendingMachine ! Selection("可口可樂")
vendingMachine.stateName shouldBe Idle
vendingMachine.stateData.currentMoneyInside shouldBe 0
vendingMachine.stateData.products.get("可口可樂").map(_.stock) shouldBe Some(99)
}
}
def this() = this(ActorSystem("MySpec"))
override def afterAll {
TestKit.shutdownActorSystem(system)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment