@tbox.cn/app-module-member 0.2.0 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +16 -0
- package/README.md +99 -11
- package/dist/chunk-EPQEF43P.js +1581 -0
- package/dist/chunk-QWGRWD5C.js +766 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +12 -2
- package/dist/member-372B74DY.css +1740 -0
- package/dist/server/index.d.ts +657 -286
- package/dist/server/index.js +11 -1
- package/package.json +25 -16
- package/skills/member/SKILL.md +141 -0
- package/skills/member/references/enrollment.md +34 -0
- package/skills/member/references/points-and-card.md +24 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/member-card-summary/index.tsx +375 -0
- package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
- package/src/client/cards/member-enrollment-form/index.tsx +181 -0
- package/src/client/cards/member-enrollment-status/index.tsx +65 -0
- package/src/client/cards/member-info/index.tsx +60 -0
- package/src/client/cards/member-login-cta/index.tsx +81 -0
- package/src/client/cards/member-point-usage-list/index.tsx +53 -0
- package/src/client/cards/view.ts +39 -0
- package/src/client/components/ImageWithFallback.tsx +38 -0
- package/src/client/index.ts +6 -4
- package/src/client/styles/member.css +1740 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/server/action.ts +348 -0
- package/src/server/cards/index.ts +7 -7
- package/src/server/cards/member-card-summary/meta.ts +26 -5
- package/src/server/cards/member-card-summary/samples.ts +272 -0
- package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
- package/src/server/cards/member-enrollment-form/meta.ts +10 -6
- package/src/server/cards/member-enrollment-status/meta.ts +5 -6
- package/src/server/cards/member-info/meta.ts +1 -1
- package/src/server/cards/member-login-cta/meta.ts +16 -0
- package/src/server/cards/member-point-usage-list/meta.ts +2 -3
- package/src/server/handler.ts +159 -119
- package/src/server/index.ts +129 -50
- package/src/server/ports.ts +66 -0
- package/src/server/service.ts +94 -80
- package/src/server/status.ts +87 -0
- package/src/server/subject-binding.ts +19 -0
- package/src/server/tool.ts +469 -99
- package/tbox.module.json +124 -0
- package/tests/cards-render.test.tsx +526 -0
- package/tests/enrollment.test.ts +121 -0
- package/tests/ports-resolve.test.ts +95 -0
- package/tests/preference-memory.test.ts +86 -0
- package/tests/samples.test.ts +61 -0
- package/tests/service.test.ts +593 -35
- package/tests/skills.test.ts +65 -0
- package/tests/snapshot-service.test.ts +75 -0
- package/tests/status.test.ts +127 -0
- package/tests/view.test.ts +33 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-JGZGLFO6.js +0 -111
- package/dist/chunk-ZV6B36AO.js +0 -613
- package/src/client/cards/index.tsx +0 -135
- package/src/server/cards/member-coupon-detail/meta.ts +0 -19
- package/src/server/cards/member-coupon-list/meta.ts +0 -18
- package/tbox.component.json +0 -28
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 015 S4:入会五阶段 mock 闭环(授权 effect → complete-auth → PII 暂存 → enroll → 状态刷新)。
|
|
3
|
+
* 覆盖:授权成功预填 / 授权降级 manual / PII TTL 与用后即删(F2)/ 幂等重入 / pending 刷新收口(R4)。
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { createInMemoryStateStore } from '@tbox.cn/app-sdk/server';
|
|
7
|
+
import {
|
|
8
|
+
createMemberActions,
|
|
9
|
+
MEMBER_PROFILE_NAMESPACE,
|
|
10
|
+
buildEnrollmentIdempotencyKey,
|
|
11
|
+
ENROLLMENT_FORM_VERSION,
|
|
12
|
+
} from '../src/server/action';
|
|
13
|
+
import { InMemoryMemberService } from '../src/server/service';
|
|
14
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
15
|
+
|
|
16
|
+
const member = new InMemoryMemberService();
|
|
17
|
+
const state = createInMemoryStateStore();
|
|
18
|
+
|
|
19
|
+
// ServerContext 最小 fake(动作只用 ctx.state)
|
|
20
|
+
const ctxFake = { state } as never;
|
|
21
|
+
const actions = createMemberActions(ctxFake, async () => member);
|
|
22
|
+
const reqCtx = (userId = 'u_1'): RequestContext =>
|
|
23
|
+
({ identity: { userId, source: 'dev' }, attributes: { mall: { miniAppId: 'app', mallId: 'mall-1', subjectId: 's1' } } }) as never;
|
|
24
|
+
|
|
25
|
+
describe('入会链:授权 → 预填 → 提交 → 刷新', () => {
|
|
26
|
+
it('阶段 1:request-alipay-authorization 返回 authorization effect(resultActionId 指向回调动作)', async () => {
|
|
27
|
+
const result = await actions.requestAuthorization.execute!({}, reqCtx());
|
|
28
|
+
expect(result.effects).toEqual([
|
|
29
|
+
{ type: 'authorization.alipay-user-profile', scopes: ['auth_user'], resultActionId: 'member.complete-alipay-authorization' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('阶段 2:complete-auth success + fake PII 暂存 → 预填表单卡(mobile readOnly)', async () => {
|
|
34
|
+
// 不打真实支付宝(env 未配置)→ 模拟授权失败路径先行验证 manual 降级
|
|
35
|
+
const manual = await actions.completeAuthorization.execute!({ status: 'cancel' }, reqCtx());
|
|
36
|
+
expect(manual.card?.cardType).toBe('member-enrollment-form');
|
|
37
|
+
expect((manual.card?.data as { manualNotice?: string }).manualNotice).toBeTruthy();
|
|
38
|
+
|
|
39
|
+
// success + authCode(ALIPAY 未配置 → 降级 manual;此处直接预置 PII 验证 enroll 消费路径)
|
|
40
|
+
const stored = { mobile: '13800001234', displayName: '张三' };
|
|
41
|
+
await state.put(MEMBER_PROFILE_NAMESPACE, 'u_2', stored, 30 * 60 * 1000);
|
|
42
|
+
const enroll = await actions.enroll.execute!(
|
|
43
|
+
{ fieldValues: { mobile: '15999999999' }, acceptedConsentIds: ['privacy'] },
|
|
44
|
+
reqCtx('u_2'),
|
|
45
|
+
);
|
|
46
|
+
// 授权 mobile 强制覆盖手填(pending 卡返回 + PII 已消费)
|
|
47
|
+
expect((enroll.card?.data as { state: string }).state).toBe('pending_confirmation');
|
|
48
|
+
// F2:PII 用后即删(consume 语义)
|
|
49
|
+
expect(await state.get(MEMBER_PROFILE_NAMESPACE, 'u_2')).toBeNull();
|
|
50
|
+
// 手机号以授权值入库:手动改用授权 mobile 的记录可通过 profile 验证(pending 后刷新 active)
|
|
51
|
+
const attemptRef = (enroll.card?.data as { attemptRef?: string }).attemptRef;
|
|
52
|
+
const first = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_2'));
|
|
53
|
+
expect((first.update?.data as { state: string }).state).toBe('pending_confirmation');
|
|
54
|
+
const second = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_2'));
|
|
55
|
+
expect((second.update?.data as { state: string }).state).toBe('active');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('阶段 3:enroll 必选 consent 未勾 → 拒绝', async () => {
|
|
59
|
+
const result = await actions.enroll.execute!({ fieldValues: { mobile: '13800001234' }, acceptedConsentIds: [] }, reqCtx('u_3'));
|
|
60
|
+
expect((result.card?.data as { state: string }).state).toBe('failed');
|
|
61
|
+
expect((result.card?.data as { message?: string }).message).toContain('隐私政策');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('阶段 4:enroll manual 手机号非法 → 拒绝', async () => {
|
|
65
|
+
const result = await actions.enroll.execute!({ fieldValues: { mobile: '123' }, acceptedConsentIds: ['privacy'] }, reqCtx('u_4'));
|
|
66
|
+
expect((result.card?.data as { state: string }).state).toBe('failed');
|
|
67
|
+
expect((result.card?.data as { message?: string }).message).toContain('手机号');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('阶段 5:pending → 显式刷新(两次查询后 active,R4 无自动轮询)', async () => {
|
|
71
|
+
const enroll = await actions.enroll.execute!(
|
|
72
|
+
{ fieldValues: { mobile: '13800005678' }, acceptedConsentIds: ['privacy'] },
|
|
73
|
+
reqCtx('u_5'),
|
|
74
|
+
);
|
|
75
|
+
const attemptRef = (enroll.card?.data as { attemptRef?: string }).attemptRef;
|
|
76
|
+
expect(attemptRef).toBeTruthy();
|
|
77
|
+
|
|
78
|
+
const first = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_5'));
|
|
79
|
+
expect((first.update?.data as { state: string }).state).toBe('pending_confirmation');
|
|
80
|
+
|
|
81
|
+
const second = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_5'));
|
|
82
|
+
expect((second.update?.data as { state: string }).state).toBe('active');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('幂等键:同 userId+mallId+formVersion 恒定(sha256 双 \\0 分隔)', () => {
|
|
86
|
+
const k1 = buildEnrollmentIdempotencyKey('u_1', 'mall-1', ENROLLMENT_FORM_VERSION);
|
|
87
|
+
const k2 = buildEnrollmentIdempotencyKey('u_1', 'mall-1', ENROLLMENT_FORM_VERSION);
|
|
88
|
+
const k3 = buildEnrollmentIdempotencyKey('u_1', 'mall-2', ENROLLMENT_FORM_VERSION);
|
|
89
|
+
expect(k1).toBe(k2);
|
|
90
|
+
expect(k1).not.toBe(k3);
|
|
91
|
+
expect(k1).toHaveLength(64);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('动作声明:complete-alipay-authorization 为 hidden(不渲染按钮,补签通道专用)', () => {
|
|
95
|
+
expect(actions.completeAuthorization.hidden).toBe(true);
|
|
96
|
+
expect(actions.completeAuthorization.singleUse).toBe(true);
|
|
97
|
+
expect(actions.requestAuthorization.singleUse).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('评审修复(#3):厂商异常释放幂等占位 → 重试可成功(不固化 24h 拒绝)', async () => {
|
|
101
|
+
const flaky = new InMemoryMemberService();
|
|
102
|
+
const origEnroll = flaky.enroll.bind(flaky);
|
|
103
|
+
let failNext = true;
|
|
104
|
+
flaky.enroll = async (input: Parameters<InMemoryMemberService['enroll']>[0]) => {
|
|
105
|
+
if (failNext) throw new Error('gateway timeout');
|
|
106
|
+
return origEnroll(input);
|
|
107
|
+
};
|
|
108
|
+
const flakyActions = createMemberActions(ctxFake, async () => flaky);
|
|
109
|
+
const payload = { fieldValues: { mobile: '13800009999' }, acceptedConsentIds: ['privacy'] };
|
|
110
|
+
|
|
111
|
+
// 第一次:厂商异常 → failed + 提示重试(占位已释放)
|
|
112
|
+
const first = await flakyActions.enroll.execute!(payload, reqCtx('u_retry'));
|
|
113
|
+
expect((first.card?.data as { state: string }).state).toBe('failed');
|
|
114
|
+
expect((first.card?.data as { message?: string }).message).toContain('请稍后重试');
|
|
115
|
+
|
|
116
|
+
// 第二次:恢复 → 占位不阻塞,正常受理
|
|
117
|
+
failNext = false;
|
|
118
|
+
const second = await flakyActions.enroll.execute!(payload, reqCtx('u_retry'));
|
|
119
|
+
expect((second.card?.data as { state: string }).state).toBe('pending_confirmation');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member ports 017 槽位原子化回归锁(五槽直连上抛,无 InMemory 兜底)。
|
|
3
|
+
* - 槽位命中 → adapter 产物 + service 参数 = 槽 id;
|
|
4
|
+
* - PROVIDER_NOT_IMPLEMENTED / 任何 registry 错误 → 上抛(member 域 fail-fast,
|
|
5
|
+
* 与 parking/promotion InMemory 兜底、guide 增强面兜底语义三分);
|
|
6
|
+
* - mallId 缺失 / registry 缺失 → 显式错误。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
10
|
+
import { createServerContext } from '@tbox.cn/app-sdk/server';
|
|
11
|
+
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
12
|
+
import {
|
|
13
|
+
resolveMemberAccountPort,
|
|
14
|
+
resolveMemberBenefitsPort,
|
|
15
|
+
resolveMemberCardPort,
|
|
16
|
+
resolveMemberEnrollmentPort,
|
|
17
|
+
resolveMemberProfilePort,
|
|
18
|
+
} from '../src/server/ports';
|
|
19
|
+
|
|
20
|
+
function requestContext(mallId = 'mall-demo', token = 'user-token'): RequestContext {
|
|
21
|
+
return {
|
|
22
|
+
identity: { userId: 'u_1001', source: 'dev' },
|
|
23
|
+
credentials: { provider: 'dev', token },
|
|
24
|
+
attributes: { mall: { miniAppId: 'demo-app', mallId } },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ctxWithIntegrations(resolveService: (input: unknown) => Promise<unknown>): ServerContext {
|
|
29
|
+
const ctx = createServerContext({});
|
|
30
|
+
(ctx as { integrations?: unknown }).integrations = { resolveService };
|
|
31
|
+
(ctx as { logger?: unknown }).logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sentinel = { kind: 'slot-hit' } as unknown as never;
|
|
36
|
+
|
|
37
|
+
const RESOLVERS = [
|
|
38
|
+
{ name: 'member.account', resolve: resolveMemberAccountPort },
|
|
39
|
+
{ name: 'member.profile', resolve: resolveMemberProfilePort },
|
|
40
|
+
{ name: 'member.card', resolve: resolveMemberCardPort },
|
|
41
|
+
{ name: 'member.benefits', resolve: resolveMemberBenefitsPort },
|
|
42
|
+
{ name: 'member.enrollment', resolve: resolveMemberEnrollmentPort },
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
describe('member ports 五槽解析(017 槽位原子化;fail-fast 无兜底)', () => {
|
|
46
|
+
for (const { name, resolve } of RESOLVERS) {
|
|
47
|
+
it(`[${name}] T1 槽位命中 → adapter 产物 + service 参数 = 槽 id`, async () => {
|
|
48
|
+
let captured: unknown;
|
|
49
|
+
const ctx = ctxWithIntegrations(async (input) => {
|
|
50
|
+
captured = input;
|
|
51
|
+
return sentinel;
|
|
52
|
+
});
|
|
53
|
+
await expect(resolve(requestContext(), ctx)).resolves.toBe(sentinel);
|
|
54
|
+
expect(captured).toMatchObject({ service: name, scope: { mallId: 'mall-demo' } });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it(`[${name}] T2 PROVIDER_NOT_IMPLEMENTED → 上抛(member 无兜底)`, async () => {
|
|
58
|
+
const ctx = ctxWithIntegrations(async () => {
|
|
59
|
+
throw Object.assign(new Error('无 adapter'), { code: 'PROVIDER_NOT_IMPLEMENTED' });
|
|
60
|
+
});
|
|
61
|
+
await expect(resolve(requestContext(), ctx)).rejects.toMatchObject({ code: 'PROVIDER_NOT_IMPLEMENTED' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it(`[${name}] T3 预期缺席(SERVICE_NOT_CONFIGURED)→ 上抛(配置即声明;工具层转译文案)`, async () => {
|
|
65
|
+
const ctx = ctxWithIntegrations(async () => {
|
|
66
|
+
throw Object.assign(new Error('未配置'), { code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
|
|
67
|
+
});
|
|
68
|
+
await expect(resolve(requestContext(), ctx)).rejects.toMatchObject({ code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it(`[${name}] T4 mallId 缺失 → 显式错误(不落空 scope)`, async () => {
|
|
72
|
+
const ctx = ctxWithIntegrations(async () => sentinel);
|
|
73
|
+
await expect(resolve({ ...requestContext(), attributes: {} }, ctx)).rejects.toThrow('缺少 mallId');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('T5 ctx 无 integrations → 显式错误(registry 缺失不静默)', async () => {
|
|
78
|
+
await expect(resolveMemberAccountPort(requestContext(), createServerContext({}))).rejects.toThrow(
|
|
79
|
+
'integrations registry 缺失',
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('T6 槽级隔离:account 缺席不拖累 benefits(部分绑定显式失败粒度)', async () => {
|
|
84
|
+
const ctx = ctxWithIntegrations(async (input: unknown) => {
|
|
85
|
+
if ((input as { service: string }).service === 'member.account') {
|
|
86
|
+
throw Object.assign(new Error('未配置'), { code: 'PROVIDER_SERVICE_NOT_CONFIGURED' });
|
|
87
|
+
}
|
|
88
|
+
return sentinel;
|
|
89
|
+
});
|
|
90
|
+
await expect(resolveMemberAccountPort(requestContext(), ctx)).rejects.toMatchObject({
|
|
91
|
+
code: 'PROVIDER_SERVICE_NOT_CONFIGURED',
|
|
92
|
+
});
|
|
93
|
+
await expect(resolveMemberBenefitsPort(requestContext(), ctx)).resolves.toBe(sentinel);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createInMemoryMemoryStore, createServerContext } from '@tbox.cn/app-sdk/server';
|
|
3
|
+
import { createMemberHandler } from '../src/server/handler';
|
|
4
|
+
|
|
5
|
+
/** 上下文统一化:显式缝——reqCtx 由调用方透传(handle 第四参) */
|
|
6
|
+
function authenticatedContext(userId = 'user-1', mallId = 'mall-1') {
|
|
7
|
+
const memory = createInMemoryMemoryStore();
|
|
8
|
+
const ctx = createServerContext({ memory });
|
|
9
|
+
const reqCtx = {
|
|
10
|
+
identity: { userId, source: 'dev' as const },
|
|
11
|
+
attributes: { mall: { mallId, miniAppId: 'app-1', subjectId: 'subject-1' } },
|
|
12
|
+
};
|
|
13
|
+
return { ctx, memory, reqCtx };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('会员偏好长期记忆', () => {
|
|
17
|
+
it('三项声明式 intent 与 PRD 问题一致(exact 整句全等,不入 BM25 词袋)', () => {
|
|
18
|
+
const { ctx } = authenticatedContext();
|
|
19
|
+
expect(createMemberHandler(ctx).intents).toEqual([
|
|
20
|
+
{ patterns: ['我常来买衣服', '我常带娃来玩', '我常来吃美食'], match: 'exact' },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('登录用户点击 query 后按 userId × mallId 保存 preference', async () => {
|
|
25
|
+
const { ctx, memory, reqCtx } = authenticatedContext();
|
|
26
|
+
const result = await createMemberHandler(ctx).handle('intent', { content: '我常来买衣服' }, ctx, reqCtx);
|
|
27
|
+
|
|
28
|
+
expect(result.text).toContain('记住啦');
|
|
29
|
+
await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toMatchObject([
|
|
30
|
+
{ kind: 'preference', content: '会员偏好:我常来买衣服' },
|
|
31
|
+
]);
|
|
32
|
+
await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-2' } })).resolves.toEqual([]);
|
|
33
|
+
await expect(memory.list({ userId: 'other-user', scope: { mallId: 'mall-1' } })).resolves.toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('保存后返回受控 Agent 续问,由 chat-engine 继续完整导购流程', async () => {
|
|
37
|
+
const { ctx, memory, reqCtx } = authenticatedContext();
|
|
38
|
+
|
|
39
|
+
const result = await createMemberHandler(ctx).handle('intent', { content: '我常来买衣服' }, ctx, reqCtx);
|
|
40
|
+
|
|
41
|
+
expect(result.text).toBe('记住啦,你常来买衣服。正在为你查找相关的门店和导购服务,请稍等。\n\n');
|
|
42
|
+
expect(result.continueWith).toEqual({ content: '推荐适合购买的服装门店和商品' });
|
|
43
|
+
expect(result.cards).toEqual([]);
|
|
44
|
+
await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toMatchObject([
|
|
45
|
+
{ kind: 'preference', content: '会员偏好:我常来买衣服' },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('匿名用户与 memory-off 上下文均不写入', async () => {
|
|
50
|
+
const memory = createInMemoryMemoryStore();
|
|
51
|
+
const anonymous = createServerContext({ memory });
|
|
52
|
+
const anonymousResult = await createMemberHandler(anonymous).handle(
|
|
53
|
+
'intent',
|
|
54
|
+
{ content: '我常来吃美食' },
|
|
55
|
+
anonymous,
|
|
56
|
+
{ identity: { userId: 'anonymous', source: 'anonymous' as const } },
|
|
57
|
+
);
|
|
58
|
+
expect(anonymousResult.text).toContain('登录后');
|
|
59
|
+
await expect(memory.list({ userId: 'anonymous' })).resolves.toEqual([]);
|
|
60
|
+
|
|
61
|
+
const disabled = createServerContext();
|
|
62
|
+
const disabledResult = await createMemberHandler(disabled).handle(
|
|
63
|
+
'intent',
|
|
64
|
+
{ content: '我常带娃来玩' },
|
|
65
|
+
disabled,
|
|
66
|
+
{ identity: { userId: 'user-1', source: 'dev' as const } },
|
|
67
|
+
);
|
|
68
|
+
expect(disabledResult.text).toContain('暂不可用');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('不把包含偏好短语的自然语言误存为按钮选择(未识别 → 放行 LLM,零写入)', async () => {
|
|
72
|
+
const { ctx, memory, reqCtx } = authenticatedContext();
|
|
73
|
+
const result = await createMemberHandler(ctx).handle(
|
|
74
|
+
'intent',
|
|
75
|
+
{ content: '我还没决定是不是我常来买衣服' },
|
|
76
|
+
ctx,
|
|
77
|
+
reqCtx,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 洋葱定律 1:未识别 = 失败 = 放行(原 12 字死文案分支改为 continueWith 续接)
|
|
81
|
+
expect(result.cards).toEqual([]);
|
|
82
|
+
expect(result.text).toBeUndefined();
|
|
83
|
+
expect(result.continueWith).toEqual({ content: '我还没决定是不是我常来买衣服' });
|
|
84
|
+
await expect(memory.list({ userId: 'user-1', scope: { mallId: 'mall-1' } })).resolves.toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* samples 双断言(迁移自 mall dev-gallery 会员卡面画廊守卫):
|
|
3
|
+
* - 全样本 dataSchema.safeParse 通过 + findSampleShapeViolations 无越界键(样本即契约);
|
|
4
|
+
* - 守卫转移:卡清单收口(会员域对话样本只覆盖卡面一张——开卡入会三卡随登录承接入会
|
|
5
|
+
* 退场、资料卡与积分流水卡由首页服务卡承接,再往回加卡得先改这里);
|
|
6
|
+
* - 画廊时期 autoClick 禁用守卫由 CardSample 类型天然满足(无该字段,编译期不可能携带
|
|
7
|
+
* 主动交互——样本只描述状态,不描述动作)。
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { findSampleShapeViolations } from '@tbox.cn/app-sdk/server';
|
|
11
|
+
import meta from '../src/server/cards/member-card-summary/meta';
|
|
12
|
+
|
|
13
|
+
describe('member samples 双断言(样本即契约)', () => {
|
|
14
|
+
it('member-card-summary: 全样本过 schema + shape 无越界', () => {
|
|
15
|
+
expect(meta.samples?.length ?? 0).toBeGreaterThan(0);
|
|
16
|
+
expect(findSampleShapeViolations(meta).violations).toEqual([]);
|
|
17
|
+
for (const s of meta.samples ?? []) {
|
|
18
|
+
expect(
|
|
19
|
+
meta.dataSchema.safeParse(s.data).success,
|
|
20
|
+
s.label,
|
|
21
|
+
).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('卡清单收口:11 格且全部属于 member-card-summary(卡面唯一)', () => {
|
|
26
|
+
expect(meta.cardType).toBe('member-card-summary');
|
|
27
|
+
expect(meta.samples).toHaveLength(11);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('四档 variant 全覆盖 + 高低档权益数量差异照实(14/15)', () => {
|
|
31
|
+
const variants = new Set(
|
|
32
|
+
(meta.samples ?? [])
|
|
33
|
+
.map((s) => (s.data as { presentation?: { variant?: string } }).presentation?.variant),
|
|
34
|
+
);
|
|
35
|
+
expect(variants.has('blue')).toBe(true);
|
|
36
|
+
expect(variants.has('silver')).toBe(true);
|
|
37
|
+
expect(variants.has('gold')).toBe(true);
|
|
38
|
+
expect(variants.has('black')).toBe(true);
|
|
39
|
+
|
|
40
|
+
const counts = (meta.samples ?? [])
|
|
41
|
+
.map((s) => (s.data as { benefits?: unknown[] }).benefits?.length)
|
|
42
|
+
.filter((n): n is number => n !== undefined);
|
|
43
|
+
expect(Math.max(...counts)).toBe(15);
|
|
44
|
+
expect(Math.min(...counts.filter((n) => n >= 10))).toBe(14);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('素材中立:权益图标全部为内联 SVG 分色占位或 example.com 占位', () => {
|
|
48
|
+
const serialized = JSON.stringify(meta.samples ?? []);
|
|
49
|
+
expect(serialized).toContain('data:image/svg+xml');
|
|
50
|
+
// 结构性断言:每个 iconUrl 要么内联 SVG,要么 example.com 占位(外链失败分支)——不允许其他外域
|
|
51
|
+
for (const s of meta.samples ?? []) {
|
|
52
|
+
for (const b of (s.data as { benefits?: { name?: string; iconUrl?: string }[] }).benefits ?? []) {
|
|
53
|
+
if (!b.iconUrl) continue;
|
|
54
|
+
expect(
|
|
55
|
+
b.iconUrl.startsWith('data:image/svg+xml') || b.iconUrl.startsWith('https://assets.example.com/'),
|
|
56
|
+
`${s.label}/${b.name}`,
|
|
57
|
+
).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|