Canvas依存処理をテストする

 9月15日の日報です。今日もなんかnode弄って終わりましたね…

Canvas依存処理をテストする

「クライアント側で画像をリサイズして、特定サイズのサムネイルを作りたい」という思いがあるとして、コードはこんな感じになると思います1

 なお「それはサーバー側でやれよ」みたいな話はなかったものとします。

export interface IImageSize {
  width: number;
  height: number;
}

export const resizeImage = (
  base64: string,
  size: IImageSize,
  options = {
    createImage: Image,
    createCanvas: () => document.createElement("canvas")
  }
): Promise<string> => {
  // return result as promise
  return new Promise<string>((resolve, reject) => {
    // create canvas object
    const canvas = options.createCanvas() as HTMLCanvasElement;

    // create image object
    const image = new options.createImage();

    // set event to resize
    image.onload = function() {
      // create required objects
      const context = canvas.getContext("2d");
      if (!context) {
        return reject(
          new Error("resizeImage failed: canvas context is blank")
        );
      }

      // set canvas size
      canvas.height = size.height;
      canvas.width = size.width;

      // resize
      context.drawImage(
        image,
        0,
        0,
        image.width,
        image.height,
        0,
        0,
        canvas.width,
        canvas.height
      );

      resolve(canvas.toDataURL());
    };

    image.onerror = function(error: ErrorEvent) {
      reject(error);
    };

    // set image data
    image.src = base64;
  });
};

gitlab

 素晴らしい、Canvas Image ごり押しで無理やり変換できましたね! コピペ元はこちらで、最初こんな感じでしたがちょいちょい修正して終わり。

 ただどう見てもブラウザー実装依存なので、node.jsでテストできなさそうな感じですが、引数 options で色々誤魔化しているので大丈夫です(説明放棄)

import { IImageSize, resizeImage } from "~/store/uploader";
import fs from "fs";
import NodeCanvas from "canvas";

const getSampleBuffer = (): Buffer => {
  const path = `${__dirname}/test-neko.png`;
  return fs.readFileSync(path);
};

describe("resizeImage", () => {
  let base64: string;
  let size: IImageSize;

  beforeEach(() => {
    // get base64 string from sample image file
    base64 = `data:image/png;base64,${getSampleBuffer().toString("base64")}`;

    // set image size
    // original: 70 x 125
    size = { height: 56, width: 100 };
  });

  it("returns base64", async () => {
    const result = await resizeImage(base64, size, {
      createImage: NodeCanvas.Image,
      createCanvas: () => new NodeCanvas()
    });
    expect(result).toMatch(/data:image\/png;.+/);
  });

  it("returns resized data", async () => {
    const result = await resizeImage(base64, size, {
      createImage: NodeCanvas.Image,
      createCanvas: () => new NodeCanvas()
    });
    console.log(result)
    expect(result.length).toBeLessThan(base64.length * 0.85);
  });
});

gitlab

 node-canvas と依存ライブラリーのインストールが必要になるので、実はこのコードをコピペしただけでは動かないのですが2公式ドキュメントを見れば多分なんとかなります。

テストの中身は別問題

 これで一応テストは書けるんですが、実際にどういうテストを書くかというと… 正直すこしも思いつかなかったので「文字列としてちょっと短くなっているか」みたいな雑極まりないテスト1件で済ませています。

 base64文字列ではなく image を返して image.width image.height 検証するくらいなら思いつきますね。ただ、ここで欲しいのはあくまでエンコードされた文字列なので、その変更はあんまりやりたくない感じです。

 「見た感じちゃんとリサイズされてるじゃん!」というテストを書くのは(私には)無理そうなので、まあ当面これでオッケーということにします。解決。


  1. 変換すべきサイズはこんな感じで適当にそれっぽく計算しておく

  2. と言いつつ対応を忘れていたのでCI落ちました

【qp】File API利用箇所をjestでテストする

 9月14日の日報です。今日はしんどかった。

File API 利用箇所のテストを書く

 いま仮に、こんなコードがあるとしてですね。

/**
 * read an image file and return contents
 * @param {File} file a file object
 * @return {Promise<string>} base64 data
 */
