@tbox.cn/app-module-member 0.2.0 → 0.8.6

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.
Files changed (65) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +99 -11
  3. package/dist/chunk-KEBILLQ5.js +771 -0
  4. package/dist/chunk-T4SYPQA5.js +1565 -0
  5. package/dist/client/index.d.ts +2 -2
  6. package/dist/client/index.js +1 -1
  7. package/dist/index.d.ts +2 -3
  8. package/dist/index.js +12 -2
  9. package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
  10. package/dist/member-QFCIYILN.css +1734 -0
  11. package/dist/server/index.d.ts +617 -246
  12. package/dist/server/index.js +11 -1
  13. package/package.json +25 -16
  14. package/skills/member/SKILL.md +141 -0
  15. package/skills/member/references/enrollment.md +34 -0
  16. package/skills/member/references/points-and-card.md +24 -0
  17. package/src/client/assets/login-mascot.jpg +0 -0
  18. package/src/client/assets.d.ts +5 -0
  19. package/src/client/cards/index.ts +10 -0
  20. package/src/client/cards/member-card-summary/index.tsx +375 -0
  21. package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
  22. package/src/client/cards/member-enrollment-form/index.tsx +181 -0
  23. package/src/client/cards/member-enrollment-status/index.tsx +65 -0
  24. package/src/client/cards/member-info/index.tsx +60 -0
  25. package/src/client/cards/member-login-cta/index.tsx +81 -0
  26. package/src/client/cards/member-point-usage-list/index.tsx +53 -0
  27. package/src/client/cards/view.ts +39 -0
  28. package/src/client/components/ImageWithFallback.tsx +38 -0
  29. package/src/client/index.ts +6 -4
  30. package/src/client/styles/member.css +1734 -0
  31. package/src/client/types/css.d.ts +2 -0
  32. package/src/server/action.ts +348 -0
  33. package/src/server/cards/index.ts +7 -7
  34. package/src/server/cards/member-card-summary/meta.ts +26 -5
  35. package/src/server/cards/member-card-summary/samples.ts +272 -0
  36. package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
  37. package/src/server/cards/member-enrollment-form/meta.ts +10 -6
  38. package/src/server/cards/member-enrollment-status/meta.ts +5 -6
  39. package/src/server/cards/member-info/meta.ts +1 -1
  40. package/src/server/cards/member-login-cta/meta.ts +16 -0
  41. package/src/server/cards/member-point-usage-list/meta.ts +2 -3
  42. package/src/server/handler.ts +159 -119
  43. package/src/server/index.ts +109 -50
  44. package/src/server/ports.ts +66 -0
  45. package/src/server/service.ts +94 -80
  46. package/src/server/status.ts +87 -0
  47. package/src/server/subject-binding.ts +19 -0
  48. package/src/server/tool.ts +469 -99
  49. package/tbox.module.json +124 -0
  50. package/tests/cards-render.test.tsx +526 -0
  51. package/tests/enrollment.test.ts +121 -0
  52. package/tests/ports-resolve.test.ts +95 -0
  53. package/tests/preference-memory.test.ts +86 -0
  54. package/tests/samples.test.ts +61 -0
  55. package/tests/service.test.ts +593 -35
  56. package/tests/skills.test.ts +65 -0
  57. package/tests/status.test.ts +127 -0
  58. package/tests/view.test.ts +33 -0
  59. package/tsup.config.ts +9 -1
  60. package/dist/chunk-JGZGLFO6.js +0 -111
  61. package/dist/chunk-ZV6B36AO.js +0 -613
  62. package/src/client/cards/index.tsx +0 -135
  63. package/src/server/cards/member-coupon-detail/meta.ts +0 -19
  64. package/src/server/cards/member-coupon-list/meta.ts +0 -18
  65. package/tbox.component.json +0 -28
