mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-09-01 15:36:33 +08:00
19df7c2389
A block translation renders as `display: inline-block` so its decoration hugs the text. That makes it an atomic inline: when a float leaves the line too narrow, the browser drops the whole box below the float instead of wrapping text beside it. Against a tall float the translation is stranded that far below its own paragraph, stretching the paragraph into a page-tall blank gap. #1188 added a `data-read-frog-float-wrap` override for this, but decided it by scanning the paragraph's DOM siblings for a floated element. A float only has to share a block formatting context to intrude — it does not have to be near the paragraph in the tree. On ja.wikipedia the body is `div.mw-parser-output > section > section > p` while the infobox floats out of the outer section's sibling, so the sibling scan never saw it and every paragraph below the lead section was left displaced. Measured on the 毛沢東 article: the 生い立ち translation landed 5643px below its paragraph. Decide by measuring the rendered result instead. A Range spanning everything before the wrapper gives the source text's last-line bottom; if the translated node sits more than `margin-top + line-height` below it, the box was displaced. That is structure-agnostic, so ancestor and cousin floats are covered too, and it costs two rect reads instead of a per-sibling subtree scan. `flowSource` existed only to locate the paragraph for the old scan, so its plumbing is removed. Verified on ja.wikipedia 毛沢東 (bilingual, zh): worst gap across all translated paragraphs 5643px -> 14px, zero stranded. Line boxes confirm the mechanism — 859px (full column, ignoring the float) -> 515px (shortened beside the infobox). Instrumented rAF shows all 14 float-wrapped nodes are decided before the first paint, so no displaced frame is ever shown. jsdom implements no layout and omits `Range.prototype.getBoundingClientRect` entirely, so vitest.setup.ts stubs it (guarded — that file also loads for node-environment tests). Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.0 KiB
TypeScript
154 lines
5.0 KiB
TypeScript
import { vi } from "vitest"
|
|
import "@testing-library/jest-dom"
|
|
|
|
// Keep test output quiet by default. Individual tests can still spy on these
|
|
// methods when they need to assert logging behavior.
|
|
// eslint-disable-next-line no-console
|
|
console.log = () => {}
|
|
// eslint-disable-next-line no-console
|
|
console.info = () => {}
|
|
console.warn = () => {}
|
|
console.error = () => {}
|
|
|
|
class MemoryStorage implements Storage {
|
|
#store = new Map<string, string>()
|
|
|
|
get length() {
|
|
return this.#store.size
|
|
}
|
|
|
|
clear() {
|
|
this.#store.clear()
|
|
}
|
|
|
|
getItem(key: string) {
|
|
return this.#store.get(key) ?? null
|
|
}
|
|
|
|
key(index: number) {
|
|
return [...this.#store.keys()][index] ?? null
|
|
}
|
|
|
|
removeItem(key: string) {
|
|
this.#store.delete(key)
|
|
}
|
|
|
|
setItem(key: string, value: string) {
|
|
this.#store.set(key, value)
|
|
}
|
|
}
|
|
|
|
// Node 22 exposes built-in Web Storage. In worker processes without a configured
|
|
// backing file, reading it emits `--localstorage-file` warnings. Replace it with
|
|
// an in-memory test double before app modules import Jotai utils.
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: new MemoryStorage(),
|
|
})
|
|
|
|
Object.defineProperty(globalThis, "sessionStorage", {
|
|
configurable: true,
|
|
value: new MemoryStorage(),
|
|
})
|
|
|
|
// Mock the runtime i18n facade so tests resolve keys deterministically (returning the
|
|
// dot-key, matching the pre-migration behaviour that test assertions rely on) without
|
|
// initializing i18next or touching browser.i18n.
|
|
vi.mock("@/utils/i18n", () => ({
|
|
i18n: {
|
|
t: (key: string) => key,
|
|
},
|
|
initI18n: async () => {},
|
|
setUiLanguage: async () => {},
|
|
}))
|
|
|
|
// LocaleBoundary is a separate module from the mocked facade above and pulls in i18next +
|
|
// the bundled YAML resources (which vitest has no plugin for). Stub it to a passthrough so
|
|
// no test loads i18next or the .yml files; runtime language switching is not under test here.
|
|
vi.mock("@/utils/i18n/locale-boundary", () => ({
|
|
LocaleBoundary: ({ children }: { children: unknown }) => children,
|
|
}))
|
|
|
|
// Iconify's <Icon> fetches icon data from api.iconify.design on mount and schedules
|
|
// retry timers when that fetch stalls (common in CI). Those Node timers outlive the
|
|
// test file's jsdom environment and crash React with "window is not defined" as an
|
|
// unhandled error attributed to whichever file runs next. Render an inert placeholder
|
|
// instead; no test exercises real icon loading. iconify-internal-api.test.ts opts back
|
|
// in via vi.unmock to keep its _api canary pointed at the real package.
|
|
vi.mock("@iconify/react", async () => {
|
|
const { createElement } = await import("react")
|
|
return {
|
|
Icon: ({ className, icon }: { className?: string; icon: string }) =>
|
|
createElement("span", { "aria-hidden": true, className, "data-icon": icon }),
|
|
_api: {
|
|
setFetch: () => {},
|
|
},
|
|
}
|
|
})
|
|
|
|
// Mock the fakeBrowser's i18n.getMessage method which is not implemented in fake-browser
|
|
// This is used when WxtVitest plugin replaces browser imports with fake-browser
|
|
vi.mock("wxt/testing/fake-browser", async () => {
|
|
const actual = await vi.importActual<any>("wxt/testing/fake-browser")
|
|
|
|
Object.assign(actual.fakeBrowser.i18n, {
|
|
getMessage: (key: string) => key.replaceAll("_", "."),
|
|
})
|
|
Object.assign(actual.fakeBrowser.identity, {
|
|
getRedirectURL: () => "https://mock-redirect-url.chromiumapp.org/",
|
|
})
|
|
Object.assign(actual.fakeBrowser.runtime, {
|
|
getManifest: () => ({
|
|
manifest_version: 3,
|
|
name: "Read Frog",
|
|
version: "1.0.0",
|
|
description: "Test manifest",
|
|
}),
|
|
})
|
|
|
|
return actual
|
|
})
|
|
|
|
// jsdom implements no layout, so it omits Range.getBoundingClientRect entirely
|
|
// (Element.getBoundingClientRect it does stub, returning zeros). Every browser
|
|
// ships it. Match jsdom's own convention with a zero rect so layout probes
|
|
// short-circuit instead of throwing; tests that exercise them spy on this.
|
|
// (Guarded: this setup file also runs for node-environment test files.)
|
|
if (typeof Range !== "undefined" && typeof Range.prototype.getBoundingClientRect !== "function") {
|
|
Range.prototype.getBoundingClientRect = function () {
|
|
return {
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: 0,
|
|
height: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
}
|
|
}
|
|
}
|
|
|
|
// JSDom + Vitest don't play well with each other. Long story short - default
|
|
// TextEncoder produces Uint8Array objects that are _different_ from the global
|
|
// Uint8Array objects, so some functions that compare their types explode.
|
|
// https://github.com/vitest-dev/vitest/issues/4043#issuecomment-1905172846
|
|
class ESBuildAndJSDOMCompatibleTextEncoder extends TextEncoder {
|
|
override encode(input: string) {
|
|
if (typeof input !== "string") {
|
|
throw new TypeError("`input` must be a string")
|
|
}
|
|
|
|
const decodedURI = decodeURIComponent(encodeURIComponent(input))
|
|
const arr = new Uint8Array(decodedURI.length)
|
|
const chars = decodedURI.split("")
|
|
for (let i = 0; i < chars.length; i++) {
|
|
arr[i] = decodedURI[i]!.charCodeAt(0)
|
|
}
|
|
return arr
|
|
}
|
|
}
|
|
|
|
globalThis.TextEncoder = ESBuildAndJSDOMCompatibleTextEncoder
|