export const readFile = async (file: File): Promise<string> => {
  // create reader
  const reader = new FileReader();

  // return promise
  return new Promise<string>((resolve, reject) => {
    // event handler
    reader.onload = (event: ProgressEvent) => {
      // get result
      const target = event.target as FileReader | null;

      // reject if target is blank
      if (!target) {
        return reject(new Error("readFile error: event.target is blank"));
      }

      // return result
      const result = target.result as string;
      resolve(result);
    };

    // read as a data url
    reader.readAsDataURL(file);
  });
};

 テストはどう書くの? というと、こう書きます。v10で実行しているので他だと動かないかも。

import { readFile } from "~/store/uploader";
import fs from "fs";

const getSampleBuffer = (): Buffer => {
  const path = `${__dirname}/test-neko.png`;
  return fs.readFileSync(path);
};

describe("readFile", () => {
  let file: File;

  beforeEach(() => {
    // create file object
    file = new File([getSampleBuffer().buffer], "test-neko.png", {
      type: "image/png"
    });
  });

  it("returns data string (base64 format) which file-reader returns", async () => {
    const result = await readFile(file);
    expect(result).toEqual(
      "data:image/png;base64,iVBOR...5CYII=" // 長すぎ中略
    );
  });
});

 今回はテストファイルと同じ場所にサンプル画像を置いています。こんなやつです。

f:id:tottokotkd:20180914233226p:plain

 再エンコードされていてbase64の値は一致しないかもしれませんが、まあその辺は適当に。

何故これで動くのか

 知らんって感じなのですが、いくつかポイントはあります。

 まず node.js Buffer が Uint8Array を継承していることです (詳しくはここ)1

 そしてUint8Arrayには buffer という読取専用プロパティがあり、ここから ArrayBuffer を取得できます。

Uint8Array.prototype.buffer [読取専用]
Uint8Array オブジェクトによって参照されるArrayBufferを返します。構築時に設定され、読取専用となります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array

 このプロパティはもちろん fs で取得した Buffer にもあります。つまり buffer.buffer です。なんか分かりにくいですね… まあいいけど。

 ともかく、ここから ArrayBuffer が手に入ります。そしてこれがそのままFileコンストラクタで使えます。

var myFile = new File(bits, name[, options]);

bits
ArrayBuffer、ArrayBufferView、Blob、DOMString オブジェクト、もしくはこれらが混合したArrayです。これはUTF-8でエンコードされたファイルの内容です。

https://developer.mozilla.org/ja/docs/Web/API/File/File

 つまり、こうです2[buffer.buffer] として配列にする必要があるので注意してください。配列の配列ってなんかワクワクしますよね。

const buffer = fs.readFileSync(path);
new File([buffer.buffer], "test-neko.png", { type: "image/png" });

 あとはこれを渡してやればテスト可能です。最初は FileReader からモックにする必要があるかと思ったのですが、特にそんなことはありませんでした。 File だけ作って渡してやればいいみたいです。

 割と簡単にFile API利用箇所をテスト可能になりました。めでたしめでたし3


  1. v8以前は NodeBuffer を経由していますが、まあ多分きっと大体同じです。

  2. 実際には buffer.buffer ではなく buffer を渡しても(この場合は)動きます。

  3. 調べるのは半日かかりましたが完成したのでセーフ

【日報】型なしvuexつらい

 お休み… みたいなものなのですが、9月13日の日報です。

型なしvuexつらい問題

 つらいのかな?と思いつつ適当に書き進めたところ「実際つらい」という認識に至りましたのでご報告します。

リファクタリングつらい

 vuex公式の型定義を利用し、その仕組みに乗れば、ステートだけは割ときっちり型で管理できるようになります。

 当初、それだけでまあまあ回るのではと思った(そして実際なんとかなった)のですが、一旦書いたコードを直す段階になって、なかなか困る点も出てきました。全面的に困るのですが、最も困りそうなのが「commit / dispatch / gettersの型定義がないと、コンパイルもテストも失敗しない」という部分です。

 コンパイルについては単純なことで、「dispatch 最初の引数は文字列なら何でもオッケー」という型定義しかなければ dispatch("hoge") だろうが dispatch("fuga") だろうが通ってしまうわけです。そんなアクションないぞ!みたいなエラーは実行するまで起こりません。大変に困りますね。

 またテストに関していえば、モックが旧実装を隠してしまうことがあります。つまりユニットテストを書く場合、テスト対象からさらに呼び出される dispatch なり commit なり、そういったものはモックで動作を再現しているかもしれません。そうすると例えば、実際には削除されているにも関わらず、actions の中では存在しなくなった動作を呼び出す実装のままであり、おまけにテスト用モックも古いままなのでエラーにならない、みたいなことが起こってきます。ドチャクソ困りますね。

 せめてコンパイルかテストか、どちらか1つでも落ちれば人間が気づいて直せるわけですが、両方スムーズに通ってしまうと厳しい。そして可能ならコンパイルで落ちてほしい1。そういう次第なので、vuexに型定義をきっちり組み入れる方向で学習追加中です。


  1. 実行するまで分からないテストの失敗と、IDEが自動検出して赤線まで引いてくれるコンパイルエラーでは、後者の方が生産性が高い

