concord-consortium/lara

View on GitHub
lara-typescript/src/interactive-api-client/hooks.spec.tsx

Summary

Maintainability
F
2 wks
Test Coverage
import * as React from "react";
import { act, renderHook } from "@testing-library/react-hooks";
import { render } from "@testing-library/react";
import { mockIFramePhone, MockPhone } from "../interactive-api-lara-host/mock-iframe-phone";
import * as hooks from "./hooks";
import * as iframePhone from "iframe-phone";
import { getClient } from "./client";
import { IAccessibilitySettings } from "./types";
import { getFamilyForFontType } from "../shared/accessibility";

jest.mock("./in-frame", () => ({
  inIframe: () => true
}));

jest.mock("iframe-phone", () => {
  return mockIFramePhone();
});

const mockedPhone = iframePhone.getIFrameEndpoint() as unknown as MockPhone;

beforeEach(() => {
  mockedPhone.reset();
});

describe("useInitMessage", () => {
  it("returns initial message from LARA/parent", async () => {
    const { result, waitForNextUpdate } = renderHook(() => hooks.useInitMessage());

    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "runtime", interactiveState: {foo: "bar"}}
      });
    }, 10);

    await waitForNextUpdate();
    expect(result.current).toEqual({
      mode: "runtime",
      interactiveState: {foo: "bar"}
    });
  });
});

describe("useInteractiveState", () => {
  it("returns current interactive state", async () => {
    const { result, waitForNextUpdate } = renderHook(() => hooks.useInteractiveState<any>());

    expect(result.current.interactiveState).toEqual(null);

    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "runtime", interactiveState: {foo: "bar"}}
      });
    }, 10);

    await waitForNextUpdate();
    expect(result.current.interactiveState).toEqual({foo: "bar"});

    act(() => {
      getClient().managedState.interactiveState = {newState: true};
    });

    expect(result.current.interactiveState).toEqual({newState: true});
  });

  it("lets client app update interactive state", async () => {
    const { result } = renderHook(() => hooks.useInteractiveState<any>());

    expect(result.current.interactiveState).toEqual(null);

    act(() => {
      result.current.setInteractiveState({ a: 1 });
    });

    expect(result.current.interactiveState).toEqual({a: 1});
    expect(getClient().managedState.interactiveState).toEqual({a: 1});

    act(() => {
      // Note that `b` property will be lost! State updates are asynchronous, so they might overwrite each other.
      // This is normal, as that's how React's useState works too. Check lines below how to do such update correctly.
      result.current.setInteractiveState({...result.current.interactiveState, b: 2 });
      result.current.setInteractiveState({...result.current.interactiveState, c: 3 });
    });

    expect(result.current.interactiveState).toEqual({a: 1, c: 3});
    expect(getClient().managedState.interactiveState).toEqual({a: 1, c: 3});

    act(() => {
      // If state is updated incrementally, functional update should be used. No property will be lost here.
      result.current.setInteractiveState((prevState: any) => ({...prevState, d: 4 }));
      result.current.setInteractiveState((prevState: any) => ({...prevState, e: 5 }));
    });

    expect(result.current.interactiveState).toEqual({a: 1, c: 3, d: 4, e: 5});
    expect(getClient().managedState.interactiveState).toEqual({a: 1, c: 3, d: 4, e: 5});
  });

  it("ensures that interactive state is always observed correctly", async () => {
    const TestComponent: React.FC = () => {
      const { interactiveState } = hooks.useInteractiveState<any>();
      const managedStateUpdated = React.useRef(false);

      if (!managedStateUpdated.current) {
        // Immediate update of interactiveState. In real life it wouldn't be done in the component obviously,
        // but that's an easy way to ensure problematic timing - after initial render of the useInteractiveState hook,
        // but before useEffect defined in useInteractiveState is called. This is regression test related to this issue:
        // https://www.pivotaltracker.com/story/show/174154314
        getClient().managedState.interactiveState = "new state 123";
        // Prevent infinite loop, update authoredState just once.
        managedStateUpdated.current = true;
      }
      return <div>{ interactiveState }</div>;
    };

    const { container } = render(<TestComponent />);
    expect(container.textContent).toEqual("new state 123");
  });
});