@@ -0,0 +1,124 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "module-member",
4
+ "version": "0.3.0",
5
+ "kind": "business",
6
+ "risk": {
7
+ "level": "medium",
8
+ "writeBoundary": "source"
9
+ },
10
+ "distribution": {
11
+ "defaultMode": "codegen"
12
+ },
13
+ "contributes": {
14
+ "handlers": [
15
+ "member"
16
+ ],
17
+ "tools": [
18
+ "queryPoints",
19
+ "queryMemberInfo",
20
+ "queryMemberCard",
21
+ "queryEnrollmentForm"
22
+ ],
23
+ "cards": [
24
+ {
25
+ "cardType": "member-info",
26
+ "schemaVersion": 1
27
+ },
28
+ {
29
+ "cardType": "member-card-summary",
30
+ "schemaVersion": 5
31
+ },
32
+ {
33
+ "cardType": "member-point-usage-list",
34
+ "schemaVersion": 1
35
+ },
36
+ {
37
+ "cardType": "member-enrollment-form",
38
+ "schemaVersion": 2
39
+ },
40
+ {
41
+ "cardType": "member-enrollment-status",
42
+ "schemaVersion": 2
43
+ },
44
+ {
45
+ "cardType": "member-enrollment-cta",
46
+ "schemaVersion": 2
47
+ },
48
+ {
49
+ "cardType": "member-login-cta",
50
+ "schemaVersion": 1
51
+ }
52
+ ],
53
+ "routes": [],
54
+ "pages": [],
55
+ "tabs": [],
56
+ "actions": [
57
+ "member.request-alipay-authorization",
58
+ "member.complete-alipay-authorization",
59
+ "member.enroll",
60
+ "member.query-enrollment-status"
61
+ ],
62
+ "services": [
63
+ {
64
+ "service": "member.account",
65
+ "optional": false,
66
+ "defaults": [
67
+ {
68
+ "provider": "mock",
69
+ "implementation": "mock-member@1"
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ "service": "member.profile",
75
+ "optional": false,
76
+ "defaults": [
77
+ {
78
+ "provider": "mock",
79
+ "implementation": "mock-member@1"
80
+ }
81
+ ]
82
+ },
83
+ {
84
+ "service": "member.card",
85
+ "optional": false,
86
+ "defaults": [
87
+ {
88
+ "provider": "mock",
89
+ "implementation": "mock-member@1"
90
+ }
91
+ ]
92
+ },
93
+ {
94
+ "service": "member.benefits",
95
+ "optional": false,
96
+ "defaults": [
97
+ {
98
+ "provider": "mock",
99
+ "implementation": "mock-member@1"
100
+ }
101
+ ]
102
+ },
103
+ {
104
+ "service": "member.enrollment",
105
+ "optional": false,
106
+ "defaults": [
107
+ {
108
+ "provider": "mock",
109
+ "implementation": "mock-member@1"
110
+ }
111
+ ]
112
+ }
113
+ ]
114
+ },
115
+ "dependencies": {
116
+ "modules": [
117
+ {
118
+ "id": "contracts-mall",
119
+ "required": true
120
+ }
121
+ ]
122
+ },
123
+ "env": []
124
+ }
@@ -0,0 +1,526 @@
1
+ /**
2
+ * member 客户端卡片渲染测试(0.4.0 D9:happy-dom)。
3
+ * 重点:入会 CTA/表单/状态卡的显式动作、历史禁用、校验与防重复提交。
4
+ */
5
+ // @vitest-environment happy-dom
6
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { act } from 'react-dom/test-utils';
9
+ import { sendCardAction } from '@tbox.cn/app-sdk/client';
10
+ import {
11
+ MemberCardSummaryCard,
12
+ MemberEnrollmentCtaCard,
13
+ MemberLoginCtaCard,
14
+ MemberEnrollmentFormCard,
15
+ MemberEnrollmentStatusCard,
16
+ MemberInfoCard,
17
+ MemberPointUsageListCard,
18
+ } from '../src/client/cards';
19
+ import MemberCardSummaryCardDefault from '../src/client/cards/member-card-summary';
20
+ import MemberEnrollmentFormCardDefault from '../src/client/cards/member-enrollment-form';
21
+ import MemberEnrollmentStatusCardDefault from '../src/client/cards/member-enrollment-status';
22
+
23
+ vi.mock('@tbox.cn/app-sdk/client', async (importOriginal) => {
24
+ const mod = await importOriginal<typeof import('@tbox.cn/app-sdk/client')>();
25
+ return { ...mod, sendCardAction: vi.fn() };
26
+ });
27
+
28
+ const mockedSend = () => vi.mocked(sendCardAction);
29
+
30
+ function render(ui: React.ReactElement) {
31
+ const host = document.createElement('div');
32
+ document.body.appendChild(host);
33
+ const root = createRoot(host);
34
+ act(() => {
35
+ root.render(ui);
36
+ });
37
+ return { host, root };
38
+ }
39
+
40
+ const formData = {
41
+ userId: 'u1',
42
+ state: 'not_enrolled',
43
+ formVersion: 'v1',
44
+ fields: [
45
+ { fieldId: 'mobile', label: '手机号', type: 'tel', required: true, readOnly: true, prefill: '138****8888' },
46
+ { fieldId: 'name', label: '姓名', type: 'text', required: true },
47
+ { fieldId: 'gender', label: '性别', type: 'select', required: false, options: ['男', '女'] },
48
+ ],
49
+ consents: [{ consentId: 'privacy', version: '1', title: '隐私政策', required: true }],
50
+ authorization: { status: 'required' },
51
+ };
52
+
53
+ describe('MemberEnrollmentFormCard 交互矩阵', () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ it('select 字段渲染为 <select> 且含 options', () => {
59
+ const { host } = render(
60
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
61
+ );
62
+ const select = host.querySelector('select') as HTMLSelectElement;
63
+ expect(select).not.toBeNull();
64
+ expect(select.querySelectorAll('option').length).toBe(3);
65
+ });
66
+
67
+ it('required 授权仅在用户显式点击后触发,且点击后防重复派发', () => {
68
+ const { host } = render(
69
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
70
+ );
71
+ expect(mockedSend()).not.toHaveBeenCalled();
72
+ const auth = host.querySelector('.member-enrollment-form__auth-btn') as HTMLButtonElement;
73
+ act(() => {
74
+ auth.click();
75
+ auth.click();
76
+ });
77
+ expect(auth.disabled).toBe(true);
78
+ expect(mockedSend()).toHaveBeenCalledWith(
79
+ 'c1',
80
+ { type: 'moduleAction', actionId: 'member.request-alipay-authorization', contextToken: 'auth' },
81
+ );
82
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it('token 缺失 → 不自动触发,渲染手动授权按钮但禁用(无 token 点击无意义)', () => {
86
+ const { host } = render(
87
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
88
+ );
89
+ expect(mockedSend()).not.toHaveBeenCalled();
90
+ const btn = host.querySelector('.member-enrollment-form__auth-btn') as HTMLButtonElement;
91
+ expect(btn).not.toBeNull();
92
+ expect(btn.disabled).toBe(true);
93
+ });
94
+
95
+ it('isHistory → 全禁 + 不自动触发', () => {
96
+ const { host } = render(
97
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={true} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
98
+ );
99
+ expect(mockedSend()).not.toHaveBeenCalled();
100
+ const controls = host.querySelectorAll('input, select');
101
+ controls.forEach((c) => expect((c as HTMLInputElement).disabled || (c as HTMLInputElement).readOnly).toBe(true));
102
+ const submit = host.querySelector('.member-ef-submit') as HTMLButtonElement;
103
+ expect(submit.disabled).toBe(true);
104
+ });
105
+
106
+ it('readOnly 字段渲染锁定图标', () => {
107
+ const { host } = render(
108
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
109
+ );
110
+ expect(host.querySelector('.member-ef-field__lock')).not.toBeNull();
111
+ const mobile = host.querySelectorAll('.member-ef-field__control')[0] as HTMLInputElement;
112
+ expect(mobile.readOnly).toBe(true);
113
+ });
114
+
115
+ it('必填字段或必选协议缺失时原地提示,不发送入会动作;完整表单只提交一次', () => {
116
+ const { host } = render(
117
+ <MemberEnrollmentFormCard data={formData as never} cardId="c-submit" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
118
+ );
119
+ const submit = host.querySelector('.member-ef-submit') as HTMLButtonElement;
120
+ act(() => submit.click());
121
+ expect(host.textContent).toContain('请填写姓名');
122
+ expect(host.textContent).toContain('请先同意:隐私政策');
123
+ expect(mockedSend()).not.toHaveBeenCalled();
124
+
125
+ mockedSend().mockClear();
126
+ const completeData = {
127
+ ...formData,
128
+ fields: formData.fields.map((field) => field.fieldId === 'name' ? { ...field, prefill: '张三' } : field),
129
+ consents: [],
130
+ };
131
+ const complete = render(
132
+ <MemberEnrollmentFormCard data={completeData as never} cardId="c-submit-complete" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
133
+ );
134
+ const completeSubmit = complete.host.querySelector('.member-ef-submit') as HTMLButtonElement;
135
+ act(() => {
136
+ completeSubmit.click();
137
+ completeSubmit.click();
138
+ });
139
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
140
+ expect(mockedSend()).toHaveBeenCalledWith(
141
+ 'c-submit-complete',
142
+ { type: 'moduleAction', actionId: 'member.enroll', contextToken: 'tok' },
143
+ expect.objectContaining({ acceptedConsentIds: [], formVersion: 'v1' }),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('MemberEnrollmentCtaCard', () => {
149
+ beforeEach(() => vi.clearAllMocks());
150
+
151
+ it('通过 SDK openLink 打开真实地址,双击只发送一次且不展示静态权益', () => {
152
+ const { host } = render(
153
+ <MemberEnrollmentCtaCard
154
+ data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled', enrollUrl: 'alipays://member/open', title: '入会后继续' }}
155
+ cardId="cta-1"
156
+ isHistory={false}
157
+ />,
158
+ );
159
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
160
+ act(() => {
161
+ button.click();
162
+ button.click();
163
+ });
164
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
165
+ expect(mockedSend()).toHaveBeenCalledWith('cta-1', { type: 'openLink', url: 'alipays://member/open' });
166
+ expect(button.disabled).toBe(true);
167
+ expect(host.textContent).not.toMatch(/1000|免费停车|专属停车|生日服务|会员专线/);
168
+ });
169
+
170
+ it('历史 CTA 禁用且不触发跳转', () => {
171
+ const { host } = render(
172
+ <MemberEnrollmentCtaCard data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled', enrollUrl: 'https://example.com/enroll' }} cardId="cta-history" isHistory />,
173
+ );
174
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
175
+ expect(button.disabled).toBe(true);
176
+ act(() => button.click());
177
+ expect(mockedSend()).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('无 enrollUrl(深链解耦形态)→ 按钮禁用、文案“暂不可用”且不发 openLink', () => {
181
+ const { host } = render(
182
+ <MemberEnrollmentCtaCard
183
+ data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled' }}
184
+ cardId="cta-nourl"
185
+ isHistory={false}
186
+ />,
187
+ );
188
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
189
+ expect(button.disabled).toBe(true);
190
+ expect(button.getAttribute('aria-label')).toBe('入会暂不可用(请联系商场客服)');
191
+ expect(host.textContent).toContain('暂不可用(请联系商场客服)');
192
+ act(() => button.click());
193
+ expect(mockedSend()).not.toHaveBeenCalled();
194
+ });
195
+ });
196
+
197
+ describe('MemberLoginCtaCard', () => {
198
+ beforeEach(() => vi.clearAllMocks());
199
+ const LOGIN_URL = 'alipays://platformapi/startapp?appId=demo&page=v/login/index';
200
+
201
+ it('通过 SDK openLink 跳转登录页,双击只发一次', () => {
202
+ const { host } = render(
203
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-1" isHistory={false} />,
204
+ );
205
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
206
+ act(() => {
207
+ button.click();
208
+ button.click();
209
+ });
210
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
211
+ expect(mockedSend()).toHaveBeenCalledWith('login-1', { type: 'openLink', url: LOGIN_URL });
212
+ expect(button.disabled).toBe(true);
213
+ expect(host.textContent).toContain('登录后可解锁全部功能');
214
+ });
215
+
216
+ it('不承诺登录之后有什么——权益要等厂商返回才知道', () => {
217
+ const { host } = render(
218
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-2" isHistory={false} />,
219
+ );
220
+ // 说明文案只讲「解锁全部功能」,不点名任何一项权益
221
+ expect(host.textContent).not.toMatch(/积分|折扣|免费停车|会员权益/);
222
+ });
223
+
224
+ it('历史卡禁用且不触发跳转', () => {
225
+ const { host } = render(
226
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-3" isHistory />,
227
+ );
228
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
229
+ expect(button.disabled).toBe(true);
230
+ act(() => button.click());
231
+ expect(mockedSend()).not.toHaveBeenCalled();
232
+ expect(host.textContent).toContain('历史卡片不可重复操作');
233
+ });
234
+
235
+ it('无 loginUrl(深链解耦形态)→ 按钮禁用、文案“暂不可用”且不发 openLink', () => {
236
+ const { host } = render(
237
+ <MemberLoginCtaCard data={{ title: '登录后继续' }} cardId="login-nourl" isHistory={false} />,
238
+ );
239
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
240
+ expect(button.disabled).toBe(true);
241
+ expect(button.getAttribute('aria-label')).toBe('登录暂不可用(请联系商场客服)');
242
+ expect(host.textContent).toContain('暂不可用(请联系商场客服)');
243
+ act(() => button.click());
244
+ expect(mockedSend()).not.toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe('MemberPointUsageListCard', () => {
249
+ it('流水双色类', () => {
250
+ const { host } = render(
251
+ <MemberPointUsageListCard
252
+ data={{
253
+ userId: 'u1',
254
+ balance: 100,
255
+ membershipLabel: '悦享卡',
256
+ items: [
257
+ { usageRecordRef: 'r1', title: '消费抵扣', amount: '-120', usedAt: '2026-08-19T10:30:00+08:00' },
258
+ { usageRecordRef: 'r2', title: '签到奖励', amount: '+50' },
259
+ ],
260
+ total: 2,
261
+ } as never}
262
+ />,
263
+ );
264
+ const rows = host.querySelectorAll('.member-pu__row b');
265
+ expect(host.textContent).toContain('悦享卡');
266
+ expect(host.textContent).toContain('最近积分记录');
267
+ expect(host.textContent).toContain('2026.08.19');
268
+ expect(host.textContent).not.toMatch(/金卡|银卡|铂金/);
269
+ expect(rows[0].className).toContain('member-pu__amount--spend');
270
+ expect(rows[1].className).toContain('member-pu__amount--income');
271
+ });
272
+ });
273
+
274
+ describe('MemberInfoCard', () => {
275
+ it('只显示用户资料,不暴露内部 memberRef,缺头像时使用正式占位', () => {
276
+ const { host } = render(
277
+ <MemberInfoCard
278
+ data={{
279
+ userId: 'u1',
280
+ authState: 'authenticated',
281
+ state: 'active',
282
+ displayName: '风清',
283
+ contactDisplay: '181****6886',
284
+ memberRef: 'member:internal-ref',
285
+ registeredAt: '2026-08-18T08:00:00+08:00',
286
+ labels: [{ labelRef: 'l1', name: '悦享会员' }],
287
+ }}
288
+ />,
289
+ );
290
+ expect(host.textContent).toContain('会员资料');
291
+ expect(host.textContent).toContain('181****6886');
292
+ expect(host.textContent).toContain('2026.08.18');
293
+ expect(host.textContent).toContain('悦享会员');
294
+ expect(host.textContent).not.toContain('member:internal-ref');
295
+ expect(host.querySelector('.member-info__avatar--fallback')).not.toBeNull();
296
+ expect(host.querySelector('img')).toBeNull();
297
+ });
298
+ });
299
+
300
+ describe('MemberEnrollmentStatusCard', () => {
301
+ beforeEach(() => vi.clearAllMocks());
302
+
303
+ it('pending 使用签发 token 刷新,双击只发送一次且不展示内部会员标识', () => {
304
+ const { host } = render(
305
+ <MemberEnrollmentStatusCard
306
+ data={{
307
+ userId: 'u1',
308
+ attemptRef: 'attempt-1',
309
+ state: 'pending_confirmation',
310
+ memberRef: 'member:internal-ref',
311
+ message: '厂商确认中,可稍后再刷新',
312
+ }}
313
+ cardId="status-1"
314
+ isHistory={false}
315
+ actionTokens={{ 'member.query-enrollment-status': 'refresh-token' }}
316
+ />,
317
+ );
318
+ expect(host.textContent).toContain('入会申请');
319
+ expect(host.textContent).toContain('厂商确认中');
320
+ expect(host.textContent).toContain('刷新状态');
321
+ expect(host.textContent).not.toContain('member:internal-ref');
322
+ const refresh = host.querySelector('.member-es__refresh') as HTMLButtonElement;
323
+ act(() => {
324
+ refresh.click();
325
+ refresh.click();
326
+ });
327
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
328
+ expect(mockedSend()).toHaveBeenCalledWith(
329
+ 'status-1',
330
+ { type: 'moduleAction', actionId: 'member.query-enrollment-status', contextToken: 'refresh-token' },
331
+ { attemptRef: 'attempt-1' },
332
+ );
333
+ expect(refresh.disabled).toBe(true);
334
+ });
335
+
336
+ it('历史 pending 禁用刷新并给出当前状态查询提示', () => {
337
+ const { host } = render(
338
+ <MemberEnrollmentStatusCard
339
+ data={{ userId: 'u1', attemptRef: 'a1', state: 'pending_confirmation' }}
340
+ cardId="status-history"
341
+ isHistory
342
+ actionTokens={{ 'member.query-enrollment-status': 'refresh-token' }}
343
+ />,
344
+ );
345
+ expect((host.querySelector('.member-es__refresh') as HTMLButtonElement).disabled).toBe(true);
346
+ expect(host.textContent).toContain('历史申请不可刷新');
347
+ });
348
+ });
349
+
350
+ describe('MemberCardSummaryCard', () => {
351
+ beforeEach(() => {
352
+ vi.clearAllMocks();
353
+ });
354
+
355
+ it('优先渲染 C 端 CardPhoto;无真实权益时不渲染静态权益区,且不把 levelName 当会员等级展示', () => {
356
+ const { host } = render(
357
+ <MemberCardSummaryCard
358
+ data={{
359
+ userId: 'u1',
360
+ cardRef: 'card-1',
361
+ cardDisplay: '悦享卡',
362
+ status: 'active',
363
+ levelName: '金卡',
364
+ availablePoints: 300,
365
+ cardImageUrl: 'https://img.example-mall.cn/sp_mall/member-card.png',
366
+ }}
367
+ />,
368
+ );
369
+ const image = host.querySelector('.member-card-summary__face') as HTMLImageElement;
370
+ expect(image.src).toBe('https://img.example-mall.cn/sp_mall/member-card.png');
371
+ expect(image.loading).toBe('eager');
372
+ expect(host.textContent).toContain('300');
373
+ expect(host.querySelector('.member-card-summary__benefits')).toBeNull();
374
+ expect(host.textContent).not.toMatch(/专属停车|积分兑换|生日服务|会员专线/);
375
+ expect(host.textContent).not.toContain('积分明细');
376
+ expect(host.textContent).not.toContain('全部优惠券');
377
+ expect(host.textContent).not.toContain('金卡');
378
+ });
379
+
380
+ it('动态卡面渲染具体积分、消费提示、进度线和接口权益', async () => {
381
+ const { host } = render(
382
+ <MemberCardSummaryCard
383
+ data={{
384
+ userId: 'u1',
385
+ cardRef: 'card-dynamic',
386
+ cardDisplay: '金卡会员',
387
+ status: 'active',
388
+ levelName: '金卡',
389
+ availablePoints: 987,
390
+ cardImageUrl: 'https://assets.example.com/member/gold-card.png',
391
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
392
+ presentation: { variant: 'gold', labelsEmbedded: true },
393
+ progress: {
394
+ daySpentCents: 12_345,
395
+ yearSpentCents: 500_000,
396
+ percent: 25,
397
+ message: '今日已消费123.45元,年累计已消费5000.00元',
398
+ },
399
+ benefits: [
400
+ {
401
+ benefitRef: 'b1',
402
+ name: '免费停车',
403
+ iconUrl: 'https://assets.example.com/member/parking.png',
404
+ description: '可免2小时',
405
+ },
406
+ { benefitRef: 'b2', name: '积分换礼', action: { kind: 'miniapp', url: 'alipays://member/points-mall' } },
407
+ ],
408
+ }}
409
+ cardId="card-dynamic"
410
+ />,
411
+ );
412
+ expect(host.querySelector('.member-card-summary__face--gold')).not.toBeNull();
413
+ expect(host.querySelector('.member-card-summary__face--embedded')).not.toBeNull();
414
+ expect(host.querySelector('.member-card-summary--gold')).not.toBeNull();
415
+ expect(host.textContent).not.toContain('我的积分');
416
+ expect(host.textContent).not.toContain('金卡会员');
417
+ expect(host.textContent).toContain('987');
418
+ expect(host.textContent).toContain('今日已消费123.45元,年累计已消费5000.00元');
419
+ expect(host.textContent).toContain('免费停车');
420
+ expect(host.textContent).toContain('可免2小时');
421
+ expect(host.querySelector('.member-card-summary__benefit-description')).not.toBeNull();
422
+ expect(host.textContent).toContain('积分换礼');
423
+ expect(host.textContent).not.toContain('专属停车');
424
+ expect(host.querySelector('.member-card-summary__benefit-icon-fallback')).not.toBeNull();
425
+ const progress = host.querySelector('[role="progressbar"]') as HTMLElement;
426
+ expect(progress.getAttribute('aria-valuenow')).toBe('25');
427
+ expect((progress.firstElementChild as HTMLElement).style.width).toBe('25%');
428
+ const benefitButton = host.querySelector('.member-card-summary__benefit-button') as HTMLButtonElement;
429
+ expect(benefitButton).not.toBeNull();
430
+ act(() => benefitButton.click());
431
+ expect(mockedSend()).toHaveBeenCalledWith('card-dynamic', {
432
+ type: 'openLink',
433
+ url: 'alipays://member/points-mall',
434
+ });
435
+ expect(document.querySelector('.member-miniapp-dialog')).toBeNull();
436
+ });
437
+
438
+ it('点击卡图内置积分规则区域展示近全屏弹层,支持 Escape 关闭', () => {
439
+ const { host } = render(
440
+ <MemberCardSummaryCard
441
+ data={{
442
+ userId: 'u1',
443
+ cardRef: 'card-rule',
444
+ cardDisplay: '金卡会员',
445
+ status: 'active',
446
+ availablePoints: 987,
447
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
448
+ presentation: { variant: 'gold', labelsEmbedded: true },
449
+ }}
450
+ />,
451
+ );
452
+ const trigger = host.querySelector('.member-card-summary__rule-hotspot') as HTMLButtonElement;
453
+ expect(trigger.getAttribute('aria-label')).toBe('积分规则');
454
+ expect(trigger.textContent).toBe('');
455
+ expect(host.querySelector('.member-card-summary__rule-trigger')).toBeNull();
456
+
457
+ act(() => { trigger.click(); });
458
+ const dialog = document.body.querySelector('.member-points-rule') as HTMLElement;
459
+ expect(trigger.className).toContain('member-card-summary__rule-hotspot');
460
+ expect(dialog.parentElement).toBe(document.body);
461
+ expect(host.contains(dialog)).toBe(false);
462
+ expect(dialog.getAttribute('role')).toBe('dialog');
463
+ expect(dialog.getAttribute('aria-modal')).toBe('true');
464
+ expect((dialog.querySelector('.member-points-rule__image') as HTMLImageElement).src)
465
+ .toBe('https://assets.example.com/member/points-rule.jpg');
466
+
467
+ act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
468
+ expect(dialog.className).toContain('member-points-rule--closing');
469
+ act(() => { dialog.dispatchEvent(new Event('animationend', { bubbles: true })); });
470
+ expect(document.body.querySelector('.member-points-rule')).toBeNull();
471
+ });
472
+
473
+ it('渲染 PRD 三项偏好,点击发送新的可见 query', () => {
474
+ const { host } = render(
475
+ <MemberCardSummaryCard
476
+ data={{
477
+ userId: 'u1',
478
+ cardRef: 'card-preferences',
479
+ cardDisplay: '悦享卡',
480
+ status: 'active',
481
+ availablePoints: 300,
482
+ }}
483
+ cardId="card-1"
484
+ isHistory={false}
485
+ />,
486
+ );
487
+ const buttons = [...host.querySelectorAll('.member-card-summary__preference-list button')] as HTMLButtonElement[];
488
+ expect(host.textContent).toContain('说说你的偏好');
489
+ expect(host.querySelector('.member-card-summary__preference-hint')?.textContent).toBe('请选择你的偏好,或直接告诉我');
490
+ expect(buttons.map((button) => button.textContent)).toEqual(['我常来买衣服', '我常带娃来玩', '我常来吃美食']);
491
+
492
+ act(() => { buttons[0].click(); });
493
+ expect(mockedSend()).toHaveBeenCalledWith('card-1', {
494
+ type: 'sendMessage',
495
+ value: '我常来买衣服',
496
+ displayText: '我常来买衣服',
497
+ visible: true,
498
+ });
499
+ });
500
+
501
+ it('历史会员卡的偏好按钮不可点击', () => {
502
+ const { host } = render(
503
+ <MemberCardSummaryCard
504
+ data={{
505
+ userId: 'u1',
506
+ cardRef: 'card-history',
507
+ cardDisplay: '悦享卡',
508
+ status: 'active',
509
+ availablePoints: 300,
510
+ }}
511
+ cardId="card-history"
512
+ isHistory
513
+ />,
514
+ );
515
+ const buttons = [...host.querySelectorAll('.member-card-summary__preference-list button')] as HTMLButtonElement[];
516
+ expect(buttons.every((button) => button.disabled)).toBe(true);
517
+ });
518
+ });
519
+
520
+ describe('D10 置位守卫(三轮修复:防"声明置位、实现漏置"回归)', () => {
521
+ it('member 三张自持结构卡 default 导出 selfRendersActions 置位', () => {
522
+ expect((MemberEnrollmentFormCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
523
+ expect((MemberEnrollmentStatusCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
524
+ expect((MemberCardSummaryCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
525
+ });
526
+ });