init: 从 cs-hand/hand-server-2/lib/shrimp 抽出独立仓

- 6 个框架源文件搬到 src/, 加 src/index.ts barrel 作为公共 API 入口
- package.json 标 private, main/types 指 src/index.ts (ts-node 消费方编译)
- 加 vitest + 22 条单元测试 (ILabel / MemoryShrimpCrud) 全通过
- examples/basic-admin/ 用 MemoryShrimpCrud 跑零 Mongo 依赖 demo

待办: ShrimpServer 强耦合 Mongo+JWT+Express, crudRegistry 用
.model 判断数据源, 这两个问题在 demo 中已暴露, 计划后续重构补到 90 分。
This commit is contained in:
2026-05-23 15:07:55 +08:00
commit 8548a1760a
19 changed files with 9033 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest"
import { ShrimpError, ShrimpCode } from "../src"
import type { ILabel } from "../src"
describe("ILabel", () => {
it("ShrimpError 接受字面量 string", () => {
const e = new ShrimpError("余额不足")
expect(e.tip).toBe("余额不足")
expect(e.message).toBe("余额不足")
expect(e.code).toBe(ShrimpCode.FAIL)
})
it("ShrimpError 接受 {t} 翻译键", () => {
const tip: ILabel = { t: "myapp.notEnough", params: { need: 100 } }
const e = new ShrimpError(tip)
expect(e.tip).toEqual(tip)
// message 用 i18n key 作为日志友好的字符串
expect(e.message).toBe("myapp.notEnough")
})
it("ShrimpError 接受 {tc} 复数键", () => {
const tip: ILabel = { tc: "myapp.items", count: 3 }
const e = new ShrimpError(tip)
expect(e.tip).toEqual(tip)
expect(e.message).toBe("myapp.items")
})
it("ShrimpError 接受 {d} 日期形态", () => {
const now = Date.now()
const tip: ILabel = { d: now, format: "short" }
const e = new ShrimpError(tip)
expect(e.message).toBe(String(now))
})
it("ShrimpError 接受 {n} 数字/货币形态", () => {
const tip: ILabel = { n: 99.5, format: "currency" }
const e = new ShrimpError(tip)
expect(e.message).toBe("99.5")
})
it("ShrimpError 接受内联多语言对象", () => {
const tip: ILabel = { zh: "提交", en: "Submit" }
const e = new ShrimpError(tip)
// 对内联多语言 extractILabelText 没有特殊处理,返回 fallback
expect(e.message).toBe("[ILabel]")
expect(e.tip).toEqual(tip)
})
it("ShrimpError 支持自定义 code", () => {
const e = new ShrimpError("权限不足", ShrimpCode.FORBIDDEN)
expect(e.code).toBe(ShrimpCode.FORBIDDEN)
})
it("ShrimpError 是 Error 的子类", () => {
const e = new ShrimpError("err")
expect(e).toBeInstanceOf(Error)
expect(e.name).toBe("ShrimpError")
})
})
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from "vitest"
import { MemoryShrimpCrud } from "../src"
interface IItem {
_id: string
name: string
price: number
tags?: string[]
}
describe("MemoryShrimpCrud", () => {
let crud: MemoryShrimpCrud<IItem>
beforeEach(() => {
crud = new MemoryShrimpCrud<IItem>()
})
it("add 返回带 _id 的新记录", async () => {
const item = await crud.add({ name: "苹果", price: 5 } as any)
expect(item._id).toBeDefined()
expect(item.name).toBe("苹果")
expect(item.price).toBe(5)
})
it("get 按 id 取记录", async () => {
const a = await crud.add({ name: "A", price: 1 } as any)
const fetched = await crud.get(a._id)
expect(fetched).toEqual(a)
})
it("get 不存在返回 null", async () => {
expect(await crud.get("missing")).toBeNull()
})
it("edit 部分更新", async () => {
const a = await crud.add({ name: "A", price: 1 } as any)
const updated = await crud.edit(a._id, { price: 99 })
expect(updated?.price).toBe(99)
expect(updated?.name).toBe("A")
})
it("delete 删除返回 true,再次删返回 false", async () => {
const a = await crud.add({ name: "A", price: 1 } as any)
expect(await crud.delete(a._id)).toBe(true)
expect(await crud.delete(a._id)).toBe(false)
expect(await crud.get(a._id)).toBeNull()
})
it("list 全量返回 + 分页字段", async () => {
await crud.add({ name: "A", price: 1 } as any)
await crud.add({ name: "B", price: 2 } as any)
const result = await crud.list()
expect(result.total).toBe(2)
expect(result.list).toHaveLength(2)
})
it("list where eq 过滤", async () => {
await crud.add({ name: "A", price: 1 } as any)
await crud.add({ name: "B", price: 2 } as any)
const result = await crud.list({ where: { name: { eq: "B" } } })
expect(result.total).toBe(1)
expect(result.list[0].name).toBe("B")
})
it("list where like 模糊匹配", async () => {
await crud.add({ name: "apple-juice", price: 1 } as any)
await crud.add({ name: "orange", price: 2 } as any)
const result = await crud.list({ where: { name: { like: "APP" } } })
expect(result.total).toBe(1)
expect(result.list[0].name).toBe("apple-juice")
})
it("list where gte/lte 范围过滤", async () => {
for (let i = 1; i <= 5; i++) await crud.add({ name: `n${i}`, price: i } as any)
const result = await crud.list({ where: { price: { gte: 2, lte: 4 } } })
expect(result.total).toBe(3)
})
it("list order 排序", async () => {
await crud.add({ name: "B", price: 2 } as any)
await crud.add({ name: "A", price: 1 } as any)
await crud.add({ name: "C", price: 3 } as any)
const asc = await crud.list({ order: [["price", "asc"]] })
expect(asc.list.map(i => i.price)).toEqual([1, 2, 3])
const desc = await crud.list({ order: [["price", "desc"]] })
expect(desc.list.map(i => i.price)).toEqual([3, 2, 1])
})
it("list offset/limit 分页", async () => {
for (let i = 1; i <= 10; i++) await crud.add({ name: `n${i}`, price: i } as any)
const page2 = await crud.list({ offset: 3, limit: 3, order: [["price", "asc"]] })
expect(page2.list.map(i => i.price)).toEqual([4, 5, 6])
expect(page2.total).toBe(10)
expect(page2.totalPages).toBe(4)
})
it("findOne 按字段查找", async () => {
await crud.add({ name: "alice", price: 1 } as any)
await crud.add({ name: "bob", price: 2 } as any)
const found = await crud.findOne({ name: "bob" })
expect(found?.price).toBe(2)
})
it("clear 清空所有记录", async () => {
await crud.add({ name: "A", price: 1 } as any)
await crud.add({ name: "B", price: 2 } as any)
await crud.clear()
const result = await crud.list()
expect(result.total).toBe(0)
})
it("populateItems 旁挂 _label", async () => {
const tagCrud = new MemoryShrimpCrud<{ _id: string; name: string }>()
await tagCrud.add({ name: "热门" } as any)
await tagCrud.add({ name: "促销" } as any)
const itemCrud = new MemoryShrimpCrud<IItem>()
itemCrud.getRelatedCrud = (key) => key === "tag" ? tagCrud as any : undefined
const a = await itemCrud.add({ name: "苹果", price: 5, tags: ["1", "2"] } as any)
const items = [{ ...a }] as any[]
await itemCrud.populateItems(items, [{ moduleKey: "tag", key: "tags", labelKey: "name" }])
expect(items[0].tags_label).toEqual(["热门", "促销"])
})
})