describe("useAuthoredState", () => {
  it("returns current authored state", async () => {
    const { result, waitForNextUpdate } = renderHook(() => hooks.useAuthoredState<any>());

    expect(result.current.authoredState).toEqual(null);

    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "authoring", authoredState: {foo: "bar"}}
      });
    }, 10);

    await waitForNextUpdate();
    expect(result.current.authoredState).toEqual({foo: "bar"});

    act(() => {
      getClient().managedState.authoredState = {newState: true};
    });

    expect(result.current.authoredState).toEqual({newState: true});
  });

  it("lets client app update authored state", async () => {
    const { result } = renderHook(() => hooks.useAuthoredState<any>());

    expect(result.current.authoredState).toEqual(null);

    act(() => {
      result.current.setAuthoredState({ a: 1 });
    });

    expect(result.current.authoredState).toEqual({a: 1});
    expect(getClient().managedState.authoredState).toEqual({a: 1});

    act(() => {
      // Note that `b` property will be lost! State updates are asynchronous, so they might overwrite each other.
      // This is normal, as that's how React's useState works too. Check lines below how to do such update correctly.
      result.current.setAuthoredState({...result.current.authoredState, b: 2 });
      result.current.setAuthoredState({...result.current.authoredState, c: 3 });
    });

    expect(result.current.authoredState).toEqual({a: 1, c: 3});
    expect(getClient().managedState.authoredState).toEqual({a: 1, c: 3});

    act(() => {
      // If state is updated incrementally, functional update should be used. No property will be lost here.
      result.current.setAuthoredState((prevState: any) => ({...prevState, d: 4 }));
      result.current.setAuthoredState((prevState: any) => ({...prevState, e: 5 }));
    });

    expect(result.current.authoredState).toEqual({a: 1, c: 3, d: 4, e: 5});
    expect(getClient().managedState.authoredState).toEqual({a: 1, c: 3, d: 4, e: 5});
  });

  it("ensures that authored state is always observed correctly", async () => {
    const TestComponent: React.FC = () => {
      const { authoredState } = hooks.useAuthoredState<any>();
      const managedStateUpdated = React.useRef(false);

      if (!managedStateUpdated.current) {
        // Immediate update of authoredState. In real life it wouldn't be done in the component obviously,
        // but that's an easy way to ensure problematic timing - after initial render of the useAuthoredState hook,
        // but before useEffect defined in useAuthoredState is called. This is regression test related to this issue:
        // https://www.pivotaltracker.com/story/show/174154314
        getClient().managedState.authoredState = "new state 123";
        // Prevent infinite loop, update authoredState just once.
        managedStateUpdated.current = true;
      }
      return <div>{ authoredState }</div>;
    };

    const { container } = render(<TestComponent />);
    expect(container.textContent).toEqual("new state 123");
  });
});

