UNPKG

react-sass-inlinesvg

Version:
541 lines (540 loc) 26.4 kB
import fs from "fs"; import React from "react"; import { render, act, fireEvent, screen, waitFor, } from "@testing-library/react"; import fetchMock, { enableFetchMocks } from "jest-fetch-mock"; import "@testing-library/jest-dom/extend-expect"; import { ForTest, setup as setupSvg } from "../Svg"; enableFetchMocks(); jest.spyOn(global, "setTimeout"); const fooIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><polygon points=""/></svg>`; const barIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><path d="" style="fill-rule:evenodd"/></svg>`; const titleDescriptionIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><title>TITLE</title><desc>DESC</desc></svg>`; const playSvg = fs.readFileSync(__dirname + "/__fixtures__/play.svg", "utf8"); const datahrefSvg = fs.readFileSync(__dirname + "/__fixtures__/datahref.svg", "utf8"); const pathMap = { FooIcon: () => "http://localhost:3000/foo-icon.svg", BarIcon: () => "http://localhost:3000/bar-icon.svg", TitleDescriptionIcon: () => "http://localhost:3000/title-description-icon.svg", Play: () => "http://localhost:3000/play.svg", DataHref: () => "http://localhost:3000/datahref.svg", }; describe("Svg", () => { const { SVG } = setupSvg(pathMap); function setup({ props = {}, } = {}) { const element = React.createElement(SVG, { ...props }); const baseResult = render(element); const cloneElement = (props) => React.cloneElement(element, props); const rerender = (props) => baseResult.rerender(cloneElement(props)); const { root, result } = createResult(baseResult); return { root, result: { ...result, rerender } }; } beforeEach(() => { jest.useFakeTimers(); ForTest["state"].setupCompleted = false; ForTest["state"].cacheObject = {}; ForTest["state"].updateQueueObject = {}; ForTest["state"].aggregation = { start: { queue: [] }, fetch: { queue: [] }, render: { queue: [] }, }; fetchMockIf({ "/foo-icon.svg": fooIcon, "/bar-icon.svg": barIcon, "/title-description-icon.svg": titleDescriptionIcon, "/play.svg": playSvg, "/datahref.svg": datahrefSvg, }); }); describe("初期状態", () => { it("スケルトンが表示されること", () => { const { root } = setup(); const element = root.query(); expect_initial(element); expect(element).toMatchSnapshot(); }); }); describe("ローディング中", () => { it("読み込み中になること", async () => { const { root } = setup(); const element = root.query(); emit(element, "FooIcon"); expect_loading(element, "FooIcon"); expect(element).toMatchSnapshot(); }); }); describe("fetch中", () => { it("読み込み中になること", async () => { const { root } = setup(); const element = root.query(); await emitFetch(element, "FooIcon"); expect_loading(element, "FooIcon"); expect(element).toMatchSnapshot(); }); it("A->Bと更新するときも読み込み中になること", async () => { const { root } = setup({}); const element = root.query(); await change(element, "FooIcon"); await emitFetch(element, "BarIcon"); expect_loading(element, "BarIcon"); expect(element).toMatchSnapshot(); }); }); describe("読み込み完了時", () => { it("FooIconに更新できること", async () => { const { root } = setup({}); const element = root.query(); await change(element, "FooIcon"); expect_complete(element, "FooIcon"); expect(element).toMatchSnapshot(); }); it("A->Bと更新できること", async () => { const { root } = setup({}); const element = root.query(); await change(element, "FooIcon"); await change(element, "BarIcon"); expect_complete(element, "BarIcon"); expect(element).toMatchSnapshot(); }); it("A->B->Aに更新できること", async () => { const { root } = setup({}); const element = root.query(); await change(element, "FooIcon"); await change(element, "BarIcon"); await change(element, "FooIcon"); expect_complete(element, "FooIcon"); expect(element).toMatchSnapshot(); }); }); describe("特殊なSVGの指定", () => { it("svg_NULL のイベントを発生させるとSVGが切り替わること", () => { const { root } = setup(); const element = root.query(); emit(element, "NULL"); // NULLはemitだけで反映される expect(element).not.toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it.each(["NONE", "HIDDEN"])("svg_%s のイベントを発生させるとSVGが切り替わること", async (svgName) => { const { root } = setup(); const element = root.query(); emit(element, svgName); // NONE HIDDENはDOMの更新を待つ必要がある await waitForDomUpdate(); expect_complete(element, svgName); expect(element).toMatchSnapshot(); }); }); describe("props", () => { describe("defaultName指定時", () => { it("NULLのときは要素を表示しないこと", () => { const { root } = setup({ props: { defaultName: "NULL" } }); const element = root.query(); // defaultNameのNULLは初回の描画から反映される expect(element).not.toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it.each(["NONE", "HIDDEN"])("%s のときは不可視要素を表示すること", (svgName) => { const { root } = setup({ props: { defaultName: svgName } }); const element = root.query(); // defaultNameのNONE HIDDENは初回の描画から反映される expect_complete(element, svgName); expect(element).toMatchSnapshot(); }); it("animationstartを経由せずに可視要素を表示すること", async () => { const { root } = setup({ props: { defaultName: "FooIcon" } }); const element = root.query(); expect_loading(element, "FooIcon"); await waitForAnimationStart(); await waitForDomUpdate(); expect_complete(element, "FooIcon"); expect(element).toMatchSnapshot(); }); }); describe("title", () => { it("titleを指定できること", async () => { const { root } = setup({ props: { title: "newTitle" } }); const element = root.query(); expect(screen.queryByTitle("newTitle")).not.toBeInTheDocument(); await change(element, "FooIcon"); expect(screen.queryByTitle("newTitle")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it("既存のtitleを上書きできること", async () => { const { root } = setup({ props: { title: "newTitle" } }); const element = root.query(); expect(screen.queryByTitle("newTitle")).not.toBeInTheDocument(); await change(element, "TitleDescriptionIcon"); expect(screen.queryByTitle("newTitle")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it("後からtitleを更新できること", async () => { const { root, result } = setup({}); const element = root.query(); await change(element, "FooIcon"); expect(screen.queryByTitle("updateTitle")).not.toBeInTheDocument(); result.rerender({ title: "updateTitle" }); expect(screen.queryByTitle("updateTitle")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it.each(["HIDDEN", "NONE", "NULL"])("SVGが %s の場合はtitleが無視されること", async (svgName) => { const { root, result } = setup({ props: { title: "newTitle" } }); const element = root.query(); await change(element, svgName); expect(screen.queryByTitle("newTitle")).not.toBeInTheDocument(); result.rerender({ title: "updateTitle" }); expect(screen.queryByTitle("updateTitle")).not.toBeInTheDocument(); expect(element).toMatchSnapshot(); }); }); describe("description", () => { it("descriptionを指定できること", async () => { const { root } = setup({ props: { description: "newDescription" } }); const element = root.query(); expect(screen.queryByText("newDescription")).not.toBeInTheDocument(); await change(element, "FooIcon"); expect(screen.queryByText("newDescription")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it("既存のdescriptionを上書きできること", async () => { const { root } = setup({ props: { description: "newDescription" } }); const element = root.query(); expect(screen.queryByText("newDescription")).not.toBeInTheDocument(); await change(element, "TitleDescriptionIcon"); expect(screen.queryByText("newDescription")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it("後からdescriptionを更新できること", async () => { const { root, result } = setup({}); const element = root.query(); await change(element, "FooIcon"); expect(screen.queryByTitle("updateDescription")).not.toBeInTheDocument(); result.rerender({ title: "updateDescription" }); expect(screen.queryByTitle("updateDescription")).toBeInTheDocument(); expect(element).toMatchSnapshot(); }); it.each(["HIDDEN", "NONE", "NULL"])("SVGが %s の場合はdescription無視されること", async (svgName) => { const { root, result } = setup({ props: { description: "newDescription" }, }); const element = root.query(); await change(element, svgName); expect(screen.queryByTitle("newDescription")).not.toBeInTheDocument(); result.rerender({ description: "updateTitle" }); expect(screen.queryByTitle("updateTitle")).not.toBeInTheDocument(); expect(element).toMatchSnapshot(); }); }); describe("innerRef", () => { it("refで要素を得られること", () => { const innerRef = React.createRef(); setup({ props: { innerRef } }); expect(innerRef.current).toBeInstanceOf(SVGSVGElement); expect(innerRef.current).toMatchSnapshot(); }); }); describe("onLoad", () => { it("初期状態では呼び出されていないこと", () => { const fn = jest.fn(); setup({ props: { onLoad: fn } }); expect(fn).toBeCalledTimes(0); }); it("SVG読み込み完了時に呼び出されること", async () => { const fn = jest.fn(); const { root } = setup({ props: { onLoad: fn } }); const element = root.query(); emit(element, "FooIcon"); expect(fn).toBeCalledTimes(0); await waitForAnimationStart(); expect(fn).toBeCalledTimes(0); await waitForDomUpdate(); expect(fn).toBeCalledTimes(1); expect(fn.mock.calls[0][0]).toBe(pathMap.FooIcon()); expect(fn.mock.calls[0][1]).toBe(false); emit(element, "BarIcon"); expect(fn).toBeCalledTimes(1); await waitForAnimationStart(); expect(fn).toBeCalledTimes(1); await waitForDomUpdate(); expect(fn).toBeCalledTimes(2); expect(fn.mock.calls[1][0]).toBe(pathMap.BarIcon()); expect(fn.mock.calls[1][1]).toBe(false); await change(element, "FooIcon"); expect(fn).toBeCalledTimes(3); expect(fn.mock.calls[2][0]).toBe(pathMap.FooIcon()); expect(fn.mock.calls[2][1]).toBe(true); }); }); describe("onError", () => { it("初期状態では呼び出されていないこと", () => { const fn = jest.fn(); setup({ props: { onError: fn } }); expect(fn).toBeCalledTimes(0); }); it("未定義のアニメーション名でonErrorが発生すること", () => { const fn = jest.fn(); const { root } = setup({ props: { onError: fn } }); const element = root.query(); emit(element, "ThrowError"); expect(fn).toBeCalledTimes(1); expect(fn.mock.calls[0][0]).toBeInstanceOf(TypeError); expect(fn.mock.calls[0][0].message).toBe(`unknown svgName "ThrowError"`); expect(element).toMatchSnapshot(); }); }); }); describe("setupSvg", () => { describe("uniquifyIDs", () => { function setup({ defaultName, uniqueHash, }) { const { SVG } = setupSvg(pathMap, { uniquifyIDs: true, uniqueHash }); return createResult(render(React.createElement("div", null, React.createElement(SVG, { "data-testid": "svg1", defaultName: defaultName }), React.createElement(SVG, { "data-testid": "svg2" })))); } it("should uniquify ids with the random uniqueHash", async () => { const { root } = setup({ defaultName: "Play" }); const svg1 = screen.getByTestId("svg1"); const svg2 = screen.getByTestId("svg2"); await waitForFetch(); await waitForDomUpdate(); await change(svg2, "Play"); expect(svg1.querySelector("radialGradient")?.id).toEqual(expect.stringMatching(/^radialGradient-1__[\dA-Za-z]{8}$/)); expect(svg2.querySelector("radialGradient")?.id).toEqual(expect.stringMatching(/^radialGradient-1__[\dA-Za-z]{8}$/)); }); it("should uniquify ids with a custom uniqueHash", async () => { const { root } = setup({ defaultName: "Play", uniqueHash: "test" }); const svg1 = screen.getByTestId("svg1"); const svg2 = screen.getByTestId("svg2"); await waitForFetch(); await waitForDomUpdate(); await change(svg2, "Play"); expect(svg1.querySelector("radialGradient")?.id).toEqual(expect.stringMatching(/^radialGradient-1__test$/)); expect(svg2.querySelector("radialGradient")?.id).toEqual(expect.stringMatching(/^radialGradient-1__test$/)); expect(root.get()).toMatchSnapshot(); }); it("should not uniquify non-id hrefs", async () => { const { root } = setup({ defaultName: "DataHref", uniqueHash: "test" }); const svg2 = screen.getByTestId("svg2"); await waitForFetch(); await waitForDomUpdate(); await change(svg2, "DataHref"); expect(root.get()).toMatchSnapshot(); }); }); }); describe("全体処理", () => { function fillMap(length, make) { return Array.from({ length }) .map((_, i) => i) .reduce((map, i) => { const [key, value] = make(i); map[key] = value; return map; }, {}); } function summary(element) { return { // A: aria-busy="true" A: element.querySelectorAll("svg[aria-busy=true]:not([data-svg-status])").length, // B: aria-busy="true" data-svg-status="loading" B: element.querySelectorAll("svg[aria-busy=true][data-svg-status=loading]:not([data-svg-name])").length, // C: aria-busy="true" data-svg-name="*" data-svg-status="loading" C: element.querySelectorAll("svg[aria-busy=true][data-svg-status=loading][data-svg-name]").length, // D: data-svg-name="*" data-svg-status="complete" D: element.querySelectorAll("svg[data-svg-status=complete][data-svg-name]").length, }; } function svgTag(index) { return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${index} ${index}"><title>${index}</title></svg>`; } describe("10種類10個ずつ100個表示の場合", () => { beforeEach(() => { fetchMockIf(fillMap(10, (i) => [`/${i}.svg`, svgTag(i)])); }); describe("defaultName指定時", () => { function setup() { const { SVG } = setupSvg(fillMap(10, (i) => [i, () => `http://localhost:3000/${i}.svg`])); return createResult(render(React.createElement("div", null, Array.from({ length: 100 }).map((_, i) => (React.createElement(SVG, { key: i, defaultName: String(i % 10) })))))); } it("初期描画後", () => { const element = setup().root.get(); expect(element).toMatchSnapshot(); }); it("徐々に変化していくこと", async () => { const element = setup().root.get(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 100, D: 0 }); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 100, D: 0 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 68, D: 32 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 36, D: 64 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 4, D: 96 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 0, D: 100 }); expect(element).toMatchSnapshot(); }); }); describe("defaultName指定なし", () => { function setup() { const { SVG } = setupSvg(fillMap(10, (i) => [i, () => `http://localhost:3000/${i}.svg`])); return createResult(render(React.createElement("div", null, Array.from({ length: 100 }).map((_, i) => (React.createElement(SVG, { key: i })))))); } it("初期描画後", () => { const element = setup().root.get(); expect(element).toMatchSnapshot(); }); it("徐々に変化していくこと", async () => { const element = setup().root.get(); expect(summary(element)).toStrictEqual({ A: 100, B: 0, C: 0, D: 0 }); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 84, B: 16, C: 0, D: 0 }); fireAnimationstart(element); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 84, B: 0, C: 16, D: 0 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 68, B: 16, C: 0, D: 16 }); fireAnimationstart(element); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 68, B: 16, C: 0, D: 16 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 52, B: 16, C: 0, D: 32 }); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 52, B: 16, C: 0, D: 32 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 36, B: 32, C: 0, D: 32 }); fireAnimationstart(element); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 36, B: 32, C: 0, D: 32 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 20, B: 16, C: 0, D: 64 }); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 20, B: 16, C: 0, D: 64 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 4, B: 32, C: 0, D: 64 }); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 4, B: 32, C: 0, D: 64 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 36, C: 0, D: 64 }); fireAnimationstart(element); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 0, B: 36, C: 0, D: 64 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 4, C: 0, D: 96 }); fireAnimationstart(element); await waitForAnimationStart(); expect(summary(element)).toStrictEqual({ A: 0, B: 4, C: 0, D: 96 }); await waitForDomUpdate(); expect(summary(element)).toStrictEqual({ A: 0, B: 0, C: 0, D: 100 }); expect(element).toMatchSnapshot(); }); }); }); }); }); function createResult(result) { return { result, root: { query: () => result.container.firstElementChild, get: () => { if (result.container.firstElementChild) { return result.container.firstElementChild; } throw new Error("element not exists"); }, }, }; } function fireAnimationstart(element) { const svgList = Array.from(element.querySelectorAll("svg")); act(() => { element.querySelectorAll("svg[data-svg-status=loading]").forEach((svg) => { const pos = svgList.findIndex((s) => s === svg); fireEvent.animationStart(svg, { animationName: `svg_${pos % 10}` }); }); }); } function emit(element, svgName) { act(() => { element && fireEvent.animationStart(element, { animationName: `svg_${svgName}` }); }); } async function emitFetch(element, svgName) { emit(element, svgName); await waitForAnimationStart(); } async function change(element, svgName) { await emitFetch(element, svgName); await waitForDomUpdate(); } async function waitForFetch() { await waitFor(() => { jest.advanceTimersByTime(0); }); } async function waitForDomUpdate() { await waitFor(() => { jest.advanceTimersByTime(16); }); } // NOTE: React18からwaitForFetchを3回呼ばないとanimationstartがハンドリングできない async function waitForAnimationStart() { await waitForFetch(); await waitForFetch(); await waitForFetch(); } function expect_initial(element) { expect(element).toHaveAttribute("aria-busy", "true"); expect(element).not.toHaveAttribute("data-svg-name"); expect(element).not.toHaveAttribute("data-svg-status"); } function expect_loading(element, svgName) { expect(element).toHaveAttribute("aria-busy", "true"); expect(element).toHaveAttribute("data-svg-name", svgName); expect(element).toHaveAttribute("data-svg-status", "loading"); } function expect_complete(element, svgName) { expect(element).not.toHaveAttribute("aria-busy"); expect(element).toHaveAttribute("data-svg-name", svgName); expect(element).toHaveAttribute("data-svg-status", "complete"); } // jestにAnimationEventが存在しないのでmockを追加 // https://gitanswer.com/fireevent-animationend-with-animationname-event-animationname-is-undefined-javascript-react-testing-library-842276564 global.AnimationEvent = class AnimationEvent extends Event { _animationName; _elapsedTime; _pseudoElement; constructor(type, animationEventInitDict = {}) { const { animationName = "", elapsedTime = 0, pseudoElement = "", ...eventInitDict } = animationEventInitDict; super(type, eventInitDict); this._animationName = animationName; this._elapsedTime = elapsedTime; this._pseudoElement = pseudoElement; } get animationName() { return this._animationName; } get elapsedTime() { return this._elapsedTime; } get pseudoElement() { return this._pseudoElement; } }; function fetchMockIf(patterns) { fetchMock.mockIf(/^http:\/\/localhost:3000\/.*$/, async (req) => { const key = Object.keys(patterns).find((key) => req.url.endsWith(key)); if (key) { return patterns[key]; } else { return { status: 404, body: "Not Found", }; } }); }