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; }); };
素晴らしい、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); }); });
node-canvas
と依存ライブラリーのインストールが必要になるので、実はこのコードをコピペしただけでは動かないのですが2、公式ドキュメントを見れば多分なんとかなります。
テストの中身は別問題
これで一応テストは書けるんですが、実際にどういうテストを書くかというと… 正直すこしも思いつかなかったので「文字列としてちょっと短くなっているか」みたいな雑極まりないテスト1件で済ませています。
base64文字列ではなく image
を返して image.width
image.height
検証するくらいなら思いつきますね。ただ、ここで欲しいのはあくまでエンコードされた文字列なので、その変更はあんまりやりたくない感じです。
「見た感じちゃんとリサイズされてるじゃん!」というテストを書くのは(私には)無理そうなので、まあ当面これでオッケーということにします。解決。
【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=" // 長すぎ中略 ); }); });
今回はテストファイルと同じ場所にサンプル画像を置いています。こんなやつです。
再エンコードされていて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でエンコードされたファイルの内容です。
つまり、こうです2。[buffer.buffer]
として配列にする必要があるので注意してください。配列の配列ってなんかワクワクしますよね。
const buffer = fs.readFileSync(path); new File([buffer.buffer], "test-neko.png", { type: "image/png" });
あとはこれを渡してやればテスト可能です。最初は FileReader
からモックにする必要があるかと思ったのですが、特にそんなことはありませんでした。 File
だけ作って渡してやればいいみたいです。
割と簡単にFile API利用箇所をテスト可能になりました。めでたしめでたし3。
【日報】型なしvuexつらい
お休み… みたいなものなのですが、9月13日の日報です。
型なしvuexつらい問題
つらいのかな?と思いつつ適当に書き進めたところ「実際つらい」という認識に至りましたのでご報告します。
リファクタリングつらい
vuex公式の型定義を利用し、その仕組みに乗れば、ステートだけは割ときっちり型で管理できるようになります。
当初、それだけでまあまあ回るのではと思った(そして実際なんとかなった)のですが、一旦書いたコードを直す段階になって、なかなか困る点も出てきました。全面的に困るのですが、最も困りそうなのが「commit
/ dispatch
/ getters
の型定義がないと、コンパイルもテストも失敗しない」という部分です。
コンパイルについては単純なことで、「dispatch
最初の引数は文字列なら何でもオッケー」という型定義しかなければ dispatch("hoge")
だろうが dispatch("fuga")
だろうが通ってしまうわけです。そんなアクションないぞ!みたいなエラーは実行するまで起こりません。大変に困りますね。
またテストに関していえば、モックが旧実装を隠してしまうことがあります。つまりユニットテストを書く場合、テスト対象からさらに呼び出される dispatch
なり commit
なり、そういったものはモックで動作を再現しているかもしれません。そうすると例えば、実際には削除されているにも関わらず、actions
の中では存在しなくなった動作を呼び出す実装のままであり、おまけにテスト用モックも古いままなのでエラーにならない、みたいなことが起こってきます。ドチャクソ困りますね。
せめてコンパイルかテストか、どちらか1つでも落ちれば人間が気づいて直せるわけですが、両方スムーズに通ってしまうと厳しい。そして可能ならコンパイルで落ちてほしい1。そういう次第なので、vuexに型定義をきっちり組み入れる方向で学習追加中です。
-
実行するまで分からないテストの失敗と、IDEが自動検出して赤線まで引いてくれるコンパイルエラーでは、後者の方が生産性が高い↩
【日報】oyasumi
9月12日はお休みです。elm学習中…
【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つ以上のタグを設定してアップロードします。
そしてタグで検索して使う。簡単ですね。
基本的な仕組みとしては、firebaseに依存し、ログインしたユーザーが自分専用の画像データを追加・検索できる、非公開型のアップローダー(要するにクラウドストレージ)になると思います。各自が勝手にfirebaseプロジェクトを作り、アプリをホストしてくれると一番いいです。
そういう次第なので、タグボトルの一部として「大人数が利用できるアップローダー」のようなものを提供する計画はありません。著作権の問題が面倒なので、個人で勝手にやってほしいということです。公衆送信をしなければ割と自由が効くという本邦著作権法の問題でもありますが。
今後の作業
タグボトル本体にも厳しいバグがいくつかあるので、そちらを対応しつつ、何日かはこのアップローダーを作り込んでいきます。
【日報】今日もおやすみ
9月10日の作業はおやすみでした。休みという割には結構書いてる気もしますが、気のせいです。