From 5a8ef5f3f5e0c52c8bebc1436699e2881e24c8fc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 09:28:33 +0800 Subject: [PATCH] fix(web): tighten schedule catalog evidence --- apps/web/tests/schedule-after.e2e.ts | 81 +++++++------------ docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.zh.md | 2 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../client/ScheduleCatalogAction.module.css | 1 + .../schedule-catalog-action.client.spec.tsx | 15 ---- 8 files changed, 38 insertions(+), 71 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index ce2c466ea2..7dfe576560 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -66,9 +66,6 @@ const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e') const CATALOG_TITLE = 'Active schedule catalog' const REMINDER_TRIGGER_NAME = /^\d+ reminders?$/ const ACTIVE_SCHEDULE_LABEL = 'Has active scheduled task' -const LARGE_INTERVAL_SECONDS = 200_000_000_001 -const LARGE_INTERVAL_PROMPT = 'Keep every large-interval metadata field visible' -const LARGE_INTERVAL_ID = ScheduleId('catalog-large-interval') const CATALOG_IDS = { after: ScheduleId('catalog-after'), at: ScheduleId('catalog-at'), @@ -725,8 +722,9 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport) expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport) expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') - const longPrompt = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) - .locator(':scope > span').nth(1) + const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) + const cadenceRow = catalog.getByRole('listitem').filter({ hasText: 'Check exact cadence' }) + const longPrompt = longRow.locator(':scope > span').nth(1) const promptLayout = await longPrompt.evaluate(element => ({ height: element.getBoundingClientRect().height, clientWidth: element.clientWidth, @@ -734,6 +732,33 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { })) expect(promptLayout.height).toBeGreaterThan(18) expect(promptLayout.scrollWidth).toBeLessThanOrEqual(promptLayout.clientWidth) + const [longRowLayout, cadenceRowLayout] = await Promise.all([ + longRow.evaluate((element) => { + const box = element.getBoundingClientRect() + return { + bottom: box.bottom, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + childBottoms: [...element.children].map(child => child.getBoundingClientRect().bottom), + } + }), + cadenceRow.evaluate((element) => { + const box = element.getBoundingClientRect() + return { top: box.top, bottom: box.bottom } + }), + ]) + expect(longRowLayout.scrollHeight).toBeLessThanOrEqual(longRowLayout.clientHeight) + expect(longRowLayout.childBottoms).not.toHaveLength(0) + for (const childBottom of longRowLayout.childBottoms) { + expect(childBottom).toBeLessThanOrEqual(longRowLayout.bottom) + } + expect(longRowLayout.bottom).toBeLessThanOrEqual(cadenceRowLayout.top) + expect(cadenceRowLayout.bottom).toBeGreaterThan(cadenceRowLayout.top) + const scrollLayout = await catalog.evaluate(element => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })) + expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor) @@ -745,53 +770,9 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { MODE, ) - parentAgent.session.append('schedule/change', { - version: 1, - operation: 'create', - schedule: createEveryScheduleRecord( - LARGE_INTERVAL_ID, - LARGE_INTERVAL_PROMPT, - LARGE_INTERVAL_SECONDS, - CATALOG_NOW, - ), - }) - await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true) - await page.getByRole('button', { name: '4 reminders' }).waitFor({ timeout: 15_000 }) - const largeRow = catalog.getByRole('listitem').filter({ hasText: LARGE_INTERVAL_PROMPT }) - await largeRow.waitFor({ timeout: 15_000 }) - const metadataLayout = await largeRow.locator(':scope > span').nth(2).evaluate((element) => { - const box = element.getBoundingClientRect() - return { - text: element.textContent, - height: box.height, - left: box.left, - right: box.right, - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - fields: [...element.children].map((child) => { - const field = child.getBoundingClientRect() - return { width: field.width, height: field.height, left: field.left, right: field.right } - }), - } - }) - expect(metadataLayout.text).toContain(`Every ${LARGE_INTERVAL_SECONDS} seconds`) - expect(metadataLayout.height).toBeGreaterThan(16) - expect(metadataLayout.scrollWidth).toBeLessThanOrEqual(metadataLayout.clientWidth) - for (const field of metadataLayout.fields) { - expect(field.width).toBeGreaterThan(0) - expect(field.height).toBeGreaterThan(0) - expect(field.left).toBeGreaterThanOrEqual(metadataLayout.left) - expect(field.right).toBeLessThanOrEqual(metadataLayout.right) - } - const scrollLayout = await catalog.evaluate(element => ({ - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })) - expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) - const sessionRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) expect(await sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - for (const id of [...Object.values(CATALOG_IDS), LARGE_INTERVAL_ID]) { + for (const id of Object.values(CATALOG_IDS)) { parentAgent.session.append('schedule/change', { version: 1, operation: 'delete', id }) } await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ba3f56d510..0448383d07 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md config-catalog.md: b2d8379dd799ab0e836a2ab0572d6af08ababc5d -config-catalog.zh.md: d768b40c54d27aeeb1294d4fee7417f54c11afb0 +config-catalog.zh.md: 6c411df61a200b1491024d0ee42d084176570af7 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d768b40c54..6c411df61a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1933,7 +1933,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:45`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 074ff8514d..14a1f747f0 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md -README.md: 83e9bfd3cd6005636f04090e1b7fa26917a70b84 -README.zh.md: 629c83ecb55989eb0f96660b02a148b9ce1c6ea3 +README.md: 2d75746876daf453b01ff2e84b6e62ed018c205c +README.zh.md: 32925e73aed252189764c4eafc0f5ecc58bb2a08 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 83e9bfd3cd..2d75746876 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -37,7 +37,7 @@ The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` thr Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. -Only the native trigger button enters the tab order. Enter and Space use normal button activation; while the popover is open, Escape closes it and restores trigger focus even after native Tab moves focus to another header action; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists. +Only the native trigger button enters the tab order. Enter and Space use normal button activation; while focus remains on the trigger or catalog, Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists. ----- diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 629c83ecb5..32925e73ae 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -37,7 +37,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml 每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。 -只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;弹层打开期间,即使原生 Tab 已把焦点移到另一个会话头部动作,Escape 仍会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。 +只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;焦点仍在触发器或目录内时,Escape 会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。 ----- diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index 7719bc05b9..3f4d6c5380 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -65,6 +65,7 @@ .row { display: flex; flex-direction: column; + flex-shrink: 0; gap: 3px; box-sizing: border-box; width: 100%; diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 602bf1c47c..c181a5f745 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -163,7 +163,6 @@ describe('ScheduleCatalogAction rows', () => { [7_200, 'Every 2 hours', '2小时一次'], [300, 'Every 5 minutes', '5分钟一次'], [301, 'Every 301 seconds', '301秒一次'], - [200_000_000_001, 'Every 200000000001 seconds', '200000000001秒一次'], ] as const for (const [seconds, english, chinese] of samples) { const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds }) @@ -175,20 +174,6 @@ describe('ScheduleCatalogAction rows', () => { expect(tZh('status.overdue')).toBe('已逾期') }) - it('renders every required metadata value for a valid large recurrence', () => { - const item = record('large', 'every', START + 200_000_000_001_000, { - everySeconds: 200_000_000_001, - }) - const t = makeTranslate(en) - render() - fireEvent.click(screen.getByRole('button')) - - const row = screen.getByRole('listitem') - expect(row.textContent).toContain(formatScheduleFrequency(item, t)) - expect(row.textContent).toContain(formatScheduleLocalTime(item.scheduledAt, 'en')) - expect(row.textContent).toContain(formatScheduleRelative(item.scheduledAt, START, t)) - }) - it('formats absolute time with the active document locale instead of the runtime default', () => { document.documentElement.lang = 'de-DE' const item = record('localized', 'at', START + 3_600_000)