diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index 43e1e569f..d50871751 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Das Lesen des Quelltexts legt den Skriptinhalt offen. Bestätige, dass du dieser Anfrage vertraust.", "decision_reject": "Ablehnen", "decision_session_allow": "Für diese Sitzung erlauben", + "decision_update_session_allow": "Aktualisieren und für diese Sitzung erlauben", "decision_allow": "Erlauben", "err_operation_expired": "Diese Anfrage ist abgelaufen oder nicht mehr ausstehend.", "approve_install_banner": "Über „Externer Zugriff“ wurde die Installation dieses Skripts angefragt – deine Entscheidung ist erforderlich.", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index e8751aa31..b61350483 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Reading source exposes the script’s content. Confirm you trust this request.", "decision_reject": "Reject", "decision_session_allow": "Allow this session", + "decision_update_session_allow": "Update and allow this session", "decision_allow": "Allow", "err_operation_expired": "This request has expired or is no longer pending.", "approve_install_banner": "Requested to install this script via External Access — your decision is required.", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index e98c0b00a..08af92c23 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "ソースを読み取るとスクリプトの内容が公開されます。この要求を信頼できるか確認してください。", "decision_reject": "拒否", "decision_session_allow": "このセッションで許可", + "decision_update_session_allow": "更新してこのセッションで許可", "decision_allow": "許可", "err_operation_expired": "この要求は期限切れか、保留中ではありません。", "approve_install_banner": "外部アクセスからこのスクリプトのインストールが要求されました。判断が必要です。", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index 64d95097a..531adb908 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "소스를 읽으면 스크립트 내용이 노출됩니다. 이 요청을 신뢰하는지 확인하세요.", "decision_reject": "거부", "decision_session_allow": "이 세션에서 허용", + "decision_update_session_allow": "업데이트하고 이 세션에서 허용", "decision_allow": "허용", "err_operation_expired": "이 요청이 만료되었거나 더 이상 대기 중이 아닙니다.", "approve_install_banner": "외부 접근에서 이 스크립트 설치를 요청했습니다. 결정이 필요합니다.", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index c4eefc547..575d40a2c 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Ler o código-fonte expõe o conteúdo do script. Confirme que você confia nesta solicitação.", "decision_reject": "Rejeitar", "decision_session_allow": "Permitir nesta sessão", + "decision_update_session_allow": "Atualizar e permitir nesta sessão", "decision_allow": "Permitir", "err_operation_expired": "Esta solicitação expirou ou não está mais pendente.", "approve_install_banner": "Instalação deste script solicitada via «Acesso externo» — sua decisão é necessária.", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index f9c45fe1c..972ece9ee 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Чтение исходного кода раскрывает содержимое скрипта. Подтвердите, что доверяете этому запросу.", "decision_reject": "Отклонить", "decision_session_allow": "Разрешить в этом сеансе", + "decision_update_session_allow": "Обновить и разрешить в этом сеансе", "decision_allow": "Разрешить", "err_operation_expired": "Этот запрос истёк или больше не ожидает обработки.", "approve_install_banner": "Через «Внешний доступ» запрошена установка этого скрипта — требуется ваше решение.", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index d7bb436bf..9026fce72 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Kaynağı okumak betiğin içeriğini açığa çıkarır. Bu isteğe güvendiğinizi onaylayın.", "decision_reject": "Reddet", "decision_session_allow": "Bu oturumda izin ver", + "decision_update_session_allow": "Güncelle ve bu oturumda izin ver", "decision_allow": "İzin ver", "err_operation_expired": "Bu istek süresi doldu veya artık beklemede değil.", "approve_install_banner": "«Harici Erişim» üzerinden bu betiğin kurulması istendi — kararınız gerekiyor.", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index 20b2ac195..fa650861b 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Đọc mã nguồn sẽ tiết lộ nội dung tập lệnh. Hãy xác nhận rằng bạn tin tưởng yêu cầu này.", "decision_reject": "Từ chối", "decision_session_allow": "Cho phép phiên này", + "decision_update_session_allow": "Cập nhật và cho phép phiên này", "decision_allow": "Cho phép", "err_operation_expired": "Yêu cầu này đã hết hạn hoặc không còn đang chờ xử lý.", "approve_install_banner": "Yêu cầu cài đặt tập lệnh này qua «Truy cập bên ngoài» — cần bạn quyết định.", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index b1e291cbd..b9a5a58a4 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "读取源码会暴露脚本内容,请确认你信任此请求。", "decision_reject": "拒绝", "decision_session_allow": "本会话允许", + "decision_update_session_allow": "更新并本会话允许", "decision_allow": "允许", "err_operation_expired": "该请求已过期或不再处于待处理状态。", "approve_install_banner": "通过「外部接入」请求安装此脚本 —— 需你决定。", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index f04c91fe0..9646ce778 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "讀取原始碼會暴露腳本內容,請確認你信任此請求。", "decision_reject": "拒絕", "decision_session_allow": "本工作階段允許", + "decision_update_session_allow": "更新並於本工作階段允許", "decision_allow": "允許", "err_operation_expired": "此請求已逾期或不再處於待處理狀態。", "approve_install_banner": "透過「外部接入」請求安裝此腳本 —— 需你決定。", diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index 1ae789f3b..70881ef07 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, type Mock } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, within, act } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; +import { mockIntersectionObserver } from "@Tests/mockIntersectionObserver"; vi.mock("./useInstallData", () => ({ useInstallData: vi.fn() })); // Monaco 编辑器无法在 DOM 测试环境中渲染(需 worker + ThemeProvider),用桩替换 @@ -276,7 +277,7 @@ describe("Install App 安装反馈", () => { expect(close).toHaveBeenCalledTimes(1); }); - it("安装失败在操作栏上方渲染内联错误条,点击重试触发重装", () => { + it("安装失败在身份卡内渲染内联错误条,点击重试触发重装", () => { const retryInstall = vi.fn(); mockHook.mockReturnValue({ ...baseHook(), @@ -285,7 +286,8 @@ describe("Install App 安装反馈", () => { state: { status: "ready", view: readyView() }, }); render(); - expect(screen.getByTestId("install-error-bar-message")).toHaveTextContent("写入数据库失败"); + const card = screen.getByTestId("script-identity"); + expect(within(card).getByTestId("install-error-bar-message")).toHaveTextContent("写入数据库失败"); expect(screen.getByTestId("install-primary")).toHaveTextContent("重试安装"); fireEvent.click(screen.getByTestId("install-error-retry")); expect(retryInstall).toHaveBeenCalledTimes(1); @@ -327,6 +329,56 @@ describe("Install App 安装反馈", () => { }); }); +describe("Install App 桌面端操作位置", () => { + let io: ReturnType; + beforeEach(() => { + io = mockIntersectionObserver(); + }); + + it("主按钮与信任说明位于脚本身份卡内,首屏不渲染吸底栏", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + const card = screen.getByTestId("script-identity"); + expect(within(card).getByTestId("install-primary")).toHaveTextContent("安装"); + expect(within(card).getByTestId("action-note")).toHaveTextContent("安装代表您信任该脚本的来源与作者"); + expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument(); + }); + + it("外部接入触发时身份卡底部说明切换为渠道说明", () => { + mockHook.mockReturnValue({ + ...baseHook(), + rejectExternalAccess: vi.fn(), + state: { + status: "ready", + view: readyView({ externalAccess: { operationId: "op-1", contentHash: "abcdef123456" } }), + }, + }); + render(); + expect(screen.getByTestId("action-note")).toHaveTextContent("由「外部接入」触发"); + }); + + it("身份卡操作组滚出后,吸底栏给出脚本名与可用的主按钮", () => { + const install = vi.fn(); + mockHook.mockReturnValue({ ...baseHook(), install, state: { status: "ready", view: readyView() } }); + render(); + act(() => { + io.setIntersecting(screen.getByTestId("card-actions"), false); + }); + const bar = screen.getByTestId("action-bar"); + expect(within(bar).getByText("全网每日签到助手")).toBeInTheDocument(); + fireEvent.click(within(bar).getByTestId("install-primary")); + expect(install).toHaveBeenCalledTimes(1); + }); + + it("移动端主按钮仍在吸底栏,身份卡内没有操作按钮", () => { + mockMatchMedia(true); + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(within(screen.getByTestId("script-identity")).queryByTestId("install-primary")).not.toBeInTheDocument(); + expect(within(screen.getByTestId("action-bar")).getByTestId("install-primary")).toBeInTheDocument(); + }); +}); + describe("Install App 权限卡的更新基线", () => { it("更新场景把已安装版本作为权限对比基线传给权限卡", () => { mockHook.mockReturnValue({ diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 9f446e384..48ad6b1b0 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Download, RefreshCw, Rss, HardDrive, RotateCcw, PlugZap } from "lucide-react"; +import { Download, RefreshCw, Rss, HardDrive, RotateCcw, PlugZap, FileCode } from "lucide-react"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { isPermissionOk, openInCurrentTab } from "@App/pkg/utils/utils"; -import { InstallLayout } from "./components/InstallLayout"; +import { InstallLayout, InstallSummary } from "./components/InstallLayout"; import { ScriptIdentity } from "./components/ScriptIdentity"; import { PermissionCard } from "./components/PermissionCard"; import { SubscribeScripts } from "./components/SubscribeScripts"; @@ -14,7 +14,6 @@ import { InstallLoading, InstallError, InstallExpired } from "./components/Insta import { WatchingBanner } from "./components/WatchingBanner"; import { ExternalAccessBanner } from "./components/ExternalAccessBanner"; import { InstallSuccessRibbon } from "./components/InstallSuccessRibbon"; -import { InstallErrorBar } from "./components/InstallErrorBar"; import { BackgroundPrompt, backgroundPromptShownKey, keepAlivePromptShownKey } from "./components/BackgroundPrompt"; import { useInstallData } from "./useInstallData"; @@ -55,10 +54,10 @@ export default function App() { const codePreviewRef = useRef(null); const installed = outcome.phase === "installed" ? outcome.result : null; const externalAccessFailure = state.status === "ready" && !!state.view.externalAccess; - const errorBar = - outcome.phase === "failed" ? ( - - ) : undefined; + const failure = + outcome.phase === "failed" + ? { message: outcome.message, onRetry: externalAccessFailure ? undefined : retryInstall } + : undefined; // 后台/定时脚本首次安装时,提示开启后台运行(对照 v1.4 checkBackgroundPrompt) const ready = state.status === "ready" ? state.view : null; @@ -122,7 +121,7 @@ export default function App() { installUrl={state.skill.installUrl} phase={outcome.phase} closing={installed?.closing === true} - alert={errorBar} + failure={failure} onInstall={installSkill} onCancel={cancelSkill} /> @@ -148,6 +147,16 @@ export default function App() { : t("external_access:install_context_chip") : baseTitle; const titleTone = watching ? "watching" : "default"; + const note = watching + ? t("install:action_note_watching") + : externalAccess + ? // 外部接入触发:提示这是可信渠道请求 + 安装即信任来源/作者(设计稿 ActionNote) + t("external_access:install_action_note") + : view.isUpdate + ? t("install:action_note_update") + : view.isSubscribe + ? t("install:action_note_subscribe") + : t("install:action_note_install"); const titleIcon = externalAccess ? PlugZap : view.isSubscribe @@ -181,9 +190,24 @@ export default function App() { /> ) : undefined } - alert={errorBar} - actions={ + failure={failure} + note={note} + summary={ + + ) : ( + + ) + } + name={view.name} + version={view.version.kind === "install" ? view.version.version : view.version.newVersion} + /> + } + actions={(placement) => ( install({ rememberSession: true }) : undefined } /> - } + )} + identity={(slots) => ( + + )} > - {view.externalAccess && ( ({ + placement: "footer" as const, isUpdate: false, isSubscribe: false, onInstall: vi.fn(), @@ -84,11 +85,6 @@ describe("InstallActions 操作区", () => { expect(screen.getByTestId("watch-toggle")).toHaveTextContent("停止监听"); }); - it("操作栏左侧渲染信任提示语(对照设计稿 BarNote)", () => { - render(); - expect(screen.getByTestId("action-bar-note")).toBeInTheDocument(); - }); - it("未提供 onExternalAccessReject 时不渲染拒绝按钮", () => { render(); expect(screen.queryByTestId("external-access-reject")).not.toBeInTheDocument(); @@ -103,9 +99,49 @@ describe("InstallActions 操作区", () => { expect(onExternalAccessReject).toHaveBeenCalledTimes(1); }); - it("外部接入触发时操作栏提示语切换为渠道说明(设计稿 ActionNote)", () => { - render(); - expect(screen.getByTestId("action-bar-note")).toHaveTextContent("由「外部接入」触发"); + it("外部接入更新时「本会话允许」不再是独立按钮,而是主按钮下拉的首项,选中触发会话允许", async () => { + const p = baseProps(); + const onExternalAccessSessionAllow = vi.fn(); + render( + + ); + expect(screen.queryByTestId("external-access-session-allow")).not.toBeInTheDocument(); + await open(screen.getByTestId("install-more")); + const items = screen.getAllByRole("menuitem"); + expect(items[0]).toHaveTextContent("更新并本会话允许"); + fireEvent.click(items[0]); + expect(onExternalAccessSessionAllow).toHaveBeenCalledTimes(1); + expect(p.onInstall).not.toHaveBeenCalled(); + }); + + it("未提供会话允许回调时下拉不含「更新并本会话允许」", async () => { + render(); + await open(screen.getByTestId("install-more")); + expect(screen.queryByText("更新并本会话允许")).not.toBeInTheDocument(); + }); +}); + +describe("InstallActions 按位置渲染", () => { + it("身份卡内整组按钮放大一号,吸底栏保持常规尺寸", () => { + const { unmount } = render(); + expect(screen.getByTestId("install-primary")).toHaveAttribute("data-size", "lg"); + expect(screen.getByTestId("close-primary")).toHaveAttribute("data-size", "lg"); + unmount(); + render(); + expect(screen.getByTestId("install-primary")).toHaveAttribute("data-size", "default"); + }); + + it("外部接入的拒绝按钮在首屏获得焦点,滚动中途出现的吸底副本不抢焦点", () => { + const { unmount } = render(); + expect(screen.getByTestId("external-access-reject")).toHaveFocus(); + unmount(); + render(); + expect(screen.getByTestId("external-access-reject")).not.toHaveFocus(); }); }); @@ -139,13 +175,15 @@ describe("InstallActions 主按钮就地状态机", () => { render( ); expect(screen.getByTestId("external-access-reject")).toBeDisabled(); - expect(screen.getByTestId("external-access-session-allow")).toBeDisabled(); + // 本会话允许位于主按钮下拉中,下拉触发器锁住即该入口锁住 + expect(screen.getByTestId("install-more")).toBeDisabled(); }); it("未传 phase 时保持原有的安装文案与可点状态", () => { diff --git a/src/pages/install/components/InstallActions.tsx b/src/pages/install/components/InstallActions.tsx index 0c93dfe4c..2285a03ea 100644 --- a/src/pages/install/components/InstallActions.tsx +++ b/src/pages/install/components/InstallActions.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, ChevronDown, Download, Eye, EyeOff, History, Info, Loader2, RefreshCw } from "lucide-react"; +import { Check, ChevronDown, Download, Eye, EyeOff, History, Loader2, RefreshCw } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; import { DropdownMenu, @@ -9,8 +9,11 @@ import { DropdownMenuItem, } from "@App/pages/components/ui/dropdown-menu"; import { cn } from "@App/pkg/utils/cn"; +import type { ActionPlacement } from "./InstallLayout"; export interface InstallActionsProps { + /** 身份卡内用大一号按钮;sticky 副本不抢初始焦点 */ + placement: ActionPlacement; isUpdate: boolean; /** 脚本在回收站中:主按钮由「安装/更新」改为「还原/还原并更新」 */ inTrash?: boolean; @@ -39,6 +42,7 @@ function MoreMenu({ testid, label, variant, + size, triggerClassName, disabled, children, @@ -46,6 +50,7 @@ function MoreMenu({ testid: string; label: string; variant: "default" | "outline"; + size: "icon" | "icon-lg"; triggerClassName?: string; disabled?: boolean; children: React.ReactNode; @@ -57,7 +62,7 @@ function MoreMenu({ - )} - - {onExternalAccessReject ? ( - // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split - // 主按钮;此处只补前两档,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 - <> - - {onExternalAccessSessionAllow && ( - - )} - - ) : isUpdate ? ( -
- - - onClose({ noMoreUpdates: true })}> - {t("install:close_update_script_no_more_update")} - - -
- ) : ( - - )} +
+ {localFile && ( + + )} + {onExternalAccessReject ? ( + // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split + // 主按钮,本会话允许收在其下拉首项;此处只补拒绝,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 + + ) : isUpdate ? (
- onInstall({ closeAfterInstall: false })}>{noCloseLabel} - {!isSubscribe && ( - onInstall({ noMoreUpdates: true })}> - {noMoreUpdateLabel} - - )} + onClose({ noMoreUpdates: true })}> + {t("install:close_update_script_no_more_update")} +
+ ) : ( + + )} + +
+ + + {onExternalAccessSessionAllow && ( + + + {t("external_access:decision_update_session_allow")} + + )} + onInstall({ closeAfterInstall: false })}>{noCloseLabel} + {!isSubscribe && ( + onInstall({ noMoreUpdates: true })}>{noMoreUpdateLabel} + )} +
); diff --git a/src/pages/install/components/InstallErrorBar.tsx b/src/pages/install/components/InstallErrorBar.tsx index 4460182c1..e383c1ed1 100644 --- a/src/pages/install/components/InstallErrorBar.tsx +++ b/src/pages/install/components/InstallErrorBar.tsx @@ -1,24 +1,30 @@ import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; +import { cn } from "@App/pkg/utils/cn"; export interface InstallErrorBarProps { message: string; onRetry?: () => void; + /** 嵌在内容列内(身份卡 / 桌面吸底栏)时用圆角框;否则贴满移动端吸底栏宽度 */ + inset?: boolean; } /** - * 贴在吸底操作栏正上方的安装失败条。失败时用户必须留在页面上做决定, + * 紧贴操作按钮的安装失败条。失败时用户必须留在页面上做决定, * 所以失败原因和重试入口都得常驻可读,不能交给会自动消失的飘窗。 */ -export function InstallErrorBar({ message, onRetry }: InstallErrorBarProps) { +export function InstallErrorBar({ message, onRetry, inset }: InstallErrorBarProps) { const { t } = useTranslation(["install", "common"]); return (
+ note={t("install:skill_warning")} + summary={ + } + tone="skill" + name={metadata.name} + version={metadata.version} + /> } - > - {/* 身份卡 */} -
-
-
- + actions={(placement) => + // 移动端不渲染 note,警示语沿用吸底栏里与按钮同行的位置 + placement === "footer" ? ( +
+

{t("install:skill_warning")}

+ {buttons(placement)}
-
-
-

{metadata.name}

- {"Skill"} - {metadata.version && ( - {`v${metadata.version}`} - )} - {isUpdate && ( - - {t("install:update_script")} - - )} + ) : ( + buttons(placement) + ) + } + identity={(slots) => ( +
+
+
+
-
- {t("install:skill_kind")} - {installUrl && ( - - - {installUrl} - +
+
+

+ {metadata.name} +

+ {"Skill"} + {metadata.version && ( + {`v${metadata.version}`} + )} + {isUpdate && ( + + {t("install:update_script")} + + )} +
+
+ {t("install:skill_kind")} + {installUrl && ( + + + {installUrl} + + )} +
+ {metadata.description && ( +

+ {metadata.description} +

)}
- {metadata.description && ( -

- {metadata.description} -

- )} + {slots?.actions}
-
-
- + {slots?.alert} + {slots?.note} +
+ )} + > {/* 提示词卡 */} {prompt && ( diff --git a/tests/mockIntersectionObserver.ts b/tests/mockIntersectionObserver.ts index ee0fe853c..fe47aa226 100644 --- a/tests/mockIntersectionObserver.ts +++ b/tests/mockIntersectionObserver.ts @@ -1,14 +1,59 @@ -export function mockIntersectionObserver(): void { - globalThis.IntersectionObserver = class { - readonly root = null; +import { vi } from "vitest"; + +/** + * 可控的 IntersectionObserver 桩:happy-dom 不做布局,原生实现永远不会回调, + * 测试用 setIntersecting 手动推送某个被观察元素进出可视区。调用方负责用 act 包裹。 + * 传数组表示同一次回调里按时间先后批量投递的多条记录(真实实现在回调排队期间会累积多条)。 + */ +export function mockIntersectionObserver() { + const observers = new Set(); + + class MockIntersectionObserver { + readonly root: Element | Document | null; readonly rootMargin = ""; - readonly thresholds: ReadonlyArray = []; + readonly thresholds: ReadonlyArray = [0]; + readonly targets = new Set(); + + constructor( + private readonly callback: IntersectionObserverCallback, + init?: IntersectionObserverInit + ) { + this.root = init?.root ?? null; + observers.add(this); + } - observe() {} - unobserve() {} - disconnect() {} + observe(el: Element) { + this.targets.add(el); + } + unobserve(el: Element) { + this.targets.delete(el); + } + disconnect() { + this.targets.clear(); + observers.delete(this); + } takeRecords() { return []; } - } as unknown as typeof IntersectionObserver; + + notify(target: Element, states: boolean[]) { + const entries = states.map((isIntersecting) => ({ + target, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + })); + this.callback(entries as unknown as IntersectionObserverEntry[], this as unknown as IntersectionObserver); + } + } + + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + + return { + setIntersecting(target: Element, isIntersecting: boolean | boolean[]) { + const states = Array.isArray(isIntersecting) ? isIntersecting : [isIntersecting]; + for (const observer of [...observers]) { + if (observer.targets.has(target)) observer.notify(target, states); + } + }, + }; }