@tbox.cn/app-module-promotion 0.2.0 → 0.9.4

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 (62) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +90 -11
  3. package/dist/chunk-QOTX4XDS.js +1745 -0
  4. package/dist/chunk-S7CVFCML.js +1330 -0
  5. package/dist/client/index.d.ts +21 -3
  6. package/dist/client/index.js +4 -2
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +16 -4
  9. package/dist/promotion-6L7HLRC2.css +2135 -0
  10. package/dist/server/index.d.ts +2481 -41
  11. package/dist/server/index.js +13 -3
  12. package/package.json +28 -15
  13. package/skills/promotion/SKILL.md +137 -0
  14. package/skills/promotion/references/claim-loop.md +18 -0
  15. package/src/client/cards/index.ts +10 -0
  16. package/src/client/cards/promotion-activity-category-list/index.tsx +34 -0
  17. package/src/client/cards/promotion-activity-detail/index.tsx +84 -0
  18. package/src/client/cards/promotion-activity-list/index.tsx +56 -0
  19. package/src/client/cards/promotion-best-deal/index.tsx +18 -0
  20. package/src/client/cards/promotion-entitlement-list/index.tsx +161 -0
  21. package/src/client/cards/promotion-offer-detail/index.tsx +116 -0
  22. package/src/client/cards/promotion-offer-list/index.tsx +283 -0
  23. package/src/client/cards/view.ts +72 -0
  24. package/src/client/components/CouponQrCode.tsx +48 -0
  25. package/src/client/components/CouponSheet.tsx +124 -0
  26. package/src/client/components/ImageWithFallback.tsx +39 -0
  27. package/src/client/index.ts +23 -4
  28. package/src/client/styles/promotion.css +2135 -0
  29. package/src/client/types/css.d.ts +2 -0
  30. package/src/client/utils/sanitize-activity-html.ts +48 -0
  31. package/src/client/utils/scroll-card-bottom.ts +40 -0
  32. package/src/server/actions.ts +217 -0
  33. package/src/server/cards/index.ts +19 -0
  34. package/src/server/cards/promotion-activity-category-list/meta.ts +13 -0
  35. package/src/server/cards/promotion-activity-detail/meta.ts +29 -0
  36. package/src/server/cards/promotion-activity-list/meta.ts +13 -0
  37. package/src/server/cards/promotion-best-deal/meta.ts +20 -0
  38. package/src/server/cards/promotion-entitlement-list/meta.ts +17 -0
  39. package/src/server/cards/promotion-entitlement-list/samples.ts +51 -0
  40. package/src/server/cards/promotion-offer-detail/meta.ts +25 -0
  41. package/src/server/cards/promotion-offer-list/meta.ts +20 -0
  42. package/src/server/cards/promotion-offer-list/samples.ts +154 -0
  43. package/src/server/handler.ts +378 -16
  44. package/src/server/index.ts +117 -22
  45. package/src/server/navigation.ts +23 -0
  46. package/src/server/ports.ts +56 -0
  47. package/src/server/service.ts +243 -24
  48. package/src/server/tools.ts +789 -0
  49. package/tbox.module.json +126 -0
  50. package/tests/cards-render.test.tsx +656 -0
  51. package/tests/ports-resolve.test.ts +96 -0
  52. package/tests/pretool-coupons.test.ts +63 -0
  53. package/tests/promotion.test.ts +976 -0
  54. package/tests/samples.test.ts +42 -0
  55. package/tests/view.test.ts +61 -0
  56. package/tsup.config.ts +9 -1
  57. package/dist/chunk-AD6OLHSM.js +0 -86
  58. package/dist/chunk-LQGF6337.js +0 -32
  59. package/src/client/cards/coupon/index.tsx +0 -12
  60. package/src/server/cards/coupon/meta.ts +0 -23
  61. package/tbox.component.json +0 -20
  62. package/tests/service.test.ts +0 -31
