kokh log

主にフロントエンドの備忘録

複数行のテキストでも下線追従アニメーション

小ネタです。
ホバー時に下線が左から右に流れるような追従アニメーション、今までafter疑似要素のscaleをtransitionする形で実装していたのですが、
この方法だと複数行対応ができないという欠点がありました。

  • テキストをinlineブロックで囲う
  • background-image で下線の色を指定し、background-size をtransitionさせる

これだけで、複数行にも対応させることができました。
inline要素は改行されたとしてもbackground-size-widthが変わらないというのは意外と盲点でした。

.inline {
  background-image: linear-gradient(to bottom, transparent, #4aed2b);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 10px;
  transition: background .3s ease-out;
  cursor: pointer;
}

.outside:hover .inline {
  background-size: 100% 10px;
}

Demo

See the Pen Untitled by Yumiko Kokubu (@yumikokh) on CodePen.

React Testing Libraryの勉強メモ

React Testing Libraryについて勉強したので備忘録です。 ソースコードこちら

テストの種類

Unit Test, Integration Testの手法 → Jest + React Testing Library(EnzymeよりHooksとの相性がいい)

Jest

  • npm test
  • test case
  • expect

React Testing Library

  • render
  • userEvent
  • custom hook

CLI

Test Suites: テストが書かれたファイルの数
Tests: 書かれたテストの数

テストの名前を表示させるには
"test": "react-scripts test --env=jsdom --verbose"

UserEvent Test

it("Should trigger output function", async () => {
    const outputConsole = jest.fn();
    render(<RenderInput outputConsole={outputConsole} />);
    const inputValue = screen.getByPlaceholderText("Enter");
    await userEvent.type(inputValue, "test");
    await userEvent.click(screen.getByRole("button"));
    expect(outputConsole).toHaveBeenCalledTimes(1);
  });
  • jest.fn() でダミーのprops関数を定義し、その関数が何回呼ばれたかを評価する
  • useEvent はPromiseを返す(React18〜)
  • getByRole で使うroleは明示的に定義していなければhtmlタグとの相関はこのようになっている

API Mock Test

import { rest } from "msw";
import { setupServer } from "msw/node";
import MockServer from "./MockServer";

// 代用されるAPIの定義
const server = setupServer(
  rest.get("https://jsonplaceholder.typicode.com/users/1", (req, res, ctx) => {
    return res(ctx.status(200), ctx.json({ username: "Bred dummy " }));
  })
);

// テストファイルの最初に1回実行される
beforeAll(() => server.listen());
// テストケースが終わる度に実行される
afterEach(() => {
  server.resetHandlers();
});
// テストファイルの最後に1回実行される
afterAll(() => {
  server.close();
});

describe("Mocking API", () => {
  it("[Fetch Success] データが正しくフェッチされてボタンがdisabledになる", async () => {
    render(<MockServer />);
    await userEvent.click(screen.getByRole("button"));
    expect(await screen.findByRole("heading")).toHaveTextContent("Bred dummy");
    expect(screen.getByRole("button")).toHaveAttribute("disabled");
  });

  it("[Fetch failure] エラーメッセージが表示されボタンが有効化される", async () => {
    // レスポンスを書き換える、このitの中でのみ有効
    server.use(
      rest.get(
        "https://jsonplaceholder.typicode.com/users/1",
        (req, res, ctx) => {
          return res(ctx.status(404));
        }
      )
    );
    render(<MockServer />);
    await userEvent.click(screen.getByRole("button"));
    expect(await screen.findByTestId("error")).toHaveTextContent(
      "Fetching Failed!"
    );
    expect(screen.queryByRole("heading")).toBeNull();
    expect(screen.getByRole("button")).not.toHaveAttribute("disabled");
  });
});
  • getBy, queryBy, findBy
    • 通常はgetBy
    • getByは対象がないとエラーになるため、対象が 存在しない ことを評価するためには queryBy
    • 非同期で対象が見つかるまで待機する場合はPromiseを返す findBy

Reducer Test

describe("increment action", () => {
    let initialState = {
      mode: 0,
      value: 1,
    };
    it("Should increment by 1 with mode 0", () => {
      const action = { type: increment.type };
      const state = reducer(initialState, action);
      expect(state.value).toEqual(2);
    });
    it("Should increment by 100 with mode 1", () => {
      initialState = {
        mode: 1,
        value: 1,
      };
      const action = { type: increment.type };
      const state = reducer(initialState, action);
      expect(state.value).toEqual(101);
    });
  });

Extra Reducer Test

const initialState = {
    mode: 0,
    value: 0,
  };
  it("Should output 100 + payload when fulfilled", () => {
    const action = { type: fetchDummy.fulfilled.type, payload: 5 };
    const state = reducer(initialState, action);
    expect(state.value).toEqual(105);
  });

Integration Test

let store;
  beforeEach(() => {
    store = configureStore({
      reducer: {
        customCounter: customCounterSlice,
      },
    });
  });

it("Should display value with incrementByAmount", async () => {
    render(
      <Provider store={store}>
        <Redux />
      </Provider>
    );
    await userEvent.type(screen.getByPlaceholderText("Enter"), "30");
    await userEvent.click(screen.getByText("Increment By Amount"));
    expect(screen.getByTestId("count-value")).toHaveTextContent(30);
  });

CustomHook Test

it("Should increment by 1", () => {
    const { result } = renderHook(() => useCounter(3));
    expect(result.current.count).toBe(3);
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(4);
  });
  • renderHook はコンポーネント外でhookを実行できるようにするための関数
  • 内部でstateに変更を加える等のふるまいがある場合は act内で実行する

ふむふむ

Next.jsでmp3をimportする

前回はfile-loaderで動画メディアを変換する方法について書きましたが、
今回はmp3ファイルをimportします。

next.config.js のwebpack設定に以下を加えます。

const nextConfig = {
  ...
  webpack(config, options) {
    config.module.rules.push({
      test: /\.mp3$/,
      use: {
        loader: "url-loader",
        options: {
          limit: 8192,
        },
      },
    });
    return config;
  },
};

url-loader は対象のファイルをbase64形式のDataURLへ変換し、js内にバンドルします。
limit オプションに数字か文字列を指定すると、その値を最大バイトとしてそれ以上のサイズのファイルは、フォールバックとしてfile-loaderで変換されます。 (file-loaderはurl-loaderの初期値のフォールバック先です)
file-loader は url-loader を異なり、ファイルをコピーし、import先ではそのURLを返します。
大きなファイルはDataURLへ変換するとバンドルサイズが肥大化するため、limit オプションを加えておきましょう。

ビルドに含めないローカルファイルをレンダラーに取り込む

Electron内で使用する頻繁に更新されるデータ(例えばjsonファイル)を
アプリのビルドをせずに、対象のファイルの差し替えで済むようにしました。

フローとしては、レンダラープロセスが読み込む準備ができたら、メインプロセスへリクエストを送り、メインプロセスでローカルファイルのデータを返します。 レンダラープロセスとメインプロセスの双方向通信には ipcMain.handleipcRenderer.invoke を使います。

例えば、Mac用にビルドされるアプリと同じ階層にdata.jsonを設置し、そのデータをアプリ内に取り込む場合はこのようになります。

メインプロセスでは ipcMain.handle でイベントのコールバックを設定します。

app.on("ready", async () => {
 ...
 ipcMain.handle("load:data", (_event) => {
    const data = fs.readFileSync(join(app.getAppPath(), "../../../../data.json"), {
          encoding: "utf-8",
        });

    return data;
  });
 
})

app.getAppPath() は現在のアプリケーションのディレクトリを返すメソッドです。 MacWindowsで階層が異なるので、Win用が必要であれば出しわけが必要です。

レンダラープロセスは ipcRenderer.invoke でイベントを呼び出し、先程メインプロセスで指定したコールバックの返り値を受け取ります。

useEffect(() => {
    async function getData() {
      try {
        const data = await global.ipcRenderer.invoke("load:data");
        console.log(JSON.parse(data));
      } catch (e) {
        console.error(e);
      }
    }
    getData();
  }, []);

global.ipcRendererpreload.ts で定義しています。

参考

www.electronjs.org

zenn.dev

Next.jsでvideoをimportする

publicディレクトリが使えないという制約があり、
Next.jsのコンポーネント内でmp4といった動画メディアをimportする必要がありました。
初期値は動画メディア用のloaderが設定されていないため、
import MOVIE from "../assets/movie.mp4" のようにimportすることができません。

解決策

next-videos - npm を利用します。 内部的にはfile-loaderで変換されているようです。

next.config.js を以下のように書き換えます。

// next.config.js

const withVideos = require("next-videos");

const nextConfig = {
 ...
};

module.exports = withVideos(nextConfig);

型定義で怒られたので定義します。

declare module "*.mp4" {
  const src: string;
  export default src;
}

これでvideo要素をimportし表示させることできるようになりました。

import Movie from "../assets/title.mp4";

export function Movie() {
  return  <video src={Movie} />
}