describe("useGlobalInteractiveState", () => {
  it("returns current global interactive state", async () => {
    const { result, waitForNextUpdate } = renderHook(() => hooks.useGlobalInteractiveState<any>());

    expect(result.current.globalInteractiveState).toEqual(null);

    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "runtime", globalInteractiveState: {foo: "bar"}}
      });
    }, 10);

    await waitForNextUpdate();
    expect(result.current.globalInteractiveState).toEqual({foo: "bar"});

    act(() => {
      getClient().managedState.globalInteractiveState = {newState: true};
    });

    expect(result.current.globalInteractiveState).toEqual({newState: true});
  });

  it("lets client app update interactive state", async () => {
    const { result } = renderHook(() => hooks.useGlobalInteractiveState<any>());

    expect(result.current.globalInteractiveState).toEqual(null);

    act(() => {
      result.current.setGlobalInteractiveState({ a: 1 });
    });

    expect(result.current.globalInteractiveState).toEqual({a: 1});
    expect(getClient().managedState.globalInteractiveState).toEqual({a: 1});

    act(() => {
      // Note that `b` property will be lost! State updates are asynchronous, so they might overwrite each other.
      // This is normal, as that's how React's useState works too. Check lines below how to do such update correctly.
      result.current.setGlobalInteractiveState({...result.current.globalInteractiveState, b: 2 });
      result.current.setGlobalInteractiveState({...result.current.globalInteractiveState, c: 3 });
    });

    expect(result.current.globalInteractiveState).toEqual({a: 1, c: 3});
    expect(getClient().managedState.globalInteractiveState).toEqual({a: 1, c: 3});

    act(() => {
      // If state is updated incrementally, functional update should be used. No property will be lost here.
      result.current.setGlobalInteractiveState((prevState: any) => ({...prevState, d: 4 }));
      result.current.setGlobalInteractiveState((prevState: any) => ({...prevState, e: 5 }));
    });

    expect(result.current.globalInteractiveState).toEqual({a: 1, c: 3, d: 4, e: 5});
    expect(getClient().managedState.globalInteractiveState).toEqual({a: 1, c: 3, d: 4, e: 5});
  });

  it("ensures that global interactive state is always observed correctly", async () => {
    const TestComponent: React.FC = () => {
      const { globalInteractiveState } = hooks.useGlobalInteractiveState<any>();
      const managedStateUpdated = React.useRef(false);

      if (!managedStateUpdated.current) {
        // Immediate update of globalInteractiveState. In real life it wouldn't be done in the component obviously,
        // but that's an easy way to ensure problematic timing - after initial render of the useGlobalInteractiveState
        // hook, but before useEffect defined in useGlobalInteractiveState is called. This is regression test related
        // to this issue: https://www.pivotaltracker.com/story/show/174154314
        getClient().managedState.globalInteractiveState = "new state 123";
        // Prevent infinite loop, update authoredState just once.
        managedStateUpdated.current = true;
      }
      return <div>{ globalInteractiveState }</div>;
    };

    const { container } = render(<TestComponent />);
    expect(container.textContent).toEqual("new state 123");
  });
});

describe("useCustomMessages", () => {

  it("does something", () => {
    const handler = jest.fn();
    renderHook(() => hooks.useCustomMessages(handler, { foo: true }));
    getClient().setSupportedFeatures({ apiVersion: 1, features: {} });
  });

});

describe("useReportItem", () => {
  it("lets client set answer handler and metadata", async () => {
    const handler = jest.fn();
    const metadata: any = { foo: 1 };

    renderHook(() => hooks.useReportItem<any, any>({ handler, metadata }));

    expect(mockedPhone.messages[0]).toEqual({ type: "reportItemClientReady", content: metadata });

    const request = { bar: 1 };
    mockedPhone.fakeServerMessage({
      type: "getReportItemAnswer",
      content: request
    });
    expect(handler).toHaveBeenCalledWith(request);
  });
});

describe("useAccessibility", () => {
  it("returns the accessibility settings from the initMessage", async () => {
    const accessibility: IAccessibilitySettings = {
      fontSize: "large",
      fontSizeInPx: 22,
      fontType: "notebook",
      fontFamilyForType: "test-font-family, fallback-font-family"
    };

    const { waitForNextUpdate } = renderHook(() => hooks.useInitMessage());
    const { result } = renderHook(() => hooks.useAccessibility({
      updateHtmlFontSize: true,
      addBodyClass: true,
      fontFamilySelector: "body",
    }));

    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "runtime", accessibility }
      });
    }, 10);

    // the DOM should not have font updates yet
    const beforeHtml = document.getElementsByTagName("html").item(0);
    const beforeBody = document.getElementsByTagName("body").item(0);
    const beforeStyle = document.getElementsByTagName("style").item(0);
    expect(beforeHtml?.style.getPropertyValue("font-size")).toEqual("");
    expect(beforeBody?.classList.toString()).toBe("");
    expect(beforeStyle).toEqual(null);

    await waitForNextUpdate();
    expect(result.current).toEqual(accessibility);

    // the DOM should now have font updates
    const afterHtml = document.getElementsByTagName("html").item(0);
    const afterBody = document.getElementsByTagName("body").item(0);
    const afterStyle = document.getElementsByTagName("style").item(0);
    expect(afterHtml?.style.getPropertyValue("font-size")).toEqual("22px");
    expect(afterBody?.classList.toString()).toBe("font-size-large font-type-notebook");
    expect((afterStyle?.sheet?.cssRules[0] as CSSPageRule).selectorText).toEqual("body");
    expect((afterStyle?.sheet?.cssRules[0] as CSSPageRule).style["font-family" as any]).toEqual("test-font-family, fallback-font-family");
  });
});