@@ -0,0 +1,656 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { navigateToAlipayPage, sendCardAction } from '@tbox.cn/app-sdk/client';
6
+ import {
7
+ PromotionActivityCategoryListCard,
8
+ PromotionActivityDetailCard,
9
+ PromotionActivityListCard,
10
+ PromotionBestDealCard,
11
+ PromotionEntitlementListCard,
12
+ PromotionOfferDetailCard,
13
+ PromotionOfferListCard,
14
+ } from '../src/client/cards';
15
+ import { sanitizeActivityHtml } from '../src/client/utils/sanitize-activity-html';
16
+
17
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
+
19
+ vi.mock('@tbox.cn/app-sdk/client', async (importOriginal) => {
20
+ const mod = await importOriginal<typeof import('@tbox.cn/app-sdk/client')>();
21
+ return { ...mod, navigateToAlipayPage: vi.fn(() => Promise.resolve()), sendCardAction: vi.fn() };
22
+ });
23
+
24
+ // 浮层 portal 到 body,用例间不清就会互相看见对方的残留
25
+ afterEach(() => {
26
+ document.body.innerHTML = '';
27
+ vi.mocked(sendCardAction).mockReset();
28
+ vi.mocked(navigateToAlipayPage).mockReset();
29
+ vi.mocked(navigateToAlipayPage).mockResolvedValue(undefined);
30
+ });
31
+
32
+ function render(ui: React.ReactElement) {
33
+ const host = document.createElement('div');
34
+ document.body.appendChild(host);
35
+ const root = createRoot(host);
36
+ act(() => root.render(ui));
37
+ return host;
38
+ }
39
+
40
+ const offer = {
41
+ offerRef: 'o1',
42
+ title: '满减会员券',
43
+ subtitle: '商场会员专享',
44
+ offerType: 'COUPON',
45
+ benefitText: '满 100 减 20',
46
+ claimable: true,
47
+ availabilityStatus: 'AVAILABLE',
48
+ validityText: '有效期至 2026-12-31',
49
+ tags: ['会员'],
50
+ itemActions: [{ kind: 'send', label: '查看详情', value: '查看「满减会员券」的优惠详情' }],
51
+ } as const;
52
+
53
+ const detail = {
54
+ offerRef: 'o1',
55
+ title: '满减会员券',
56
+ offerType: 'COUPON',
57
+ subtitle: '会员专享',
58
+ benefitText: '满 100 减 20',
59
+ validityText: '有效期至 2026-12-31',
60
+ rules: ['单笔订单限用一张'],
61
+ steps: [{ title: '领取优惠', description: '进入会员券包' }],
62
+ requirements: ['已登录会员'],
63
+ notice: '资格以厂商校验为准',
64
+ claimable: true,
65
+ caveats: ['库存实时变化'],
66
+ } as const;
67
+
68
+ const activity = {
69
+ activityRef: 'a1',
70
+ title: '周末会员手作',
71
+ subtitle: '限定体验',
72
+ categoryName: '亲子',
73
+ registrationRequired: true,
74
+ remainingQuota: 6,
75
+ periodText: '8月22日 14:00',
76
+ availabilityStatus: 'AVAILABLE',
77
+ itemActions: [{ kind: 'send', label: '查看活动详情', value: '查看「周末会员手作」的活动详情' }],
78
+ } as const;
79
+
80
+ const activityDetail = {
81
+ activityRef: 'demo-c:activity:100006450',
82
+ title: '周末会员手作',
83
+ subtitle: '限定体验',
84
+ categoryName: '亲子',
85
+ periodText: '8月22日 14:00',
86
+ location: '商场北座',
87
+ registrationRequired: true,
88
+ registrationUrl: 'alipays://platformapi/startapp?appId=demo&page=v%2Fmarketing%2Factivity%2Fdetail%3Fid%3Ddemo-activity',
89
+ remainingQuota: 6,
90
+ sessions: [{ sessionRef: 's1', title: '周六下午场', startText: '8月22日 14:00', remainingQuota: 6, status: 'OPEN' }],
91
+ guideSteps: ['验证会员身份'],
92
+ guideItems: [{ title: '验证会员身份', description: '到场出示会员码' }],
93
+ conditions: ['商场会员'],
94
+ notice: '当前仅展示参与说明',
95
+ } as const;
96
+
97
+ describe('legacy 19 个营销展示语义', () => {
98
+ it.each([
99
+ ['offer-list', 'offer', '活动与优惠'],
100
+ ['promotion-list', 'promotion', '本周营销活动'],
101
+ ['coupon-list', 'coupon', '商场神券'],
102
+ ] as const)('%s 恢复列表层级', (_name, presentation, text) => {
103
+ const host = render(<PromotionOfferListCard data={{ mode: 'search', presentation, items: [offer] } as never} cardId="c1" />);
104
+ expect(host.querySelector('.promotion-list__items')).not.toBeNull();
105
+ expect(host.textContent).toContain(text);
106
+ if (presentation === 'promotion') expect(host.querySelector('.promotion-list__hero')).not.toBeNull();
107
+ });
108
+
109
+ it('coupon-list 使用独立豌豆 IP 头图,并与活动中心共用标题排版', () => {
110
+ const host = render(<PromotionOfferListCard data={{ mode: 'claimable', presentation: 'coupon', items: [offer] } as never} cardId="c1" />);
111
+ const header = host.querySelector('.promotion-coupon__header');
112
+ expect(header?.textContent).toContain('商场神券');
113
+ expect(header?.textContent).toContain('当前商场 · 优惠精选');
114
+ expect(header?.classList.contains('promotion-activity__header--pea')).toBe(true);
115
+ // 素材变量链(主题轨 T-2.3):coupon 头图经 CSS background + --tbox-asset-promotion-coupon-header;
116
+ // 资产值断言归构建链(css url 相对引用),渲染层断言 img 不再渲染(防回归)
117
+ expect(header?.querySelector('.promotion-activity__header-art')).toBeNull();
118
+ });
119
+
120
+ it('券列表默认最多展示 4 条,并支持查看更多/收起', () => {
121
+ const items = Array.from({ length: 6 }, (_, index) => ({ ...offer, offerRef: `offer-${index}`, title: `券${index}` }));
122
+ const host = render(<PromotionOfferListCard data={{ mode: 'claimable', presentation: 'coupon', items } as never} cardId="c1" />);
123
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(6);
124
+ const toggle = host.querySelector('.promotion-list__toggle') as HTMLButtonElement;
125
+ expect(toggle.textContent).toContain('查看更多');
126
+ expect(host.querySelector('.promotion-list--offers')).not.toBeNull();
127
+ expect(toggle.firstChild?.nodeType).toBe(Node.TEXT_NODE);
128
+ expect(toggle.lastChild?.nodeName.toLowerCase()).toBe('svg');
129
+ expect(toggle.querySelector('svg')?.getAttribute('data-rotated')).toBeNull();
130
+ expect(toggle.getAttribute('aria-expanded')).toBe('false');
131
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
132
+ const scrollIntoView = vi.fn();
133
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView });
134
+ vi.useFakeTimers();
135
+ try {
136
+ act(() => toggle.click());
137
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(6);
138
+ expect(toggle.textContent).toContain('收起');
139
+ expect(toggle.querySelector('svg')?.getAttribute('data-rotated')).toBe('true');
140
+ expect(toggle.getAttribute('aria-expanded')).toBe('true');
141
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' });
142
+ act(() => toggle.click());
143
+ act(() => vi.advanceTimersByTime(320));
144
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(6);
145
+ expect(toggle.textContent).toContain('查看更多');
146
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
147
+ } finally {
148
+ vi.useRealTimers();
149
+ if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: originalScrollIntoView });
150
+ else delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView;
151
+ }
152
+ });
153
+
154
+ it('recommendation 恢复推荐主视觉、依据和候选', () => {
155
+ const host = render(<PromotionOfferListCard data={{ mode: 'recommend', presentation: 'recommendation', summary: '综合资格和优惠力度', items: [offer, { ...offer, offerRef: 'o2' }] } as never} cardId="c1" />);
156
+ expect(host.querySelector('.promotion-recommendation__main')).not.toBeNull();
157
+ expect(host.querySelector('.promotion-recommendation__reason')?.textContent).toContain('推荐依据');
158
+ expect(host.textContent).toContain('另有 1 个候选优惠');
159
+ });
160
+
161
+ it.each([
162
+ ['offer-detail', 'offer'],
163
+ ['promotion-detail', 'promotion'],
164
+ ['coupon-detail', 'coupon'],
165
+ ] as const)('%s 恢复 hero、玻璃利益点和规则', (_name, presentation) => {
166
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation } as never} />);
167
+ expect(host.querySelector('.promotion-detail__hero')).not.toBeNull();
168
+ expect(host.querySelector('.promotion-detail__glass')?.textContent).toContain('满 100 减 20');
169
+ expect(host.querySelector('.promotion-detail__rules')).not.toBeNull();
170
+ });
171
+
172
+ it('package-detail 恢复礼包子权益', () => {
173
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'package', offerType: 'BUNDLE', bundleItems: [{ name: '停车券', quantity: 2 }] } as never} />);
174
+ expect(host.querySelector('.promotion-detail__bundle')?.textContent).toContain('停车券');
175
+ });
176
+
177
+ it('coupon detail 可选展示适用门店且不影响历史字段', () => {
178
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon', claimable: false, stores: [{ storeId: 's1', storeName: '南京大排档', floor: '1F', address: '北区' }] } as never} />);
179
+ expect(host.querySelector('.promotion-detail__stores')?.textContent).toContain('南京大排档');
180
+ expect(host.querySelector('.promotion-detail__stores')?.textContent).toContain('1F');
181
+ });
182
+
183
+ it('promotion-guide 恢复步骤、要求和参与方式', () => {
184
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'promotion-guide', participationMode: 'CLAIM_THEN_PURCHASE' } as never} />);
185
+ expect(host.querySelector('.promotion-guide__summary')?.textContent).toContain('先领券');
186
+ expect(host.querySelector('.promotion-guide__requirements')).not.toBeNull();
187
+ });
188
+
189
+ it('entitlement-list 恢复钱包、状态、券码和安全提示', () => {
190
+ const host = render(<PromotionEntitlementListCard data={{ items: [{ entitlementRef: 'e1', title: '停车券', entitlementType: 'COUPON', status: 'EFFECTIVE', codeMasked: '****8888', validUntilText: '有效期至 2026-12-31' }] } as never} cardId="c1" />);
191
+ expect(host.querySelector('.promotion-entitlements')?.classList.contains('promotion-list--coupon')).toBe(true);
192
+ expect(host.querySelector('.promotion-list--offers')).toBeNull();
193
+ expect(host.querySelector('.promotion-coupon__header')?.textContent).toContain('我的优惠券');
194
+ expect(host.querySelector('.promotion-coupon__header')?.textContent).toContain('当前账户 · 券包权益');
195
+ expect(host.querySelector('.promotion-activity__header-art')).toBeNull(); // 素材变量链:头图 img 已转 CSS background
196
+ // 券码不再摊在行上:一屏几张券,脱敏码谁也用不上,点开浮层才需要
197
+ expect(host.querySelector('.promotion-coupon-row')?.textContent).not.toContain('****8888');
198
+ expect(host.querySelector('.promotion-coupon-row')?.textContent).not.toContain('2026-12-31');
199
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
200
+ expect(document.querySelector('.promotion-sheet')?.textContent).toContain('****8888');
201
+ expect(document.querySelector('.promotion-sheet')?.textContent).toContain('2026-12-31');
202
+ expect(host.querySelector('.promotion-card__footer')).toBeNull();
203
+ });
204
+
205
+ it('我的优惠券默认最多展示 4 条,maxItems 可配置', () => {
206
+ const items = Array.from({ length: 5 }, (_, index) => ({
207
+ entitlementRef: `e${index}`,
208
+ title: `优惠券${index}`,
209
+ entitlementType: 'COUPON',
210
+ status: 'EFFECTIVE',
211
+ }));
212
+ const host = render(<PromotionEntitlementListCard data={{ items } as never} cardId="c1" />);
213
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(5);
214
+ const toggle = host.querySelector('.promotion-list__toggle') as HTMLButtonElement;
215
+ act(() => toggle.click());
216
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(5);
217
+
218
+ const custom = render(<PromotionEntitlementListCard data={{ items, maxItems: 2 } as never} cardId="c2" />);
219
+ expect(custom.querySelectorAll('.promotion-coupon-row')).toHaveLength(5);
220
+ });
221
+
222
+ it('best-deal 恢复预计实付 hero 和三项指标', () => {
223
+ const host = render(<PromotionBestDealCard data={{ authoritative: true, originalAmountCents: 20000, savingsCents: 3000, expectedPayCents: 17000, appliedOfferRefs: ['o1'] }} />);
224
+ expect(host.querySelector('.promotion-deal__hero')?.textContent).toContain('170.00');
225
+ expect(host.querySelectorAll('.promotion-deal__metrics > span')).toHaveLength(3);
226
+ });
227
+
228
+ it('activity-category-list 恢复双列图标入口', () => {
229
+ const host = render(<PromotionActivityCategoryListCard data={{ items: [{ categoryId: 'family', name: '亲子' }] }} />);
230
+ expect(host.querySelector('.promotion-activity__categories')?.textContent).toContain('发现精彩活动');
231
+ expect(host.querySelector('.promotion-activity__category-icon')).not.toBeNull();
232
+ });
233
+
234
+ it('activity-list 使用稳定活动中心头图,并保留业务封面、状态、日期和余量', () => {
235
+ const host = render(<PromotionActivityListCard data={{ items: [{ ...activity, coverUrl: 'https://example.com/activity.png' }] } as never} cardId="c1" />);
236
+ expect(host.querySelector('.promotion-activity__header')?.textContent).toContain('活动中心');
237
+ expect(host.querySelector('.promotion-activity__header')?.textContent).toContain('发现商场近期精彩活动');
238
+ // 素材变量链(主题轨 T-2.3):活动中心头图经 CSS background + --tbox-asset-promotion-activity-header;
239
+ // 业务封面仍只在条目缩略图(下方 thumb 断言),头图 img 不再渲染
240
+ expect(host.querySelector('.promotion-activity__header-art')).toBeNull();
241
+ expect(host.querySelector('.promotion-activity__hero')).toBeNull();
242
+ expect(host.textContent).not.toContain('MEMBER PICKS');
243
+ expect(host.textContent).not.toContain('本周会员精选');
244
+ expect(host.querySelector('.promotion-activity__list')?.textContent).toContain('仅余 6 席');
245
+ const thumb = host.querySelector('.promotion-activity__thumb');
246
+ expect(thumb?.getAttribute('src')).toBe('https://example.com/activity.png');
247
+ expect(thumb?.getAttribute('loading')).toBe('eager');
248
+ const row = host.querySelector('.promotion-activity__list > button') as HTMLButtonElement;
249
+ expect(row).not.toBeNull();
250
+ expect(host.querySelector('.promotion-activity__list .promotion-chip')).toBeNull();
251
+ act(() => row.click());
252
+ expect(vi.mocked(sendCardAction)).toHaveBeenCalledWith('c1', {
253
+ type: 'sendMessage',
254
+ value: activity.itemActions[0].value,
255
+ visible: true,
256
+ });
257
+ });
258
+
259
+ it('activity-list 空态仍展示同一活动中心标题', () => {
260
+ const host = render(<PromotionActivityListCard data={{ items: [], noResultsReason: '近期暂无活动' }} />);
261
+ expect(host.querySelector('.promotion-activity__header')?.textContent).toContain('活动中心');
262
+ expect(host.querySelector('.promotion-activity__header--pea')).not.toBeNull(); // 空态仍保留品牌头容器(素材经 CSS background)
263
+ expect(host.querySelector('.promotion-card__empty')?.textContent).toContain('近期暂无活动');
264
+ });
265
+
266
+ it('activity-detail 恢复封面、时间名额、地点和会员礼遇', () => {
267
+ const host = render(<PromotionActivityDetailCard data={{ ...activityDetail, presentation: 'detail' } as never} cardId="activity-card" />);
268
+ expect(host.querySelector('.promotion-activity__cover-meta')?.textContent).toContain('会员专享体验');
269
+ expect(host.querySelector('.promotion-activity__spotlight')).not.toBeNull();
270
+ expect(host.querySelector('.promotion-activity__venue')?.textContent).toContain('商场北座');
271
+ expect(host.querySelector('.promotion-activity__detail-notice')?.textContent).toContain('当前仅展示参与说明');
272
+ const register = host.querySelector('.promotion-activity__register') as HTMLButtonElement;
273
+ expect(register.textContent).toContain('去支付宝报名');
274
+ act(() => register.click());
275
+ expect(vi.mocked(sendCardAction)).toHaveBeenCalledWith('activity-card', {
276
+ type: 'openLink',
277
+ url: activityDetail.registrationUrl,
278
+ });
279
+ });
280
+
281
+ it('activity-schedule 恢复场次编号和实时名额', () => {
282
+ const host = render(<PromotionActivityDetailCard data={{ ...activityDetail, presentation: 'schedule' } as never} />);
283
+ expect(host.querySelector('.promotion-activity__session-summary')?.textContent).toContain('实时名额');
284
+ expect(host.querySelector('.promotion-activity__sessions')?.textContent).toContain('01');
285
+ });
286
+
287
+ it('activity-guide 恢复 HOW TO JOIN、步骤和参与条件', () => {
288
+ const host = render(<PromotionActivityDetailCard data={{ ...activityDetail, presentation: 'guide' } as never} />);
289
+ expect(host.querySelector('.promotion-activity__guide-banner')?.textContent).toContain('HOW TO JOIN');
290
+ expect(host.querySelector('.promotion-activity__requirements')?.textContent).toContain('商场会员');
291
+ });
292
+ });
293
+
294
+ describe('优惠券链路视觉状态语义', () => {
295
+ it('可领券列表:只列可领的券,商户图与售价行齐备', () => {
296
+ const host = render(<PromotionOfferListCard data={{
297
+ mode: 'claimable',
298
+ presentation: 'coupon',
299
+ summary: '当前商场 · 优惠精选',
300
+ items: [
301
+ { ...offer, subtitle: '2026-09-01 00:00:00 至 2026-09-15 23:59:59', imageUrl: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>', benefitText: '¥30', tags: ['TOP1', '餐饮', '周末可用'] },
302
+ { ...offer, offerRef: 'o2', title: '超长商户名称与优惠说明用于验证窄屏折行稳定性', imageUrl: undefined, claimable: false, availabilityStatus: 'UNAVAILABLE' },
303
+ ],
304
+ } as never} cardId="coupon-list" />);
305
+
306
+ // 领不了的券不进列表:列出来用户点一次才知道点不动
307
+ expect(host.querySelectorAll('.promotion-list__items > article')).toHaveLength(1);
308
+ const row = host.querySelector('.promotion-coupon-row') as HTMLElement;
309
+ expect(row.getAttribute('role')).toBe('button');
310
+ expect(row.querySelector('.promotion-item-actions--coupon > .promotion-chip--coupon')?.textContent).toBe('领取');
311
+ expect(row.querySelector('button')).toBeNull();
312
+ expect(row.querySelector('img')?.getAttribute('alt')).toBe('满减会员券');
313
+ expect(host.textContent).toContain('¥30');
314
+ expect(host.querySelector('.promotion-coupon-row__tags')?.textContent).not.toContain('TOP1');
315
+ expect(host.querySelector('.promotion-coupon-row__tags')?.textContent).toContain('餐饮');
316
+ expect(row.querySelector('.promotion-coupon-row__sub')).toBeNull();
317
+ expect(row.textContent).not.toContain('2026-09-01');
318
+ expect(host.textContent).not.toContain('超长商户名称');
319
+ // 参考设计里没有「可领取/不可领」文字标签,也没有类目角标
320
+ expect(host.textContent).not.toContain('不可领');
321
+ expect(host.querySelector('.promotion-card__footer')).toBeNull();
322
+ act(() => row.click());
323
+ expect(document.body.textContent).toContain('有效期至 2026-12-31');
324
+ });
325
+
326
+ it('可领券列表保留非时间型的辅助说明', () => {
327
+ const host = render(<PromotionOfferListCard data={{
328
+ mode: 'claimable',
329
+ presentation: 'coupon',
330
+ items: [{ ...offer, subtitle: '商场会员专享' }],
331
+ } as never} cardId="coupon-list" />);
332
+ expect(host.querySelector('.promotion-coupon-row__sub')?.textContent).toBe('商场会员专享');
333
+ });
334
+
335
+ it('商场券和我的券缺图时共用居中的缩略图兜底', () => {
336
+ const offerHost = render(<PromotionOfferListCard data={{
337
+ mode: 'claimable',
338
+ presentation: 'coupon',
339
+ items: [{ ...offer, imageUrl: undefined }],
340
+ } as never} cardId="coupon-list" />);
341
+ const entitlementHost = render(<PromotionEntitlementListCard data={{
342
+ items: [{ entitlementRef: 'e1', title: '缺图优惠券', entitlementType: 'COUPON', status: 'EFFECTIVE' }],
343
+ } as never} cardId="entitlements" />);
344
+
345
+ for (const host of [offerHost, entitlementHost]) {
346
+ const fallback = host.querySelector('.promotion-coupon-row__thumb.promotion-image--fallback');
347
+ expect(fallback).not.toBeNull();
348
+ expect(fallback?.querySelector('svg')).not.toBeNull();
349
+ }
350
+ });
351
+
352
+ it('券详情只在可领取且有动作令牌时提交权威领取动作', () => {
353
+ vi.mocked(sendCardAction).mockClear();
354
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon' } as never} cardId="coupon-detail" actionTokens={{ 'promotion.acquire': 'token' }} />);
355
+ const claim = [...host.querySelectorAll('button')].find((button) => button.textContent === '立即领取') as HTMLButtonElement;
356
+ expect(claim).not.toBeUndefined();
357
+ act(() => claim.click());
358
+ expect(vi.mocked(sendCardAction)).toHaveBeenCalledWith(
359
+ 'coupon-detail',
360
+ { type: 'moduleAction', actionId: 'promotion.acquire', contextToken: 'token' },
361
+ { offerRef: 'o1' },
362
+ );
363
+ expect(claim.disabled).toBe(true);
364
+ expect(claim.textContent).toBe('领取中…');
365
+ act(() => window.dispatchEvent(new CustomEvent('tbox:notice', {
366
+ detail: { level: 'info', text: '领取成功!已存入卡包', surfaceId: 'coupon-detail' },
367
+ })));
368
+ expect(claim.disabled).toBe(true);
369
+ expect(claim.textContent).toBe('已领取');
370
+ expect(host.textContent).toContain('领取成功!已存入卡包');
371
+
372
+ // 详情卡只在当前组件生命周期内保留已领取;历史卡重新挂载后恢复领取按钮
373
+ const remounted = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon' } as never} cardId="coupon-detail" actionTokens={{ 'promotion.acquire': 'token' }} />);
374
+ expect([...remounted.querySelectorAll('button')].some((button) => button.textContent === '立即领取')).toBe(true);
375
+
376
+ const missingToken = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon' } as never} cardId="coupon-detail-without-token" />);
377
+ expect([...missingToken.querySelectorAll('button')].some((button) => button.textContent === '立即领取')).toBe(false);
378
+
379
+ // 不可领时不渲染写动作,哪怕令牌在手
380
+ const unavailable = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon', claimable: false } as never} cardId="unavailable-detail" actionTokens={{ 'promotion.acquire': 'token' }} />);
381
+ expect([...unavailable.querySelectorAll('button')].some((button) => button.textContent === '立即领取')).toBe(false);
382
+ });
383
+
384
+ it('付费券详情通过 openLink 唤起购买页,不签发领取动作', () => {
385
+ vi.mocked(sendCardAction).mockClear();
386
+ const purchaseUrl = 'alipays://platformapi/startapp?appId=demo&page=pages%2Fcoupon%2Fdetail%3Fid%3Dpaid-2';
387
+ const host = render(<PromotionOfferDetailCard data={{ ...detail, presentation: 'coupon', claimActionLabel: '抢购', purchaseUrl } as never} cardId="paid-detail" actionTokens={{ 'promotion.acquire': 'token' }} />);
388
+ const purchase = [...host.querySelectorAll('button')].find((button) => button.textContent === '去小程序抢购') as HTMLButtonElement;
389
+ act(() => purchase.click());
390
+ expect(vi.mocked(navigateToAlipayPage)).toHaveBeenCalledWith(purchaseUrl);
391
+ });
392
+
393
+ it('历史券列表可回看浮层,但领取动作禁用', () => {
394
+ const host = render(<PromotionOfferListCard data={{ mode: 'claimable', presentation: 'coupon', items: [offer] } as never} cardId="history-list" isHistory />);
395
+ const row = host.querySelector('.promotion-coupon-row') as HTMLElement;
396
+ act(() => row.click());
397
+ // 浮层 portal 到 body:历史卡无令牌,退回 ItemActionsRow 且整体禁用
398
+ const claim = [...document.querySelectorAll('.promotion-sheet button')].find((b) => b.textContent === '查看详情') as HTMLButtonElement;
399
+ expect(claim.disabled).toBe(true);
400
+
401
+ const sheet = document.querySelector('.promotion-sheet') as HTMLElement;
402
+ const close = sheet.querySelector('[aria-label="关闭详情"]') as HTMLButtonElement;
403
+ act(() => close.click());
404
+ expect(sheet.classList.contains('promotion-sheet--closing')).toBe(true);
405
+ expect(document.querySelector('.promotion-sheet')).not.toBeNull();
406
+ act(() => sheet.dispatchEvent(new Event('animationend', { bubbles: true })));
407
+ expect(document.querySelector('.promotion-sheet')).toBeNull();
408
+ });
409
+
410
+ it('券列表浮层的领取按钮直接触发写动作,不绕发消息', () => {
411
+ const host = render(
412
+ <PromotionOfferListCard
413
+ data={{ mode: 'claimable', presentation: 'coupon', items: [{ ...offer, claimActionLabel: '领取', freeCouponClaimMode: 'DIRECT' }] } as never}
414
+ cardId="list-1"
415
+ actionTokens={{ 'promotion.acquire-from-list': 'list-token' }}
416
+ />,
417
+ );
418
+ // 行上的「抢购」只是浮层入口:整行才是可交互控件,行内不该再有按钮
419
+ expect(host.querySelectorAll('.promotion-coupon-row button').length).toBe(0);
420
+ const row = host.querySelector('.promotion-coupon-row') as HTMLElement;
421
+ act(() => row.click());
422
+ const claim = [...document.querySelectorAll('.promotion-sheet button')].find((b) => b.textContent === '领取') as HTMLButtonElement;
423
+ act(() => claim.click());
424
+ expect(vi.mocked(sendCardAction)).toHaveBeenCalledWith(
425
+ 'list-1',
426
+ { type: 'moduleAction', actionId: 'promotion.acquire-from-list', contextToken: 'list-token' },
427
+ { offerRef: 'o1' },
428
+ );
429
+ // 动作发出后保留领取中态,服务端成功后会另行推送我的券包卡
430
+ expect(document.querySelector('.promotion-sheet')).not.toBeNull();
431
+ expect(claim.disabled).toBe(true);
432
+ expect(claim.textContent).toContain('领取中');
433
+ act(() => window.dispatchEvent(new CustomEvent('tbox:notice', {
434
+ detail: { level: 'error', text: '领取失败,请稍后重试', surfaceId: 'list-1' },
435
+ })));
436
+ expect(claim.disabled).toBe(false);
437
+ expect(claim.textContent).toContain('领取');
438
+ act(() => claim.click());
439
+ act(() => window.dispatchEvent(new CustomEvent('tbox:notice', {
440
+ detail: { level: 'info', text: '领取成功!已存入卡包', surfaceId: 'list-1' },
441
+ })));
442
+ const claimed = [...document.querySelectorAll('.promotion-sheet button')].find((b) => b.textContent === '已领取') as HTMLButtonElement;
443
+ expect(claimed).not.toBeUndefined();
444
+ expect(claimed.disabled).toBe(true);
445
+
446
+ const sheet = document.querySelector('.promotion-sheet') as HTMLElement;
447
+ const close = sheet.querySelector('[aria-label="关闭详情"]') as HTMLButtonElement;
448
+ act(() => close.click());
449
+ act(() => sheet.dispatchEvent(new Event('animationend', { bubbles: true })));
450
+ expect(document.querySelector('.promotion-sheet')).toBeNull();
451
+
452
+ act(() => row.click());
453
+ const reopened = [...document.querySelectorAll('.promotion-sheet button')].find((b) => b.textContent === '领取') as HTMLButtonElement;
454
+ expect(reopened).not.toBeUndefined();
455
+ expect(reopened.disabled).toBe(false);
456
+ });
457
+
458
+ it('免费券 redirect 仍由列表行打开详情,再复用抢购 openLink 动作;历史卡保持可点击', () => {
459
+ const purchaseUrl = 'alipays://platformapi/startapp?appId=demo&page=pages%2Fcoupon%2Fdetail%2Findex%3FcouponNo%3Dfree-1%26plazaId%3Dmall-1';
460
+ const host = render(<PromotionOfferListCard data={{
461
+ mode: 'claimable',
462
+ presentation: 'coupon',
463
+ items: [{ ...offer, freeCouponClaimMode: 'REDIRECT', purchaseUrl }],
464
+ } as never} cardId="redirect-list" isHistory actionTokens={{ 'promotion.acquire-from-list': 'must-not-use' }} />);
465
+ expect(host.querySelectorAll('.promotion-coupon-row button')).toHaveLength(0);
466
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
467
+ expect(sendCardAction).not.toHaveBeenCalled();
468
+ const claim = [...document.querySelectorAll<HTMLButtonElement>('.promotion-sheet button')]
469
+ .find((button) => button.textContent === '去小程序领取') as HTMLButtonElement;
470
+ expect(claim.disabled).toBe(false);
471
+ act(() => claim.click());
472
+ expect(vi.mocked(navigateToAlipayPage)).toHaveBeenCalledTimes(1);
473
+ expect(vi.mocked(navigateToAlipayPage)).toHaveBeenCalledWith(purchaseUrl);
474
+ });
475
+
476
+ it('免费券 redirect 缺跳链时禁用,且不回退既有写动作', () => {
477
+ const host = render(<PromotionOfferListCard data={{
478
+ mode: 'claimable',
479
+ presentation: 'coupon',
480
+ items: [{ ...offer, freeCouponClaimMode: 'REDIRECT' }],
481
+ } as never} cardId="redirect-missing" actionTokens={{ 'promotion.acquire-from-list': 'must-not-use' }} />);
482
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
483
+ const claim = [...document.querySelectorAll<HTMLButtonElement>('.promotion-sheet button')]
484
+ .find((button) => button.textContent === '领取链接暂不可用') as HTMLButtonElement;
485
+ expect(claim.disabled).toBe(true);
486
+ act(() => claim.click());
487
+ expect(sendCardAction).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it('付费券在浮层内通过 openLink 唤起小程序购买页', () => {
491
+ vi.mocked(sendCardAction).mockClear();
492
+ const purchaseUrl = 'alipays://platformapi/startapp?appId=demo&page=pages%2Fcoupon%2Fdetail%3Fid%3Dpaid-1';
493
+ const host = render(<PromotionOfferListCard data={{
494
+ mode: 'claimable',
495
+ presentation: 'coupon',
496
+ items: [{ ...offer, claimActionLabel: '抢购', purchaseUrl, freeCouponClaimMode: 'DIRECT' }],
497
+ } as never} cardId="paid-list" isHistory />);
498
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
499
+ const purchase = [...document.querySelectorAll('.promotion-sheet button')].find((b) => b.textContent === '去小程序抢购') as HTMLButtonElement;
500
+ expect(purchase).not.toBeUndefined();
501
+ expect(purchase.disabled).toBe(false);
502
+ act(() => purchase.click());
503
+ expect(vi.mocked(navigateToAlipayPage)).toHaveBeenCalledWith(purchaseUrl);
504
+ });
505
+
506
+ it('我的券稳定区分待使用、已使用、已过期,并保留脱敏券码', () => {
507
+ const host = render(<PromotionEntitlementListCard data={{ items: [
508
+ { entitlementRef: 'e1', title: '饮品券', entitlementType: 'COUPON', status: 'EFFECTIVE', codeMasked: '****6688', benefitText: '¥30' },
509
+ { entitlementRef: 'e2', title: '零售券', entitlementType: 'COUPON', status: 'USED', validUntilText: '已使用' },
510
+ { entitlementRef: 'e3', title: '餐饮券', entitlementType: 'COUPON', status: 'EXPIRED', validUntilText: '已过期' },
511
+ ] } as never} cardId="entitlements" />);
512
+
513
+ expect(host.querySelectorAll('.promotion-coupon-row')).toHaveLength(3);
514
+ expect(host.textContent).toContain('去使用');
515
+ expect(host.textContent).toContain('已使用');
516
+ expect(host.textContent).toContain('已过期');
517
+ const usableAction = host.querySelector('.promotion-owned-action:not(.promotion-chip--coupon-off)');
518
+ expect(usableAction?.textContent).toBe('去使用');
519
+ expect(usableAction?.classList.contains('promotion-chip')).toBe(true);
520
+ // 不可再用的券右侧退成中性灰,不与「去使用」同色
521
+ expect(host.querySelectorAll('.promotion-owned-action.promotion-chip--coupon-off')).toHaveLength(2);
522
+ expect(host.querySelectorAll('.promotion-coupon-row__state--used, .promotion-coupon-row__state--expired')).toHaveLength(2);
523
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
524
+ expect(document.querySelector('.promotion-sheet')?.textContent).toContain('****6688');
525
+ });
526
+
527
+ it('我的券详情展示券码并支持复制', async () => {
528
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } });
529
+ const host = render(<PromotionEntitlementListCard data={{ items: [{ entitlementRef: 'e-copy', title: '券', entitlementType: 'COUPON', status: 'EFFECTIVE', code: 'ABC-1234', stores: [{ storeId: 's1', storeName: '商户门店', imageUrl: 'https://cdn.example/store.png' }] }] } as never} />);
530
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
531
+ expect(document.querySelector('.promotion-sheet')?.textContent).toContain('ABC-1234');
532
+ expect(document.querySelector('.promotion-sheet__store')?.textContent).toContain('商户门店');
533
+ const copy = document.querySelector('.promotion-sheet__copy') as HTMLButtonElement;
534
+ await act(async () => { copy.click(); });
535
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABC-1234');
536
+ });
537
+
538
+ it('我的券详情按原生结构展示须知保障、使用字段和适用商家', () => {
539
+ const host = render(<PromotionEntitlementListCard data={{ items: [{
540
+ entitlementRef: 'e-native-detail',
541
+ title: 'Coolhut满300-50',
542
+ entitlementType: 'COUPON',
543
+ status: 'EFFECTIVE',
544
+ code: '279353609701670310',
545
+ usageNotice: '周一至周日可用',
546
+ guaranteeText: '随时退·过期自动退',
547
+ usageSceneText: '小程序扫码买单',
548
+ validityText: '有效期 2026-08-29 15:50:16 至 2026-08-31 23:59:59',
549
+ usageTimeText: '00:00:00 至 23:59:59',
550
+ thresholdText: '满300元可用',
551
+ claimTimeText: '2026-08-29 15:50:16 至 2026-08-31 23:59:59',
552
+ usageRuleText: '<p>不可与其他优惠叠加使用</p>',
553
+ stores: [{ storeId: 's1', storeName: 'Coolhut(杭州拱墅万达店)', floor: '1F-1012B', address: '杭行路666号1F' }], // guard-vendor-neutrality: allow
554
+ }] } as never} />);
555
+
556
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
557
+ const sheet = document.querySelector('.promotion-sheet') as HTMLElement;
558
+ expect(sheet.querySelector('.promotion-sheet__notice-strip')?.textContent).toContain('须知周一至周日可用');
559
+ expect(sheet.querySelector('.promotion-sheet__notice-strip')?.textContent).toContain('保障随时退·过期自动退');
560
+ expect([...sheet.querySelectorAll('.promotion-sheet__facts dt')].map((node) => node.textContent)).toEqual([
561
+ '使用场景', '有效期', '使用时间', '使用限制', '可领取时间', '使用规则',
562
+ ]);
563
+ expect(sheet.querySelector('.promotion-sheet__facts')?.textContent).toContain('小程序扫码买单');
564
+ expect(sheet.querySelector('.promotion-sheet__facts')?.textContent).toContain('满300元可用');
565
+ expect(sheet.querySelector('.promotion-sheet__stores')?.textContent).toContain('Coolhut(杭州拱墅万达店)'); // guard-vendor-neutrality: allow
566
+ expect(sheet.querySelector('.promotion-sheet__stores')?.textContent).toContain('1F-1012B');
567
+ });
568
+
569
+ });
570
+
571
+ describe('usageNotice 富文本安全渲染', () => {
572
+ it('券行富文本按 HTML 渲染换行,脚本面被剥离', () => {
573
+ const host = render(<PromotionEntitlementListCard data={{ items: [{
574
+ entitlementRef: 'e-notice',
575
+ title: '满减券',
576
+ entitlementType: 'COUPON',
577
+ status: 'EFFECTIVE',
578
+ usageNotice: '满100减20<br>全场通用<script>alert(1)</script>',
579
+ }] } as never} cardId="c1" />);
580
+ const sub = host.querySelector('.promotion-coupon-row__sub') as HTMLElement;
581
+ // <br> 以标签形态进 DOM(换行生效),而不是字面文本
582
+ expect(sub.innerHTML).toContain('<br>');
583
+ expect(sub.textContent).not.toContain('<br>');
584
+ expect(sub.innerHTML).not.toContain('<script');
585
+ expect(sub.textContent).not.toContain('alert');
586
+ });
587
+
588
+ it('券行富文本中的事件属性被剥离', () => {
589
+ const host = render(<PromotionEntitlementListCard data={{ items: [{
590
+ entitlementRef: 'e-img',
591
+ title: '饮品券',
592
+ entitlementType: 'COUPON',
593
+ status: 'EFFECTIVE',
594
+ usageNotice: '到店点单可用<img src="x" onerror="alert(1)">',
595
+ }] } as never} cardId="c1" />);
596
+ const sub = host.querySelector('.promotion-coupon-row__sub') as HTMLElement;
597
+ expect(sub.innerHTML).not.toContain('onerror');
598
+ expect(sub.querySelector('img')).not.toBeNull();
599
+ });
600
+
601
+ it('浮层「使用规则」同样净化:排版保留、script 剥离', () => {
602
+ const host = render(<PromotionEntitlementListCard data={{ items: [{
603
+ entitlementRef: 'e-sheet',
604
+ title: '餐饮券',
605
+ entitlementType: 'COUPON',
606
+ status: 'EFFECTIVE',
607
+ usageRuleText: '<b>周一至周日</b>可用<script>alert(1)</script>',
608
+ }] } as never} cardId="c1" />);
609
+ act(() => (host.querySelector('.promotion-coupon-row') as HTMLElement).click());
610
+ const dd = document.querySelector('.promotion-sheet__facts dd') as HTMLElement;
611
+ expect(dd.innerHTML).toContain('<b>周一至周日</b>');
612
+ expect(dd.innerHTML).not.toContain('<script');
613
+ expect(dd.textContent).not.toContain('alert');
614
+ });
615
+ });
616
+
617
+ describe('sanitizeActivityHtml(活动规则富文本过滤)', () => {
618
+ it('剥离 script/iframe/style/svg 与事件属性,保留正文文本', () => {
619
+ const out = sanitizeActivityHtml(
620
+ '<p onclick="evil()">文案</p><script>alert(1)</script><iframe src="https://x"></iframe><style>body{}</style><svg onload="alert(1)"><circle/></svg>',
621
+ );
622
+ expect(out).toContain('文案');
623
+ expect(out).not.toContain('<script');
624
+ expect(out).not.toContain('<iframe');
625
+ expect(out).not.toContain('<style');
626
+ expect(out).not.toContain('<svg');
627
+ expect(out).not.toContain('onclick');
628
+ });
629
+
630
+ it('剥离 javascript: 协议与非 image data: 协议链接,保留正常 href', () => {
631
+ const out = sanitizeActivityHtml(
632
+ '<a href="javascript:evil()">a</a><a href="data:text/html;base64,x">b</a><a href="https://ok.example">c</a><img src="data:image/png;base64,x" alt="" />',
633
+ );
634
+ expect(out).not.toContain('javascript:');
635
+ expect(out).not.toContain('data:text/html');
636
+ expect(out).toContain('https://ok.example');
637
+ expect(out).toContain('data:image/png');
638
+ });
639
+
640
+ it('HTML entity 混淆的 javascript: 协议被剥离(DOMParser 先解码)', () => {
641
+ expect(sanitizeActivityHtml('<a href="&#106;avascript:alert(1)">x</a>')).not.toContain('javascript:');
642
+ });
643
+
644
+ it('formaction / xlink:href / srcdoc 等次要 URL 载体同样剥离', () => {
645
+ expect(sanitizeActivityHtml('<button formaction="javascript:alert(1)">x</button>')).not.toContain('formaction');
646
+ expect(sanitizeActivityHtml('<iframe srcdoc="<script>alert(1)</script>"></iframe>')).not.toContain('srcdoc');
647
+ // svg 整体丢弃 → xlink 载体不存在
648
+ expect(sanitizeActivityHtml('<svg><a xlink:href="javascript:alert(1)"><text>x</text></a></svg>')).not.toContain('svg');
649
+ });
650
+
651
+ it('保留富文本排版(加粗/换行/内联样式)', () => {
652
+ const out = sanitizeActivityHtml('<p><b>满减</b><br/><span style="color:#E84749">2</span>元</p>');
653
+ expect(out).toContain('<b>满减</b>');
654
+ expect(out).toContain('color:#E84749');
655
+ });
656
+ });