188 lines
4.9 KiB
JavaScript
188 lines
4.9 KiB
JavaScript
/**
|
||
* Property 7: 购物车数量与价格不变量
|
||
*
|
||
* 对于任意购物车状态:
|
||
* - 总数量 = 所有菜品数量之和
|
||
* - 总价 = 所有菜品的(数量 × 单价)之和
|
||
* - 添加菜品使数量 +1
|
||
* - 减少菜品使数量 -1,减至 0 时移除
|
||
*/
|
||
import { describe, it, expect } from 'vitest'
|
||
import * as fc from 'fast-check'
|
||
|
||
// ==================== 纯函数购物车逻辑(与 store 一致) ====================
|
||
|
||
/**
|
||
* 计算购物车总数量
|
||
* @param {Array} items - [{ dish, quantity }]
|
||
*/
|
||
function calcTotalCount(items) {
|
||
return items.reduce((sum, item) => sum + item.quantity, 0)
|
||
}
|
||
|
||
/**
|
||
* 计算购物车总价
|
||
* @param {Array} items - [{ dish: { price }, quantity }]
|
||
*/
|
||
function calcTotalPrice(items) {
|
||
return items.reduce((sum, item) => sum + item.quantity * item.dish.price, 0)
|
||
}
|
||
|
||
/**
|
||
* 添加菜品,数量 +1
|
||
*/
|
||
function addItem(items, dish) {
|
||
const copy = items.map(i => ({ dish: { ...i.dish }, quantity: i.quantity }))
|
||
const existing = copy.find(i => i.dish.id === dish.id)
|
||
if (existing) {
|
||
existing.quantity += 1
|
||
} else {
|
||
copy.push({ dish: { ...dish }, quantity: 1 })
|
||
}
|
||
return copy
|
||
}
|
||
|
||
/**
|
||
* 减少菜品,数量 -1,减至 0 时移除
|
||
*/
|
||
function removeItem(items, dishId) {
|
||
const copy = items.map(i => ({ dish: { ...i.dish }, quantity: i.quantity }))
|
||
const index = copy.findIndex(i => i.dish.id === dishId)
|
||
if (index === -1) return copy
|
||
copy[index].quantity -= 1
|
||
if (copy[index].quantity <= 0) {
|
||
copy.splice(index, 1)
|
||
}
|
||
return copy
|
||
}
|
||
|
||
// ==================== 生成器 ====================
|
||
|
||
/** 生成合法菜品对象 */
|
||
const dishArb = fc.record({
|
||
id: fc.integer({ min: 1, max: 1000 }),
|
||
name: fc.string({ minLength: 1, maxLength: 10 }),
|
||
photo: fc.constant('test.png'),
|
||
price: fc.float({ min: Math.fround(0.01), max: Math.fround(999), noNaN: true }).map(v => Math.round(v * 100) / 100)
|
||
})
|
||
|
||
/** 生成购物车操作序列 */
|
||
const cartActionArb = fc.oneof(
|
||
dishArb.map(dish => ({ type: 'add', dish })),
|
||
fc.integer({ min: 1, max: 1000 }).map(id => ({ type: 'remove', dishId: id }))
|
||
)
|
||
|
||
// ==================== 属性测试 ====================
|
||
|
||
describe('Feature: login-and-homepage, Property 7: 购物车数量与价格不变量', () => {
|
||
/**
|
||
*
|
||
* 总数量始终等于所有菜品数量之和
|
||
*/
|
||
it('总数量等于所有菜品数量之和', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(cartActionArb, { minLength: 1, maxLength: 50 }),
|
||
(actions) => {
|
||
let items = []
|
||
for (const action of actions) {
|
||
if (action.type === 'add') {
|
||
items = addItem(items, action.dish)
|
||
} else {
|
||
items = removeItem(items, action.dishId)
|
||
}
|
||
}
|
||
const expectedCount = items.reduce((s, i) => s + i.quantity, 0)
|
||
expect(calcTotalCount(items)).toBe(expectedCount)
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
)
|
||
})
|
||
|
||
/**
|
||
*
|
||
* 总价始终等于所有菜品的(数量 × 单价)之和
|
||
*/
|
||
it('总价等于所有菜品的数量乘以单价之和', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(cartActionArb, { minLength: 1, maxLength: 50 }),
|
||
(actions) => {
|
||
let items = []
|
||
for (const action of actions) {
|
||
if (action.type === 'add') {
|
||
items = addItem(items, action.dish)
|
||
} else {
|
||
items = removeItem(items, action.dishId)
|
||
}
|
||
}
|
||
const expectedPrice = items.reduce((s, i) => s + i.quantity * i.dish.price, 0)
|
||
// 浮点精度容差
|
||
expect(Math.abs(calcTotalPrice(items) - expectedPrice)).toBeLessThan(0.001)
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
)
|
||
})
|
||
|
||
/**
|
||
*
|
||
* 添加菜品使该菜品数量 +1
|
||
*/
|
||
it('添加菜品使数量加1', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(cartActionArb, { maxLength: 20 }),
|
||
dishArb,
|
||
(setupActions, dish) => {
|
||
let items = []
|
||
for (const action of setupActions) {
|
||
if (action.type === 'add') {
|
||
items = addItem(items, action.dish)
|
||
} else {
|
||
items = removeItem(items, action.dishId)
|
||
}
|
||
}
|
||
const beforeQty = items.find(i => i.dish.id === dish.id)?.quantity || 0
|
||
const afterItems = addItem(items, dish)
|
||
const afterQty = afterItems.find(i => i.dish.id === dish.id)?.quantity || 0
|
||
expect(afterQty).toBe(beforeQty + 1)
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
)
|
||
})
|
||
|
||
/**
|
||
*
|
||
* 减少菜品使数量 -1,减至 0 时移除
|
||
*/
|
||
it('减少菜品使数量减1且减至0时移除', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
dishArb,
|
||
fc.integer({ min: 1, max: 10 }),
|
||
(dish, addCount) => {
|
||
// 先添加若干次
|
||
let items = []
|
||
for (let i = 0; i < addCount; i++) {
|
||
items = addItem(items, dish)
|
||
}
|
||
expect(items.find(i => i.dish.id === dish.id)?.quantity).toBe(addCount)
|
||
|
||
// 减少一次
|
||
const afterItems = removeItem(items, dish.id)
|
||
if (addCount === 1) {
|
||
// 减至 0,应被移除
|
||
expect(afterItems.find(i => i.dish.id === dish.id)).toBeUndefined()
|
||
} else {
|
||
expect(afterItems.find(i => i.dish.id === dish.id)?.quantity).toBe(addCount - 1)
|
||
}
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
)
|
||
})
|
||
})
|