【qp】画像をアップロードできないバグを修正

 9月11日の日報です。qpはquelplanの略か何かです。

firestoreクエリで array-contains は1回のみ

 オレオレ画像アップローダー quelplan には「複数タグによる絞り込みをfirestore完結で行う」という要件があります。そういう機能はRDBなりLucene系なりでやれという話はあると思うんですが、まあそれは実験的プロジェクトいうことで進めています。

 さて、この機能を実現するにあたって、firestoreクエリに新しく追加された array-contains を利用しようと考えていました。タグ文字列を配列で持たせて、クエリで取ってこようということです。簡単そうですよね。

 ところが実際に動かしてみると、タグ1つの時はいいのですが、2つだと落ちます。どうやら array-contains 複数回使うことができず、クエリ1つにつき1回しか使えない… みたいです。ドキュメントが見つからないのですが、SDKのテストにあるので、そういう仕様なんだろうなあという感じですね。

validationIt(
  persistence,
  'with multiple array-contains filters fail.',
  db => {
    expect(() =>
      db
        .collection('test')
        .where('foo', 'array-contains', 1)
        .where('foo', 'array-contains', 2)
    ).to.throw(
      'Invalid query. Queries only support a single array-contains filter.'
    );
  }
);

 仕方がないので、古式ゆかしいオブジェクト形式に戻しました。

# create
const tags: { [key: string]: boolean } = {};
for (const tag of props.tags) {
  tags[tag] = true;
}
await db
  .collection("files")
  .doc("users")
  .collection(this.$firebase.auth().currentUser.uid)
  .add({
    name: props.name,
    path: props.path,
    tags,
    createdAt: firebase.firestore.FieldValue.serverTimestamp(),
    lastUpdatedAt: firebase.firestore.FieldValue.serverTimestamp()
  });

# search
let query: firebase.firestore.Query = db
  .collection("files")
  .doc("users")
  .collection(this.$firebase.auth().currentUser.uid)
  .limit(100);

// search by tags
for (const tag of state.tags) {
  query = query.where(`tags.${tag}`, "==", true);
}

 ドキュメント欲しいですね… あるのかもしれませんが、見つからないのでつらい。

 ちなみに、firebaseの公式日本語ドキュメントは読まない方がいいです。英語版には書いてある情報が、日本語版では項目ごとなかったりします。array-contains もまだないかも?

【日報】オレオレ画像ストレージ

 9月11日の日報ー。

画像アップローダー

 タグボトルには画像投稿の機能がありません。そこで画像を貼るには「とりあえずimgurに置く」というような方法を取っているのですが、非常に面倒なので、画像アップローダーを作り始めました。

 画像に1つ以上のタグを設定してアップロードします。

f:id:tottokotkd:20180911002054p:plain

 そしてタグで検索して使う。簡単ですね。

f:id:tottokotkd:20180911002126p:plain

 基本的な仕組みとしては、firebaseに依存し、ログインしたユーザーが自分専用の画像データを追加・検索できる、非公開型のアップローダー(要するにクラウドストレージ)になると思います。各自が勝手にfirebaseプロジェクトを作り、アプリをホストしてくれると一番いいです。

 そういう次第なので、タグボトルの一部として「大人数が利用できるアップローダー」のようなものを提供する計画はありません。著作権の問題が面倒なので、個人で勝手にやってほしいということです。公衆送信をしなければ割と自由が効くという本邦著作権法の問題でもありますが。

今後の作業

 タグボトル本体にも厳しいバグがいくつかあるので、そちらを対応しつつ、何日かはこのアップローダーを作り込んでいきます。