camelotで点線を実線として処理する

はじめに

camelotではパラメータの調整だけでは点線を含むテーブルの処理が上手く動作しません。

たとえば、以下のようなPDFがそれにあたります。
➀縦の点線
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

②横の点線
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

この問題は下記のIssueとして挙げられていますが、解決されていないようです。

Detect dotted line #370
https://github.com/atlanhq/camelot/issues/370

対応方法

Camelotのテーブル認識の仕組み

対応方法の説明の前にCamelotがどのようにテーブルを認識しているか説明します。
Camelotにはテーブルの解析の方法に2種類存在します。1つはStreamでもう1つはLatticeです。
デフォルトではLatticeで解析しています。

Latticeではhostscriptを使用してPDFページを画像に変換し、次に、OpenCVを使用して線とその交点を求めます。
そしてそれらの座標を元に、テーブルのセルに割り当てていきます。

対応方法

Latticeでは一旦、画像に変換しているわけですから、その画像において点線を実線に置き換えればよいです。

縦の点線を実線に変換する場合は、縦方向にdilateとerodeを行います。
横の点線を実線に変換する場合は、横方向にdilateとerodeを行います。
こうやって変更した画像をcamelotの線分を見つける処理に渡せばいいのです。

具体的な実装例

まずLatticeをベースクラスとしたLatticeExを用意します。
https://github.com/mima3/yakusyopdf/blob/master/camelot_ex.py

これを以下のように使用します

import camelot_ex
import camelot
import os
import cv2
import numpy

from camelot.utils import (
    TemporaryDirectory,
)

def image_proc_tate_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[:, 1] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def image_proc_yoko_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[2, :] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def parse(filepath, pages, password=None, suppress_stdout=False, layout_kwargs={}, **kwargs):
    handler = camelot.handlers.PDFHandler(filepath)
    handler_pages = handler._get_pages(filepath, pages)

    tables = []
    with TemporaryDirectory() as tempdir:
        for p in handler_pages:
            handler._save_page(filepath, p, tempdir)
        tmp_pages = [
            os.path.join(tempdir, "page-{0}.pdf".format(p)) for p in handler.pages
        ]
        parser = camelot_ex.LatticeEx(
            **kwargs
        )
        for p in tmp_pages:
            t = parser.extract_tables(
                p,
                suppress_stdout=suppress_stdout,
                layout_kwargs=layout_kwargs
            )
            tables.extend(t)

        return tables

# 縦の点線を含むPDF
ret = parse(
     'test.pdf', 
     'all', 
     line_scale=60,
     image_proc = image_proc_tate_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

# 横の点線を含むPDF
ret = parse(
     '20200502/兵庫県.pdf', 
     '1', 
     layout_kwargs={
         'char_margin': 0.25
     },
     line_scale=60,
     copy_text=['v'],
     image_proc = image_proc_yoko_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

参考

厚生労働省のPDFをCSVやJSONに変換する

目的

政府がオープンデータを叫び出して何年かが過ぎましたが、多くの政府が公開するデータはPDFベースになっています。
さすがにひと昔前のように紙をスキャンしただけのデータではなくなりましたがCSVやJSONなどの機械的に処理を行うのが楽であるとは言い難い現状です。

今回は下記のページに公開されているPDFを処理しやすい形式(CSV,JSON)に変換する実験を行います。
 

新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について

https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html

このページのPDFは以下の性質をもっています。

  • 文字情報をもっている。(画像ではない)
  • 日々更新される可能性がある
  • 県によってはフォーマットが異なる可能性がある

実験環境

  • Windows10 64bit
  • Python 3.7.5 (tags/v3.7.5:5c02a39a0b, Oct 15 2019, 00:11:34) [MSC v.1916 64 bit (AMD64)]
  • Java8
  • Office365

PDFからテーブルを取得する方法

まずPDFからデータを取得する方法を考えてみます。
前述した実験環境においてPDFからテーブルの情報を取得するには3つの方法があります。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する

PDFをWORDで開いたのち、WORDのテーブルをExcelに張り付けることで処理しやすい形式に変換することが可能です。

この方法は、お手軽で少ない少ない件数のPDFを処理する場合において有効ですが、失敗するケースがあります。

たとえば、下記のPDFをダウンロードしてWORDで開いてみます。
https://www.mhlw.go.jp/content/000625002.pdf

下記のようなエラーが発生します。

このPDFを開くときに問題が発生しました。Wordによってサポートされている最大ページ数を超えています。

このエラーメッセージはPDFの用紙サイズがWord文書の最大用紙サイズである558.7mm×558.7mmを超えている場合に発生します。

tabulaを使用する

tabula-javaはJavaで実装されており、PDFからテーブルの情報を抜き出すことが可能です。また、tabula-pyを使用することでPythonからtabula-javaを使用することが可能になります。

GUIでtabulaを使用する。

まずJava7以降の実行環境を構築してください。

次に下記のページから環境にあったファイルをダウンロードしてください。今回はWindowsで実行します。
https://tabula.technology/

解凍したexeを起動することでwebサーバーが起動して、ブラウザ経由でPDFの解析処理が可能になります。

ただし、exeを普通に起動するだけでは以下のようなエラーが発生します。

(ArgumentError) invalid byte sequence in Windows-31J

そのため以下のようなバッチファイルを記述して、バッチファイル経由で起動する必要があります。

set RUBYOPT=-EUTF-8
tabula.exe

あとは、ブラウザを経由してPDFをアップロードし、PDF中のテーブルのエクスポートが可能になります。

参考:
http://deathon2legs.blogspot.com/2018/08/tabulapdf.html

Pythonでtabulaを使用する

tabula-pyを使用することでPythonでtabulaが使用できます。

tabula-pyを使用するにはPython3.5+,Java 8+の環境が必要になります。

インストール方法や必要要件は下記を参照してください。
https://tabula-py.readthedocs.io/en/latest/getting_started.html

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import tabula

# 当該ページは日々更新されるため、下記のリンクから最新のURLを取得してください
# https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html
df_list = tabula.read_pdf("https://www.mhlw.go.jp/content/000626082.pdf", pages='all', lattice=True)
for df in df_list:
    for ix in df.index.values:
        print(df.loc[ix][1])

制限

PDFの複雑さによっては、表の内容の正確さを抽出することが難しい場合があります。すくなくとも新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてで公開されているPDFのいくつは正確に抽出することはできませんでした。

camelotを使用する

camelotはPDFの表をより正確に抽出可能になっています。

camelotはghostscriptを使用しているのでWindowsの場合、以下からインストールを行う必要があります。
https://www.ghostscript.com/download/gsdnld.html

依存関係の詳細は以下を参照してください。
https://camelot-py.readthedocs.io/en/master/user/install-deps.html

インストール方法については下記を参照してください。今回はsource codeを使用してインストールしました。
https://camelot-py.readthedocs.io/en/master/user/install.html

なお、camelotはtabula-pyにくらべメモリを大量に使用する傾向があるので64bitのプロセスで実行することをお勧めします。すくなくとも東方の32bit環境では愛知県のデータは取得できませんでした。

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import camelot
tables = camelot.read_pdf(
  'https://www.mhlw.go.jp/content/000626082.pdf',
  pages = 'all',      # ページによってはメモリ使用量が跳ね上がるので1ページづつ解析した方がよい
  line_scale = 30,   # 数が大きいほど細い線でも認識するようになるが、大きすぎると文字を線とみなす デフォルト15
  copy_text=['v'],      # 空のデータを取得した場合に縦方向からデータをコピーする。表を結合している場合に有効
  layout_kwargs = {
    'char_margin': 0.25 # 2つの文字がこのマージンよりも接近している場合、それらは同じ行の一部と見なされます。 デフォルト:2
  }
)
for tbl in tables:
  for ix in tbl.df.index.values:
    print(tbl.df.loc[ix][1])

各方法の比較

PDFからテーブルを抽出する方法として以下の3つを比較します。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する方法は、お手軽に行えるメリットがありますが、今回使用するには問題があります。
まず、当該PDFは日々更新されています。そのため手動で行う処理が辛いです。仮にこの問題をマクロ等でカバーできたとしても、WORDの最大用紙サイズの制限があるため、完全に今回のデータを処理するには適さない方法です。

tabulaとcamelotについては精度の話を抜きにした場合、今回のPDFを機械的に読み取ることが可能です。
処理速度と使用メモリについてはtabulaが有利です。
逆に精度についてはcamelotが有利です。
camelotはいくつかのパラメータでチューニングが可能になっています。

主なパラメータの説明
https://camelot-py.readthedocs.io/en/master/api.html#main-interface

camelotはPDFMinerの機能を元に作成されており、PDFMinerで調整できる項目についてはlayout_kwargsのパラメータで指定できます。この項目についてはPDFMinerのドキュメントで確認できます。

https://euske.github.io/pdfminer/

(2020/05/03追記)
Camelotでは点線を含むPDFの処理が上手く動作しません。この対応については以下の通りになります。
https://needtec.sakura.ne.jp/wod07672/2020/05/03/camelot%e3%81%a7%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%a8%e3%81%97%e3%81%a6%e5%87%a6%e7%90%86%e3%81%99%e3%82%8b/

「新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について」のページを解析する

ここまででPDFからテーブル情報を取得する基本的な方法について説明しました。
しかしながら、実際、抽出するにはいくつか問題点があります。

問題点

対応医療機関リストのPDFは日々更新され、そのURLも変わる

対応医療機関リストのPDFは日々更新されますが、そのURLは更新のたびに変更されるようです。
例えば東京都のURLは以下のようになります。

2020/04/28時点
https://www.mhlw.go.jp/content/000625693.pdf

2020/04/29時点
https://www.mhlw.go.jp/content/000626106.pdf

このため、PDFのURLは新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのリンクから取得する必要があります。

凡例の有無

データの1行目に凡例が入っている場合とない場合があります。
東京都の場合は凡例がありますが、北海道の場合はありません。

つまり県ごとに1ページ目のデータ行の取得位置を調整する必要があります。

各県ごとにページヘッダの取り扱いが異なる。

たとえば、北海道と茨城県のPDFを比較してみてください。
北海道は2ページ目以降にヘッダが含まれていますが、茨城県には含まれません。

つまり県ごとに2ページ目以降のデータ行の取得位置を調整する必要があります。

各県ごとに項目の取り扱いが異なる。

たとえば、北海道と愛知県を比較してみてください。
愛知県には所管保健所の列がありますが、北海道には存在しません。

つまり県ごとに必要な列の位置を調整する必要があります。

複数ページにまたがるデータ

以下は2020/04/28時点の奈良県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E5%A5%88%E8%89%AF%E7%9C%8C.pdf

ここで2ページと3ページ目にまたがっているデータが存在することが分かります。
施設名などは3ページ目にあります。

以下は同日の鳥取県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E9%B3%A5%E5%8F%96%E7%9C%8C.pdf

ここで6ページと7ページ目にまたがっているデータが存在することが分かります。
施設名などは6ページ目にあります。

つまり、施設名などが空の行を発見した場合に、改ページによって分割されている可能性を考慮する必要があります。

長い文字を含むデータ

たとえば以下のようにセルからはみ出るようなデータが存在する場合、結合された情報として抽出される可能性があります。

これを完全に回避する方法はなく、郵便番号の形式や電話番号の形式に当たらない場合は結合されているとみなして分割するなどの次善策を行う必要があります。

問題点を踏まえた上での処理

以下のようになります。
https://github.com/mima3/yakusyopdf

使用方法

# 新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのページからPDFをダウンロードしてフォルダに保存する
python download_pdf.py

# 作成したフォルダを指定して、PDFの内容からJSONとCSVを作成する
python analyze_pdf.py 20200428100754

結果
https://github.com/mima3/yakusyopdf/tree/master/20200428100754

まとめ

ある程度まではPDFからCSVやJSONなどに変換することはできました。

しかしながら、完璧に行うことは不可能です。
たとえば、県ごとに細かい設定とかを行えるようにはしましたが、これはあくまで現時点のものであり、PDFの書き方が急に変ったりします。

このため官公庁のPDFを解析する場合は、解析した時点の日付を明示する必要があり、また、フォーマットが変わった場合の検知が肝要と思われます。

はてなブックマークのREST APIを利用する

目的

はてなブックマークをREST API経由で登録してみます。
環境は以下の通りです。
・windows 10
・python 3.7.4

はてなブックマークのREST APIについては下記を参照してください。
http://developer.hatena.ne.jp/ja/documents/bookmark/apis/rest#authorization

事前準備

pythonを使用してはてなブックマークをREST API経由で登録するにはOAuth認証をする必要があります。
ここではOAuth認証を行うための事前準備について説明します。

Consumer Keyの取得

(1)Consumer Keyを取得するため、OAuth 開発者向け設定ページにアクセスします。この際、はてなのユーザーIDに紐づくパスワードの入力が要求されます。

(2)「新しいアプリケーション」の作成ボタンを押します。

(3)下記を参考に必要な項目を変更し、Consumer KeyとConsumer Secretをメモしておきます。

・アプリケーションの名称、説明、URLを変更します。スクリプトで使用するのでURLは127.0.0.1になっています。

・Consumer KeyとConsumer Secretをメモします。この値は外部に漏れないようにしましょう。

・必要な権限を割り当てます。はてなブックマークを登録するだけなら「write_public」が必要になります。

・「変更する」ボタンを押下します。
・「提供アプリケーション一覧に戻る」をクリックすると、新しいアプリケーションが追加されています。
※どうも削除する方法はないようで、使わなくなったら「無効化」するようです。

requests-oauthlibのインストール

OAuth認証を自前で実装するのは辛いのでrequests-oauthlibをインストールします。
https://github.com/requests/requests-oauthlib

はてなブックマークの登録

はてなブックマークを登録するためのサンプルコード

https://github.com/mima3/qiita_exporter/blob/master/hatena_api.py

"""はてなREST APIを操作する.

はてなのRESTAPIについては下記を参照してください。
http://developer.hatena.com/ja/documents/bookmark/apis/rest

また、OAuthの認証方法については下記のページを参考にしています.

"""

import requests
# https://github.com/requests/requests-oauthlib
from requests_oauthlib import OAuth1Session

class HatenaApiError(Exception):
    """HatenaApiErrorのエラーが発生したことを知らせる例外クラス"""

class HatenaApi:
    """はてなAPIを操作するクラス."""
    LOGIN_URL = 'https://www.hatena.ne.jp/login'
    session = None

    def _get_rk(self, hatena_id, password):
        """はてなIDとログインパスワードからrkを取得します。
        以下のコードを利用しています
        
        https://github.com/iruca/hatena-oauth-python/blob/master/get_access_token_util.py

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
        Returns:
            rk文字列
        Raises:
            HatenaApiError: rk文字列が取得できなかったとき。ID/パスワードが間違っているか、rkを取得するためのはてなAPIの仕様が変わった
        """
        payload = {'name': hatena_id, 'password': password}
        response = requests.post(self.LOGIN_URL, data=payload)
        if not "Set-Cookie" in response.headers:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        if not "rk=" in response.headers['Set-Cookie']:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        rk = response.headers["Set-Cookie"].split("rk=")[1].split(";")[0]
        return rk

    def _get_authorization_redirect_url(self, user_id, password, authorization_url):
        """authorization_urlにアクセスしてアプリケーションの許可を行う."""
        rk = self._get_rk(user_id, password)
        res_auth = requests.get(authorization_url, headers={"Cookie" : "rk="+ rk}).text
        rkm = res_auth.split("<input type=\"hidden\" name=\"rkm\" value=\"")[1].split("\"")[0]
        oauth_token = res_auth.split("<input type=\"hidden\" name=\"oauth_token\" value=\"")[1].split("\"")[0]
        res_redirect = requests.post(
            authorization_url,
            headers={"Cookie": "rk="+ rk},
            params={
                "rkm": rkm,
                "oauth_token": oauth_token,
                "name": "%E8%A8%B1%E5%8F%AF%E3%81%99%E3%82%8B"
            }
        )
        return res_redirect.url

    def authorize(self, user_id, password, consumer_key, consumer_secret, scope):
        """OAuth認証を行う.

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
            consumer_key: 「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key
            consumer_secret:「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret
            scope : 取得権限. ex "read_public,write_public"
        """
        self.session = OAuth1Session(
            consumer_key,
            consumer_secret,
            callback_uri="http://localhost/hogehoge"  # このURLは実際にアクセスできる必要はありません.
        )
        self.session.fetch_request_token(
            "https://www.hatena.com/oauth/initiate?scope={}".format(scope)
        )
        authorization_url = self.session.authorization_url("https://www.hatena.ne.jp/oauth/authorize")
        redirect_response = self._get_authorization_redirect_url(
            user_id,
            password,
            authorization_url
        )
        self.session.parse_authorization_response(redirect_response)
        self.session.fetch_access_token("https://www.hatena.com/oauth/token")

    def add_bookmark(self, url, comment="", tags=None):
        """はてなブックマークの追加または更新を行う.
        このメソッドを実行する前に、authorizeを呼び出す必要がある.

        Args:
            url:  ブックマーク対象のURL
            comment: コメント
            tags: タグの一覧
        """
        if not self.session:
            raise HatenaApiError("call authorize method.")
        if tags:
            for t in tags:
                comment += "[{}]".format(t)
        return self.session.post(
            "https://bookmark.hatenaapis.com/rest/1/my/bookmark?url=",
            params={
                "url": url,
                "comment" : comment,
            }
        )

上記のクラスは以下のように使用します。

    api = HatenaApi()
    api.authorize("はてなのユーザ名", "パスワード", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret", "write_public")
    res = api.add_bookmark("http://developer.hatena.com/ja/documents", "test!", [])
    res.raise_for_status()
    print(res.text)

解説

OAuth認証を行ったあとに,はてなブックマーク用のAPIを実行するだけですが、WebアプリケーションではなくスクリプトだけでOAuth認証を行うのは結構面倒くさいです。

理由はOAuth認証の途中で以下のような画面を表示してユーザが該当のアプリケーションを許可するプロセスが入るためです。

この問題は以下のページにて解決策が書かれています。

はてなのOAuth API用のアクセストークンを簡単に取得する [python]
https://www.iruca21.com/entry/2017/05/24/090000

簡単にいうと、ユーザーが画面で許可するボタンを押したのと同じ動きを行っています。先のコードでいうと_get_authorization_redirect_urlが、その動きに当たります。

また、python3xで動くようにするのと、できるだけ処理をrequests_oauthlibで行うように直してあります。

JavaScriptでテキストの差分を見るライブラリ

まえがき

JavaScriptで2ファイルのテキストの差分を確認するためのDiff用ライブラリについて調べます。

difflib

image.png

GitHub:
https://github.com/cemerick/jsdifflib

デモサイト
http://cemerick.github.io/jsdifflib/demo.html

サンプルコード

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<link rel="stylesheet" href="diffview.css">
<script src="difflib.js"></script>
<script src="diffview.js"></script>
<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div id="output"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

          var base = difflib.stringAsLines(src1);
          var newtxt = difflib.stringAsLines(src2);

          // create a SequenceMatcher instance that diffs the two sets of lines
          var sm = new difflib.SequenceMatcher(base, newtxt);

          // get the opcodes from the SequenceMatcher instance
          // opcodes is a list of 3-tuples describing what changes should be made to the base text
          // in order to yield the new text
          var opcodes = sm.get_opcodes();
          var contextSize = 0;
          document.getElementById('output').append(diffview.buildView({
              baseTextLines: base,
              newTextLines: newtxt,
              opcodes: opcodes,
              // set the display titles for each resource
              baseTextName: "Base Text",
              newTextName: "New Text",
              contextSize: contextSize,
              viewType: 1 // 0にするとbaseとnewTextが別の列になります
          }));

        </script>
    </body>
</html>

使用感

・シンプルで使い易いですが、細かい調整はできないようです。(たとえば文字レベルでの差分の表示は現時点でできない)
・積極的な開発はおこなわれていないようです。
・BSDライセンスです。

prettydiff

image.png

GitHub
https://github.com/prettydiff/prettydiff/

デモ
https://prettydiff.com/

ドキュメント
https://prettydiff.com/documentation.xhtml

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<script src="prettydiff/js/browser.js"></script>
<link href="prettydiff/css/index.css" media="all" rel="stylesheet" type="text/css"/>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div class="white" id="prettydiff"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

// integrate into the browser
let output     = "",
    prettydiff = window.prettydiff,
    options    = window.prettydiff.options;
options.source_label = "修正前";
options.source = src1;
options.diff_label = "修正後";
options.diff = src2;
options.diff_format = "html";

options.mode = "diff";
options.language = "auto";
options.lexer = "text";
options.wrap = 10;
options.diff_view = 'inline'; // 'sidebyside'で別の列で表示
output         = prettydiff();
console.log(output);
          document.getElementById('prettydiff').innerHTML = output;
// You can include the Pretty Diff code in any way that is convenient,
// whether that is using an HTML script tag or concatenating the
// js/browser.js code with your other code.

        </script>
    </body>
</html>

使用感

・ブラウザで使用するbrowser.jsはnpmでインストール後、tscコマンドで作成されます
さまざまなオプションがあります
・ライセンスはCC0

mergely

image.png

GitHub
https://github.com/wickedest/Mergely

デモ
http://www.mergely.com/

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>DIFF</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.min.js"></script>
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/addon/search/searchcursor.min.js"></script>
<script src="mergely/libs/mergely.js" type="text/javascript"></script>
<link rel="stylesheet" media="all" href="mergely/libs/mergely.css" />
    </head>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>

<body>
<div class="mergely-full-screen-8">
  <div class="mergely-resizer">
    <div id="mergely"></div>
  </div>
</div>

        <script>
$(document).ready(function () {
    $('#mergely').mergely({
        wrap_lines : true,
        cmsettings: { 
            readOnly: false,
            lineNumbers: true
        },
        lhs: function(setValue) {
            setValue(document.getElementById('src1').innerText);
        },
        rhs: function(setValue) {
            setValue(document.getElementById('src2').innerText);
        }
    });
});
        </script>
</body>

    </body>
</html>

使用感

・依存ライブラリが多い(JQuery,codemirror)
・codemirrorがエディタ用のライブラリなので、修正したテキストの差分がすぐ確認できる
・ライセンスはGNU LGPL v3.0

まとめ

・差分を表示するだけならprettydiffがよさそうです。
・マージなどの編集が必要ならmergelyになるでしょうが、JQueryに依存しています。
・2ファイルを入力とするのでなく、diffの結果を入力としてHTML表示するならdiff2htmlも使えそうです。
・なお、画像の差分をとるならjs-imagediffが使えそうでした(未検証)

参考

JavaScript based diff utility [closed]
https://stackoverflow.com/questions/3053587/javascript-based-diff-utility

Qiitaの記事をGitHubに移行する

はじめに

一つのサービスに依存するのは、リスクだと思うのでQiitaの記事をGitHubに移行するスクリプトを書いてみます。

なお、私の記事は以下のようになりました。
https://github.com/mima3/note

動作環境

Python 3.7.4
Windows10

事前準備

Qiitaのアクセストークンを取得します。

(1)設定画面のアプリケーションタブから「新しいトークンを発行する」を押下します。

image.png

(2)読み取り権限を付けて「発行」を押下します
image.png

(3)アクセストークンをメモしておきます
image.png

使用方法

(1)以下のリポジトリからスクリプトを取得する
https://github.com/mima3/qiita_exporter

(2)下記の形式でスクリプトを実行する。

python qiita_to_github.py [userid] [accesstoken] [保存先フォルダ] [GitHubのブロブのルートURL ex.https://github.com/mima3/note/blob/master]

(3)保存先フォルダをGitHubに登録します。

やっていること

・QiitaAPIを利用してQiita上の指定ユーザが記載した全てのマークダウンを取得します。
・画像をローカルにダウロードして、リンクを書き換えます。
・コードブロック以外の行にて、改行コードの前にスペース2ついれて改行を行います。
・「#タイトル」という記述があったら「# タイトル」に直します。
・コードブロックのタイトル(例:「```python:test.py」)が表示されないので対応します。
・自分の記事へのURLを移行先のURLに修正する

課題

・タグとかの表現をどうするか。
・コメントとかの取り扱いをどうするか。
・タイトルをファイル名としているが、使っている文字によっては動かない。タイトルによっては大文字に変換とかの小細工が必要になると思う。
・もしかするとWikiの機能を利用した方がいいかも。。。
・Qiitaで表示できてたTwitterの埋め込みは旨く表示されない。
image.png

image.png
GitHub PagesでJekyllが上手く動作しないマークダウンがある。たとえば{%s}とかいうコードが混ざっていると失敗する。
image.png

image.png

 

Vue.js+ bulmaでダイアログを表示してみる

目的

JavaScriptのフレームワークであるVue.jsとCSSのフレームワークである bulmaを使用してダイアログを表示してみる。

image.png

サンプル

Vue.jsにbulmadialog-componentコンポーネントを追加します。
その後、任意のタイミングで親側からコンポーネントのメソッドshowDialogを実行します。
この際、以下のプロパティを持つオブジェクトをパラメータに指定します。

  • title : ダイアログのタイトル
  • contents : ダイアログの内容。HTMLタグは無効
  • html : ダイアログの内容。HTMLのタグは有効
  • buttons : ボタン情報の配列
    • caption : ボタンのタイトル
    • callback : コールバック関数

コンポーネント側

IE11で動くようにしています。

bulma_dialog.js

/**
 * bulma + vue.jsでダイアログを表示します。
 * html: vue.jsの管理下に以下を追加します
 *   <bulmadialog-component ref="dialog"></bulmadialog-component>
 * js:Vue.jsを作成するときにコンポーネントを追加する
 *  components: {
      'bulmadialog-component': BulmaDialog,
    },        
 * js:Vue.jsの親のメソッドにて以下を実行
 *  this.$refs.dialog.showDialog({
        title:'わっふるる',
        //contents:'わっふるぼでぃ0\nsadfasfd',
        html : 'あたえたt<br>awrawtあたえたt<br>',
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('いいえ');
            }
          }
        ]
      });
 */
// eslint-disable-next-line no-unused-vars
const BulmaDialog = {
  /* eslint-disable max-len */
  template: (function() {/*
      <div v-bind:class="{ 'is-active': isShow }" class="modal">
        <div class="modal-background"></div>
          <div class="modal-card">
              <header class="modal-card-head">
                  <p class="modal-card-title">{{data.title}}</p>
              </header>
              <div >
              </div>
              <section v-if="data.html" v-html="data.html" class="modal-card-body"></section>
              <section v-else class="modal-card-body">{{data.contents}}</section>
              <footer class="modal-card-foot"  style="justify-content: flex-end;">
                  <button v-for="btnObj in data.buttons" type="button" class="button" @click="btnObj.callback(); isShow = false;">{{btnObj.caption}}</button>
              </footer>
          </div>
      </div>
      </div>
    */}).toString().match(/\/\*([^]*)\*\//)[1],
  /* eslint-enable */
  data: function() {
    return {
      isShow: false,
      data: {
        title: '',
        body: '',
        html: '',
        buttons: [],
      },
    };
  },
  methods: {
    showDialog: function(data) {
      this.isShow = true;
      this.data.title = data.title;
      this.data.contents = data.contents;
      this.data.html = data.html;
      this.data.buttons = data.buttons;
    },
  },
};

使う側

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello Bulma!</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="/phptest/codeview/js/bulma_dialog.js"></script>
  </head>
  <body>
  <div id="app">
    <bulmadialog-component ref="dialog"></bulmadialog-component>
    <button class="button is-primary" @click="test1">ダイアログA</button>
    <button class="button is-primary" @click="test2">ダイアログB</button>
  </div>
<script>
var app = new Vue({
  el: '#app',
  components: { //Scopedが使える
    'bulmadialog-component': BulmaDialog,
  },
  data: function() {
      return {
      }
  },
  methods: {
    test1 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(TEXT)', 
        contents: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('test1 はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('test2 いいえ');
            }
          }
        ]
      });
    },
    test2 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(HTML)', 
        html: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'YES',
            callback : function () {
              console.log('Yes');
            }
          },
          {
            caption : 'No',
            callback : function () {
              console.log('No');
            }
          }
        ]
      });
    }
  }
})
</script>

  </body>
</html>

参考

SafariでもエラーにならないJavascriptのヒアドキュメントの書き方
https://qiita.com/ampersand/items/c6c773ba7ae9115856d0

メモ

Bulma自体がIEのサポートを部分的にのみしかしていないので、テンプレートリテラルを使ってIEをあきらめた方が無難かも

Bulma uses autoprefixer to make (most) Flexbox features compatible with earlier browser versions. According to Can I use, Bulma is compatible with recent versions of:

  • Chrome
  • Edge
  • Firefox
  • Opera
  • Safari

Internet Explorer (10+) is only partially supported.

https://github.com/jgthms/bulma

Twig + bulma でページネーションをやってみる

目的

PHPのテンプレートフレームワークであるTwigとCSSのフレームワークであるBulmaでページネーションをやってみる
image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

コード

PHPでテンプレートを使用する際に下記のデータを指定してください。

  • currentPage: 現在のページ番号
  • maxPage : 最大ページ番号
  • pageRange : ここで指定したページ数分、最初のページ, 最終ページ, 現在ページの前後ページへのリンクを省略せずに表示する。

PHP側

        return $this->view->render(
            $response,
            'commitlog.twig',
            [
                'BASE_PATH' => $this->config['BASE_PATH'],
                'commitlogs' => $commitlogs,
                'pageLimit' => $limit,
                'currentPage' => $page,
                'maxPage' => $maxPage,
                'pageRange' => 2
            ]
        );

ページネーション用のテンプレート

pagination.php

<nav class="pagination is-centered" role="navigation" aria-label="pagination">
    {% if currentPage != 1 %}
        <a class="pagination-previous" href="?page={{currentPage - 1}}">Previous</a>
    {% endif %}
    {% if currentPage != maxPage %}
        <a class="pagination-next" href="?page={{currentPage + 1}}">Next page</a>
    {% endif %}
    <ul class="pagination-list">
        {% set preItemHasEllipsis = false %}
        {% for i in range(1,maxPage) %}
            {% if i == currentPage %}
                <li><a class="pagination-link is-current" aria-lasbel="Goto page {{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif (i <= 1 + pageRange) or 
                      (i >= maxPage - pageRange) or
                      ((currentPage - pageRange <= i) and (i <= currentPage + pageRange))
            %}
                <li><a class="pagination-link" aria-lasbel="Goto page {{i}}" href="?page={{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif preItemHasEllipsis == false and ( 
                        (i == 1 + 1 + pageRange) or 
                        (i == maxPage - pageRange - 1) or
                        (i == currentPage - pageRange - 1) or
                        (i == currentPage + pageRange + 1)
                    )
            %}
                <li><span class="pagination-ellipsis">…</span></li>
                {% set preItemHasEllipsis = true %}
            {% endif %}
        {% endfor %}                
    </ul>
</nav>

読み出し側のテンプレート

// 略
        <h1 class="title">コミットログ</h1>
        <div class="content">
            <div class="table-container">
             // 略
            </div>

            {{ include("component/pagination.twig")}}
// 略

メモ

ループは効率が悪そうなので、速度が重要なら分岐でうまいこと作った方がいいと思う。

Githubでスターが多いWYSIWYGエディタ(2019年11月)

はじめに

2019/11時点でGithubにあるWYSIWYGタグでスターが多いライブラリを調べてみます。

Quill

概要

Github
https://github.com/quilljs/quill

Demo
https://quilljs.com/
image.png

・テーブルの作成はできないようです(ver2.x用にテーブル追加のプラグインはある)
・クリップボードを経由して画像のアップロードが可能です。
highlight.jsを使用してコードブロックのハイライトが可能のようです

対象ブラウザ
image.png
IEは非推奨のようです。

ライセンス
BSD 3-clause

サンプル

1.3.7のサンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <!-- highlight.js を使う場合はquillの前に参照する-->
    <link rel="stylesheet"
          href="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/styles/default.min.css">
    <script src="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/highlight.min.js"></script>

    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

  </head>
  <body>

<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>
<button id="btnContent">コンテンツ取得</button>
<button id="btnImage">イメージの挿入</button>
<button id="btnDisable">編集可能/不可能</button>

<script>
var Delta = Quill.import('delta');
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    syntax : true,              // Include syntax module
    // https://quilljs.com/docs/modules/toolbar/
    toolbar : [
      ['bold', 'italic', 'underline', 'strike'],
      [{ 'color': [] }, { 'background': [] }], 
      ['link', 'image'] ,
      ['code-block']
    ]
  }
});

document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});

document.getElementById('btnImage').addEventListener('click', function() {
  // このあたりを工夫すればクリップボードからの画像貼り付け等ができそう・・
  console.log(quill.getSelection(true).index);
  quill.insertEmbed(quill.getSelection(true).index, 'image', 'https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png');
});

let enableEditor = false;
document.getElementById('btnDisable').addEventListener('click', function() {
  quill.enable(enableEditor);
  enableEditor = !enableEditor;
  console.log(enableEditor);
});

/**
 * ペーストのイベント追加例
 */
quill.root.addEventListener("paste", function (t) {
  console.log('paste');
  console.log(t);
  return true;
} , false);

</script>
  </body>
</html>

拡張モジュール

画像の貼り付けについて

quill-image-drop-and-paste
https://github.com/chenjuneking/quill-image-drop-and-paste

image.png

quill-image-drop-and-pasteはどうも以下のように修正しないと動作しないようです。
export.ImageDropAndPasteを使用しているが、設定していないので替わりにImageDropAndPasteを設定する。

(function(){var exports={};
"use strict";Object.defineProperty(exports,"__esModule",{value:true});var _createClass=function(){function e(e,t){for(var a=0;a<t.length;a++){var n=t[a];n.enumerable=n.enumerable||false;n.configurable=true;if("value"in n)n.writable=true;Object.defineProperty(e,n.key,n)}}return function(t,a,n){if(a)e(t.prototype,a);if(n)e(t,n);return t}}();function _classCallCheck(e,t){if(!(e instanceof t)){throw new TypeError("Cannot call a class as a function")}}var ImageDropAndPaste=function(){function e(t){var a=arguments.length>1&&arguments[1]!==undefined?arguments[1]:{};_classCallCheck(this,e);this.quill=t;this.options=a;this.handleDrop=this.handleDrop.bind(this);this.handlePaste=this.handlePaste.bind(this);this.quill.root.addEventListener("drop",this.handleDrop,false);this.quill.root.addEventListener("paste",this.handlePaste,false)}_createClass(e,[{key:"handleDrop",value:function e(t){var a=this;t.preventDefault();if(t.dataTransfer&&t.dataTransfer.files&&t.dataTransfer.files.length){if(document.caretRangeFromPoint){var n=document.getSelection();var i=document.caretRangeFromPoint(t.clientX,t.clientY);if(n&&i){n.setBaseAndExtent(i.startContainer,i.startOffset,i.startContainer,i.startOffset)}}this.readFiles(t.dataTransfer.files,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert.call(a,e,t)}},t)}}},{key:"handlePaste",value:function e(t){var a=this;if(t.clipboardData&&t.clipboardData.items&&t.clipboardData.items.length){this.readFiles(t.clipboardData.items,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert(e,t)}},t)}}},{key:"readFiles",value:function e(t,a,n){[].forEach.call(t,function(e){var t=e.type;if(!t.match(/^image\/(gif|jpe?g|a?png|svg|webp|bmp)/i))return;n.preventDefault();var i=new FileReader;i.onload=function(e){a(e.target.result,t)};var r=e.getAsFile?e.getAsFile():e;if(r instanceof Blob)i.readAsDataURL(r)})}},{key:"insert",value:function e(t,a){var n=(this.quill.getSelection()||{}).index||this.quill.getLength();this.quill.insertEmbed(n,"image",t,"user")}}]);return e}();exports.default=ImageDropAndPaste;
window.Quill.register('modules/imageDropAndPaste',ImageDropAndPaste)})(); // export.ImageDropAndPaste->ImageDropAndPaste
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

    <script src="quill-image-drop-and-paste-master/quill-image-drop-and-paste.min.js" type="text/javascript"></script>

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    imageDropAndPaste : true
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
</script>
  </body>
</html>

テーブル操作

quilljs-table

quilljs-table
https://github.com/dost/quilljs-table

image.png

サンプルを見る限り、テーブルの削除や列、行の削除がGUIからできそうにないです。
最終コミット日が2017年。

quilljs-table

quilljs-table
https://github.com/volser/quill-table-ui

quilljs v2.0.0-dev.3が必要になります。
最終更新日は2019年10月25日です。

テーブルの操作は以下のようなイメージになります。
image.png

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.min.js" type="text/javascript"></script>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.snow.min.css" rel="stylesheet">

    <script src="https://unpkg.com/quill-table-ui@1.0.5/dist/umd/index.js" type="text/javascript"></script>
    <link href="https://unpkg.com/quill-table-ui@1.0.5/dist/index.css" rel="stylesheet">

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<button id="btnTable">テーブル追加</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

Quill.register({
  'modules/tableUI': quillTableUI.default
}, true);

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    table: true,
    tableUI: true,
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
document.getElementById('btnTable').addEventListener('click', function() {
  let table = quill.getModule('table');
  console.log(table);
  table.insertTable(3, 3);
});

</script>
  </body>
</html>

メモ

2019/11/28時点の最終リリースはバージョン1.3.7です。
2.0の開発が進められていますが、そのマイルストーンは不透明なものとなっています。
https://github.com/quilljs/quill/issues/2435

moduleを実装することで拡張機能が作れる模様。

trix

Github
https://github.com/basecamp/trix

Demo
https://trix-editor.org/
image.png

対象ブラウザ
IE 11以降をサポートしているようです。
https://github.com/basecamp/trix/issues/173

ライセンス
MIT License

サンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <link rel="stylesheet" type="text/css" href="trix.css">
    <script type="text/javascript" src="trix.js"></script>
  </head>
  <body>

<!-- Create the editor container -->
<trix-editor class="trix-content">サンプル</trix-editor>

<button id="btnContent">コンテンツ取得</button>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>

<script>
document.getElementById('btnContent').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  console.log(element.editor.getDocument());
  console.log(element.editor.getDocument().toString());
});

// エディタの内容はJSON化して保存と読み込みが可能
document.getElementById('btnSave').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  localStorage["editorState"] = JSON.stringify(element.editor);
});

document.getElementById('btnLoad').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  element.editor.loadJSON(JSON.parse(localStorage["editorState"]));
});

// イベントの確認
addEventListener("trix-attachment-add", function(event) {
  // 添付ファイルや画像を追加するとこのイベントが実行される
  // 以下のコードを参考にfileuploadとかができそう
  // https://trix-editor.org/js/attachments.js
  console.log('trix-attachment-add');
  console.log(event.attachment);
});
addEventListener("trix-attachment-remove", function(event) {
  // 添付ファイルや画像を削除するとこのイベントが実行される
  console.log('trix-attachment-remove');
  console.log(event.attachment);
});

addEventListener("trix-change", function(event) {
  // 内容が変化した場合実行
  console.log('trix-change');
  console.log(event);
});

</script>
  </body>
</html>

メモ

学習コストは低いと思われる。
※最低限の動作確認はtrix-editorタグを作ってtrix.jsを読み込むだけでいい。

テーブルをサポートする予定はない。
https://github.com/basecamp/trix/issues/539

コードブロックはあるが強調表示はサポートしていない。
image.png

拡張とかはできなさそう。

MediumEditor

medium.comインラインエディターツールバーのクローン

Github
https://github.com/yabwe/medium-editor

Demo
http : //yabwe.github.io/medium-editor/
image.png

画像の貼り付けやコードブロックはなさそう。

対象ブラウザ
image.png
IEをサポートしている

ドキュメント
https://github.com/yabwe/medium-editor/wiki

ライセンス
MIT

サンプル

単純な例

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">
  </head>
  <body>

<div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
var editor = new MediumEditor('.editable', {
    placeholder: {
        text: 'テキストを入力してください',
        hideOnClick: true
    },
    toolbar: {
        /* These are the default options for the toolbar,
           if nothing is passed this is what is used */
        allowMultiParagraphSelection: true,
        buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'],
        diffLeft: 0,
        diffTop: -10,
        firstButtonClass: 'medium-editor-button-first',
        lastButtonClass: 'medium-editor-button-last',
        relativeContainer: null,
        standardizeSelectionStart: false,
        static: false,
        /* options which only apply when static is true */
        align: 'center',
        sticky: false,
        updateOnEmptySelection: false
    }
});

document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

MediumEditor Tables

テーブルの作成を行うプラグインです。
Jqueryに依存しています。

GitHub
https://github.com/yabwe/medium-editor-tables

demo
https://yabwe.github.io/medium-editor-tables/

tablemedium.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>

    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <script type="text/javascript" src="lib/js/medium-editor-tables.js"></script>

    <link rel="stylesheet" href="lib/css/medium-editor-tables.css" />

  </head>
  <body>

    <div class="editable"></div>

<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    },
    extensions: {
      table: new MediumEditorTable()
    }
  });
</script>
  </body>
</html>

jQuery insert plugin for MediumEditor

画像やYoutubeやTwitterなどの埋め込みが可能なプラグインです。
Jqueryに依存します。

Github
https://github.com/orthes/medium-editor-insert-plugin

demo
https://linkesch.com/medium-editor-insert-plugin/

tablemedium3.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin-frontend.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/css/medium-editor.min.css" />

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.12/handlebars.runtime.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-sortable/0.9.13/jquery-sortable-min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/jquery.ui.widget@1.10.3/jquery.ui.widget.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.iframe-transport/1.0.1/jquery.iframe-transport.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-file-upload/9.28.0/js/jquery.fileupload.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/js/medium-editor.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/js/medium-editor-insert-plugin.min.js"></script>

  </head>
  <body>

    <div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    }
  });
  $('.editable').mediumInsert({
      editor: editor
  });
document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

メモ

Medium Editorのライブラリ自体はJavaScriptのみで外部のライブラリに依存していない。
しかし、その拡張機能がJQueryに依存している。

任意の拡張機能が作成可能。
https://github.com/yabwe/medium-editor/blob/master/src/js/extensions/README.md

Pell

もっともサイズの小さいWYSIWYGライブラリで他のライブラリに依存しません。

GitHub
https://github.com/jaredreich/pell

Demo
https://jaredreich.com/pell/
画像はURL指定して表示。
Link等でダイアログを表示する際はブラウザのメッセージボックスを使用している

image.png

対象ブラウザ
image.png
かなり古いブラウザでも動作するようです。

ライセンス
MIT

メモ

軽量であるのが売り。
テーブル機能はなさそう。
また拡張機能等はなさそう。

Editor.js

GitHub
https://github.com/codex-team/editor.js

Demo
https://editorjs.io/

image.png

・表、画像のアップロードをサポートしている。
・ツールバーは表示されずに、必要な時にポップアップが出る

対象ブラウザ
image.png

IEは対象外の模様
※すくなくともデモサイトはIE11で動作しない

ドキュメント
https://github.com/codex-team/editor.js/tree/bcdfcdadbc444921aee62b38516329cda3c96a70/docs

ライセンス
Apache License 2.0
寄付を受け付けている
https://opencollective.com/editorjs

サンプル

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Editor.js example</title>
  <link href="https://fonts.googleapis.com/css?family=PT+Mono" rel="stylesheet">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</head>
<body>
  <div class="ce-example">
    <div class="ce-example__content _ce-example__content--small">
      <div id="editorjs"></div>

      <button id="saveButton">
        editor.save()
      </button>
      <button id="loadButton">
        editor.load()
      </button>
    </div>
  </div>

  <!-- Load Tools -->
  <!--
   You can upload Tools to your project's directory and use as in example below.
   Also you can load each Tool from CDN or use NPM/Yarn packages.
   Read more in Tool's README file. For example:
   https://github.com/editor-js/header#installation
   -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest"></script><!-- Header -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"></script><!-- Image -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@latest"></script><!-- Delimiter -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest"></script><!-- List -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/checklist@latest"></script><!-- Checklist -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@latest"></script><!-- Quote -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/code@latest"></script><!-- Code -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@latest"></script><!-- Embed -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/table@latest"></script><!-- Table -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/link@latest"></script><!-- Link -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@latest"></script><!-- Warning -->

  <script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@latest"></script><!-- Marker -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@latest"></script><!-- Inline Code -->

  <!-- Load Editor.js's Core -->
  <script src="./dist/editor.js"></script>

  <!-- Initialization -->
  <script>
    /**
     * To initialize the Editor, create a new instance with configuration object
     * @see docs/installation.md for mode details
     */
    var initObj = {
      /**
       * Wrapper of Editor
       */
      holder: 'editorjs',
      /**
       * Tools list
       */
      tools: {
        /**
         * Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md}
         */
        header: {
          class: Header,
          inlineToolbar: ['link'],
          config: {
            placeholder: 'Header'
          },
          shortcut: 'CMD+SHIFT+H'
        },
        /**
         * Or pass class directly without any configuration
         */
        image: {
          class: SimpleImage,
          inlineToolbar: ['link'],
        },
        list: {
          class: List,
          inlineToolbar: true,
          shortcut: 'CMD+SHIFT+L'
        },
        checklist: {
          class: Checklist,
          inlineToolbar: true,
        },
        quote: {
          class: Quote,
          inlineToolbar: true,
          config: {
            quotePlaceholder: 'Enter a quote',
            captionPlaceholder: 'Quote\'s author',
          },
          shortcut: 'CMD+SHIFT+O'
        },
        warning: Warning,
        marker: {
          class:  Marker,
          shortcut: 'CMD+SHIFT+M'
        },
        code: {
          class:  CodeTool,
          shortcut: 'CMD+SHIFT+C'
        },
        delimiter: Delimiter,
        inlineCode: {
          class: InlineCode,
          shortcut: 'CMD+SHIFT+C'
        },
        linkTool: LinkTool,
        embed: Embed,
        table: {
          class: Table,
          inlineToolbar: true,
          shortcut: 'CMD+ALT+T'
        },
      },
      /**
       * This Tool will be used as default
       */
      // initialBlock: 'paragraph',
      /**
       * Initial Editor data
       */
      data: {
      },
      onReady: function(){
      },
      onChange: function() {
        console.log('something changed');
      }
    };
    var editor = new EditorJS(initObj);
    /**
     * Saving example
     */
    const saveButton = document.getElementById('saveButton');
    const loadButton = document.getElementById('loadButton');

    saveButton.addEventListener('click', function () {
      editor.save().then((savedData) => {
        console.log(savedData);
        localStorage["editJs"] = JSON.stringify(savedData);
      });
    });
    loadButton.addEventListener('click', function () {
      let data = JSON.parse(localStorage["editJs"]);
      console.log(data);
      editor.render(data);
    });
  </script>
</body>
</html>

メモ

IEは動作しない
undo機能は2019/11/28時点では自前でやる必要があるっぽい。
https://github.com/codex-team/editor.js/issues/518

CKEditor5

Gitのスター順で並べると上位5位にでてきませんが、バージョンごとにプロジェクトが分かれているっぽいので累計すると、結構使われているように見えます。
公式ページを見ると累計で27.500.000+のダウンロードが行われているそうです。

GitHub
https://github.com/ckeditor/ckeditor5

Demo
https://ckeditor.com/ckeditor-5/demo/

ライセンス
GNU General Public License Version 2 or later.

商用ライセンスがある。
https://ckeditor.com/pricing/#null

メモ

この中ではデモが一番、使い易かったので、金が豊富にあるならコレがよさそう。

まとめ

色々調べましたが、結局、一長一短ある感じがします。
その上で個人的にはコードのハイライトが簡単に使えそうなQuillか、学習コストの低そうなtrixがよさそうに見えます。

復活のVisualStudioのマクロ

はじめに

VisualStudio2008あたりまではVisualStudioにマクロがついていました。
この機能は最近のVisualStudioからは削除されましたが、再び拡張機能として復活しています。

https://github.com/microsoft/VS-Macros

VisualStudio2019でもそれっぽく動作するようです。

導入手順

(1)拡張機能の管理より「Macros for Visual Studio」をインストールします
image.png

(2)インストール後、再起動をするとツールからMacrosメニューが使用できるようになります。
image.png

(3)Macro Exploreを選択すると、Macro Exploreウィンドウが表示されます。
image.png

(4)JSファイルを右クリックして開くを選択することでJSの中身が編集可能になります。
image.png

なお、JSファイルをダブルクリックすることで記載したJSを実行可能です。

サンプル

出力ウィンドウに文字列を出力する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
// https://github.com/Microsoft/VS-Macros/issues/28
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();
outputWindowPane.OutputString("display this text in the output window panel\n");

image.png

出力ウィンドウのクリアを行う

dte.ExecuteCommand("Edit.ClearAll");

ソリューションのプロジェクトのアイテムをすべて列挙する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

function dumpProjectItems(projectItems, lv) {
    for (var i = 1; i <= projectItems.Count; i++) {
        var file = projectItems.Item(i);
        for (var j = 0; j < lv; ++j) {
            outputWindowPane.OutputString(' ');
        }
        outputWindowPane.OutputString(file + "\n");

        if (file.SubProject != null) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        } else if (file.ProjectItems != null && file.ProjectItems.Count > 0) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        }
    }
}

for (var i = 1; i <= dte.Solution.Projects.Count; i++) {
    outputWindowPane.OutputString(dte.Solution.Projects.Item(i).Name + "\n");
    dumpProjectItems(dte.Solution.Projects.Item(i).ProjectItems, 1);
}

関数の先頭に移動してその関数の情報を取得

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

var textSelection = dte.ActiveDocument.Selection;

// Define Visual Studio constants
var vsCMElementFunction = 2;
var vsCMPartHeader = 4;

var codeElement = textSelection.ActivePoint.CodeElement(vsCMElementFunction);

if (codeElement != null) {
    // 関数の先頭に移動
    textSelection.MoveToPoint(codeElement.GetStartPoint(vsCMPartHeader));
    dte.ActiveDocument.Activate();

    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.GetStartPoint(vsCMPartHeader).Line + "\n");
    outputWindowPane.OutputString(codeElement.GetEndPoint().Line + "\n");
    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.Parameters.Count + "\n");
    // Itemの列挙ができない模様
    /*
    for (var i = 0; i < codeElement.Parameters.Count; ++i) {
        // Itemが認識できない
        outputWindowPane.OutputString(codeElement.Parameters.Item(i) + "\n");
    }
    */
    outputWindowPane.OutputString(codeElement.DocComment + "\n");

}

時刻のインサート

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var date = new Date();

var day = date.getDate();
var month = date.getMonth() + 1;
var year = date.getYear();

var hours = date.getHours();
var minutes = date.getMinutes();

// Add a zero if single digit
if (day <= 9) day = "0" + day;
if (month <= 9) month = "0" + month;
if (minutes <= 9) minutes = "0" + minutes;
if (hours <= 9) hours = "0" + hours;

Macro.InsertText("//" + year + "/" + month + "/" + day + ", " + hours + ":" + minutes);

まとめ

VisualStudioのマクロは復活しましたが、デバッグ機能がない、すべての機能が使えるわけでもない、メンテナンスする気はないとうたっているので、使いどころは限られるようです。

20年物のC言語で作られたシステムのテスト工程を改善しようとした話

はじめに

ちょっと前に20年物のC言語で作られたシステムのテストを色々改善しようとしてみたので、この時に得たちょっとした知見を書いていこうと思います。

※注意
記事を書くために自分のパソコンで当時を思い出しながら環境を作っているので、実際、実務でやった環境やバージョンとは違います。
また、この記事にはいくつかコードがでてきますが、すべて記事を書くために考えた疑似的な例にすぎません。

単体テスト用のテストコードの作成

20年も動いているシステムだと、もはや誰にも意味はわからんが、既存の挙動を変えてはいけない箇所がいくつもあります。

そういう箇所に手を入れざるを得ないときに、有効な方法として以下のような方法があります。

まず、既存のコードに対するテストコードを記載します。そして全て合格することを確認してから、少しづつ機能を拡張していきます。
これにより、新規機能追加が既存の機能を壊していないことを確認しながら機能追加の作業を進めることが可能になります。

いわゆるテストファーストになりますが、こういう長い歴史をもつ保守的な環境で、XPで言っていたようなことをやると色々と歪みが出ます。
それについてはこの章の最後に記載します。

xUnitの導入

assert等で自力で頑張ってもいいですが、CUnitなどのxUnitのテストフレームワークを使用した方がいいです。

※今回はCUnitしか検証していませんが、最近のモノだと以下のようなC言語用のユニットテストのライブラリがあるようです。

Cutter(2019-09-13にリリースされた1.2.7が最新)
https://cutter.osdn.jp/index.html.ja

UNITY(2017/11にv2.4.3をリリース。コミット履歴は2019/10のものもある)
http://www.throwtheswitch.org/unity
https://github.com/ThrowTheSwitch/Unity

また、CPPUnitなどのC++用のxUnitのフレームワークからもテストが可能ですが、今回は外しています。

何故xUnitのテストフレームワークが必要か

有名どころのテストフレームワークを使用した方が、実現例とかが世の中で出回っていてトラブル時に助かります。
また、有名どころのテストフレームワークを使用するとJenkinsとの連携が楽になります。

たとえばJenkinsのxUnit Pluginは有名どころのxUnitで出力されたXMLを集計する機能を有しており、簡単に自動テストの結果を集計することが可能になっています。

CUnit

CUnitはC言語用のxUnitのフレームワークでgccやvisual cから使用することが可能です。

visual studio2019でビルドする方法

VS2008とか2005の時代のソリューションしかないのでいかんせ古くてワーニングが出まくります。

  1. CUnit-2.1-3/VC9/CUnit.slnを開く
  2. CUnit-2.1-3/CUnit/Headers/CUnit.h.inを基にCUnit.hを作成します。違いは以下の通りです。
    image.png
  3. libcunit->その他のプロジェクトの順番でビルドしていきます。
    ※一度にビルドしてエラーが出る場合は、プロジェクトの依存関係を見直してください。また、プロジェクトの同時ビルド数を1にすると正常にビルドできるようになります。

Visual Stduio2019でプロジェクトの同時ビルド数の確認方法
メニューの「ツール」→「オプション」でオプションダイアログを開き「プロジェクトおよびソリューション」→「ビルド/実行」で該当の画面が表示されます。
image.png

makeコマンドでビルドする方法

以下でWindows10 + Ubuntu 18.04の環境下でmakeコマンドを利用してビルドする例を示します。
基本的に以下の記事と同じ方法でビルドできますがバージョンは2.1-3にしています。

CUnitでCプログラムの単体テストをする
https://qiita.com/muniere/items/ff9a984ed7e51eee7112

wget https://jaist.dl.sourceforge.net/project/cunit/CUnit/2.1-3/CUnit-2.1-3.tar.bz2
tar xvf CUnit-2.1-3.tar.bz2
cd CUnit-2.1-3
aclocal
autoconf
automake
./configure
make
make install
トラブルシュート
automakeやautoconfコマンドがない

以下を実行してインストールする。

sudo apt-get install autoconf              
configureとか実行すると「configure: error: cannot find install-sh, install.sh, or shtool in "." "./.." "./../.."」というエラーがでる。

下記のコマンドを実行してautoconfからやり直す

autoreconf -i
configure時に「error: Libtool library used but 'LIBTOOL' is undefined」が発生した

以下のコマンドでlibtoolをインストールする。

sudo apt-get install libtool
実行時に「error while loading shared libraries: libcunit.so.1: cannot open shared object file: No such file or directory」が出る場合

環境変数LD_LIBRARY_PATHを設定する。

export LD_LIBRARY_PATH=/usr/local/lib

永続化したい場合は.bashrcにでも記載しておく

実行例

コンソールで実行する場合は以下のようになります。

# include <CUnit/CUnit.h>

int max(int x, int y) {
    if (x>y) {
        return x;
    } else {
        return y;
    }
}
int min(int x, int y) {
    /** bug  **/
    if (x>y) {
        return x;
    } else {
        return y;
    }
}

void test_max_001(void) {
    CU_ASSERT_EQUAL(max(5, 4) , 5);
}
void test_max_002(void) {
    CU_ASSERT_EQUAL(max(4, 5) , 5);
}
void test_max_003(void) {
    CU_ASSERT_EQUAL(max(5, 5) , 5);
}
void test_min_001(void) {
    CU_ASSERT_EQUAL(min(5, 4) , 4);
}
void test_min_002(void) {
    CU_ASSERT_EQUAL(min(4, 5) , 4);
}
void test_min_003(void) {
    CU_ASSERT_EQUAL(min(5, 5) , 5);
}

int main() {
    CU_pSuite max_suite, min_suite;

    CU_initialize_registry();
    max_suite = CU_add_suite("max", NULL, NULL);
    CU_add_test(max_suite, "test_001", test_max_001);
    CU_add_test(max_suite, "test_002", test_max_002);
    CU_add_test(max_suite, "test_003", test_max_003);

    min_suite = CU_add_suite("min", NULL, NULL);
    CU_add_test(min_suite, "test_001", test_min_001);
    CU_add_test(min_suite, "test_002", test_min_002);
    CU_add_test(min_suite, "test_003", test_min_003);
    CU_console_run_tests();
    CU_cleanup_registry();

    return(0);
}

他のASSERT用の関数については下記を参照してください。
http://cunit.sourceforge.net/doc/writing_tests.html

CUnitを実行するソースをコンパイルする際はlibcunit.aにリンクしておいてください。

gcc unit.c  -Wall -L/usr/local/lib -lcunit -o unit

コンソールで実行した場合は以下のようになります。
image.png

Jenkinsとの連携方法

CUnitの結果をJenkinsに取り込む方法について説明します。

テストコードでXMLを出力するようにする

Jenkinsに渡すXMLファイルを作成するようにコードを変更します。
XMLファイルに出力する場合、CU_console_run_testsの代わりにCU_automated_run_testsとCU_list_tests_to_fileを使用します。

/**略**/
    /** コンソール出力
    CU_console_run_tests();
    */
    /** XML の出力 **/
    CU_set_output_filename("./unit");
    CU_automated_run_tests();
    CU_list_tests_to_file();
    CU_cleanup_registry();
/**略**/

上記のファイルを変更してコンパイルして実行するとコンソールに出力する代わりに下記のXMLファイルが作成されます。

  • unit-Listing.xml
  • unit-Results.xml

CUnitの実行方法の詳細については下記を参照してください。
http://cunit.sourceforge.net/doc/running_tests.html#overview

Jenkinsでテスト結果のXMLを解析するようにする。

プラグインの管理でxUnit Pluginをインストールします。
image.png

ジョブの設定でビルド後の処理に「Publish xUnit test result report」を追加してCUnit-2.1を選択します。

image.png

Patternにはunit-Results.xmlを指定してください。

ビルドを実行すると以下のような結果が作成されます。
image.png

image.png

テスト用のスタブの作成

テストを自動化する場合、xUnitのテストフレームワークだけを用意して自動化をしようとすると、導入に失敗するケースが多いです。
単純なユーティリティー関数しかテストできずに廃れていくか、あるいは、依存するライブラリの整合性をとるために複雑なテストコードを記述する羽目になって破綻するかのどちらかです。

たとえば以下のようにライブラリ1に依存するライブラリ2があるとします。
image.png

ライブラリ2をテストする場合、ライブラリ1のテスト用のスタブを作成し、ライブラリ2のテストの都合のいいデータを返すようにします。
そうすることで、仮にライブラリ1がネットワークやデータベースに依存していたとしても、実際にはそれらを使用せずにテストに都合のいい状態にすることができます。
image.png

単純なスタブの作成例

単純な例でライブラリ1に依存しているライブラリ2のテストを書く方法について考えてみましょう。

ライブラリ1のサンプル

ライブラリ1は以下のような実装とします。
引数を2つ受け取り、それを加算した結果を返すだけのaddという関数を用意します。

static1.h

# ifndef STATIC1_H
# define STATIC1_H
extern int add(int x, int y);
# endif

static1.c

# include "static1.h"

int add(int x, int y) {
    return x + y;
}
ライブラリ2のサンプル

ライブラリ2は以下のような実装とします。
ライブラリ2ではproc関数でライブラリ1のadd関数を利用して処理をしています。

static2.h

# ifndef STATIC2_H
# define STATIC2_H

# include "static1.h"

extern int proc(int x, int y, int z);

# endif

static2.h

# include "static2.h"

int proc(int x, int y, int z) {
    int a = add(x,y);
    return add(a, z);
}
ライブラリ1のスタブ

本物のコードの代わりに、スタブを作成します。
このスタブの処理はadd関数が実行されるたびに以下の処理を行います。
・呼び出された回数の記録
・テストコードで事前に登録されたコールバック関数の実行

stub_static.h

# ifndef STUB_static1_H
# define STUB_static1_H
# include "static1.h"

typedef int (*pfunc_add_def) ( int x , int y );

typedef struct {

    int count_add;
    pfunc_add_def pfunc_add;
} stub_data_def_static1;
extern stub_data_def_static1 stub_data_static1;
# endif

stub_static.c

# include "stub_static1.h"
stub_data_def_static1 stub_data_static1;

int add ( int x , int y ) {
    ++stub_data_static1.count_add;

    return stub_data_static1.pfunc_add( x , y );

}
テストコードの例

add関数のスタブから実行されるコールバック関数をテストコードに定義して、proc関数を実行する例を以下に示します。

# include <CUnit/CUnit.h>
# include "static2.h"
# include "stub_static1.h"

int test001_stub_add(int x, int y) {
    /** add()のスタブ。CUNITでテストするならxとyを **/
    if (stub_data_static1.count_add == 1) {
        /** 1回目の呼び出し **/
        CU_ASSERT_EQUAL(x , 10);
        CU_ASSERT_EQUAL(y , 5);
        return 15;
    }
    if (stub_data_static1.count_add == 2) {
        CU_ASSERT_EQUAL(x , 15);
        CU_ASSERT_EQUAL(y , 4);
        return 19;
    }
    CU_FAIL("addの呼び出し回数不正");
    return -1;
}

void test001() {
    memset(&stub_data_static1, 0, sizeof(stub_data_static1));
    stub_data_static1.pfunc_add = test001_stub_add;
    int ret = proc(10, 5,4);

    /** 呼び出し回数の確認 **/
    CU_ASSERT_EQUAL(stub_data_static1.count_add , 2);

    CU_ASSERT_EQUAL(ret , 19);
}

int main() {
    CU_pSuite suite;

    CU_initialize_registry();
    suite = CU_add_suite("stubsample", NULL, NULL);
    CU_add_test(suite, "test001", test001);
    CU_console_run_tests();
    CU_cleanup_registry();

    return(0);
}

スタブ用関数が複数回呼び出される場合は、呼び出し回数を元に、スタブが受け取る予定の引数の値をチェックし、スタブが返すべき値を決定します。

スタブの自動作成

前項でスタブを利用した簡単なテストコードの例を紹介しました。
次に問題となるのが、スタブの作成コストです。

手で作成していくことも不可能ではないですが、ライブラリ1の関数が大量にある場合や、更新頻度が多い場合、手で修正していくのは辛いものがあります。
そこで、スタブを自動生成する方法について考えてみましょう。

基本的には単純な変換になります。
そのため、どんなプログラミング言語でもスタブの自動生成できますが、楽をしたいなら、テンプレートライブラリとC言語のパーサーが使えるプログラミング言語がよいでしょう。

Pythonでスタブを自動生成してみる

C言語のパーサーライブラリ

pycparserを使用することでC/C++のソースコードの解析が行えます。
https://github.com/eliben/pycparser

注意事項として、このライブラリはプリプロセッサで処理済みのコードに対してのみしか動作しません。そのため、実際、解析対象とするソースコードは以下のようにプリプロセッサを実行した結果を用いてください。

gcc -E static1.h

今回は以下のようなプリコンパイル済みのヘッダーファイルを解析するものとします。

解析対象のヘッダファイル

extern int add(int x, int y);
typedef struct {
 int x;
 int y;
} in_data_t;
typedef struct {
 int add;
 int minus;
} out_data_t;
extern void calc(in_data_t* i, out_data_t *o);
extern int max(int data[], int length);

ヘッダファイルを解析するサンプル

import sys
from pycparser import c_parser, c_ast, parse_file

# https://github.com/eliben/pycparser/blob/master/examples/cdecl.py
def _explain_type(decl):
    """ Recursively explains a type decl node
    """
    typ = type(decl)

    if typ == c_ast.TypeDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type)
    elif typ == c_ast.Typename or typ == c_ast.Decl:
        return _explain_type(decl.type)
    elif typ == c_ast.IdentifierType:
        return ' '.join(decl.names)
    elif typ == c_ast.PtrDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type) + '*'
    elif typ == c_ast.ArrayDecl:
        arr = 'array'
        if decl.dim: 
            arr = '[%s]' % decl.dim.value
        else:
            arr = '[]'

        return _explain_type(decl.type) + arr

    elif typ == c_ast.FuncDecl:
        if decl.args:
            params = [_explain_type(param) for param in decl.args.params]
            args = ', '.join(params)
        else:
            args = ''

        return ('function(%s) returning ' % (args) +
                _explain_type(decl.type))

    elif typ == c_ast.Struct:
        decls = [_explain_decl_node(mem_decl) for mem_decl in decl.decls]
        members = ', '.join(decls)

        return ('struct%s ' % (' ' + decl.name if decl.name else '') +
                ('containing {%s}' % members if members else ''))

def show_func_defs(filename):
    # Note that cpp is used. Provide a path to your own cpp or
    # make sure one exists in PATH.
    ast = parse_file(filename, use_cpp=False,
                     cpp_args=r'-Iutils/fake_libc_include')
    if not isinstance(ast, c_ast.FileAST):
        return

    for ext in ast.ext:
        if type(ext.type) == c_ast.FuncDecl:
            print(f"function name: {ext.name} -----------------------")
            args = ''
            print("parameters----------------------------------------")
            if ext.type.args:
                for arg in ext.type.args:
                    print(f"{_explain_type(arg)} {arg.name}")
            print("return----------------------------------------")
            print(_explain_type(ext.type.type))

if __name__ == "__main__":
    if len(sys.argv) > 1:
        filename  = sys.argv[1]
    else:
        exit(-1)

    show_func_defs(filename)

解析処理の簡単な流れとしては、まずparse_fileを実行結果としてFileASTが取得できます。
そこのextプロパティを解析することで、ファイルに定義している関数の情報を取得できます。
上記のプログラムを実行してヘッダファイル中の関数定義を調べた結果が以下のように出力されます。

C:\dev\python3\cparse>python test.py static1.h
function name: add -----------------------
parameters----------------------------------------
int x
int y
return----------------------------------------
int
function name: calc -----------------------
parameters----------------------------------------
in_data_t* i
out_data_t* o
return----------------------------------------
void
function name: max -----------------------
parameters----------------------------------------
int[] data
int length
return----------------------------------------
int

このようにpycparserを使用することでC言語のソースファイルの解析が容易に行えることが確認できました。

テンプレートライブラリ

スタブ用のヘッダファイルとソースファイルを作成する際に、テンプレートライブラリを使用すると作成が楽になります。
Pythonでのテンプレートライブラリについては下記を参考にしてください。

Pythonで久しぶりにHTMLを出力したくなったのでテンプレートについて調べる
https://needtec.sakura.ne.jp/wod07672/?p=9162

今回はJinja2を利用しています。

スタブ作成用コード

C言語のパーサーとテンプレートライブラリを使用して、スタブ作成ツールを実装した例が以下のようになります。

create_stub.py

import sys
import os
from pycparser import c_parser, c_ast, parse_file
from jinja2 import Template, Environment, FileSystemLoader

stub_header_tpl = """
# ifndef STUB_{{name}}_H
# define STUB_{{name}}_H
# include "{{name}}.h"
{% for func in function_list %}
typedef {{func.return_type}} (*pfunc_{{func.name}}_def) ({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.typedef}} {{arg.name}}{{arg.array}} {% endfor %});
{% endfor %}

typedef struct {
{% for func in function_list %}
    int count_{{func.name}};
    pfunc_{{func.name}}_def pfunc_{{func.name}};
{% endfor %}
} stub_data_def_{{name}};
extern stub_data_def_{{name}} stub_data_{{name}};
# endif
"""

stub_source_tpl = """
# include "stub_{{name}}.h"
stub_data_def_{{name}} stub_data_{{name}};

{% for func in function_list %}
{{func.return_type}} {{func.name}} ({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.typedef}} {{arg.name}}{{arg.array}} {% endfor %}) {
    ++stub_data_{{name}}.count_{{func.name}};
    {% if func.return_type != "void" %}
    return stub_data_{{name}}.pfunc_{{func.name}}({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.name}} {% endfor %});
    {% else %}
    stub_data_{{name}}.pfunc_{{func.name}}({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.name}} {% endfor %});
    {% endif %}
}
{% endfor %}
"""

class ParameterData():
    def __init__(self, name, typedef):
        self.__name = name
        self.__typedef = typedef
        self.__array = ""
        if '[' in typedef:
            self.__array = "[" + typedef[typedef.index("[")+1:typedef.rindex("]")] + "]"
            self.__typedef = typedef[0: typedef.index("[")]

    @property
    def name(self):
        return self.__name

    @property
    def typedef(self):
        return self.__typedef

    @property
    def array(self):
        return self.__array

class FunctionData:
    def __init__(self, name, return_type):
        self.__name = name
        self.__return_type = return_type
        self.__args = []

    @property
    def name(self):
        return self.__name

    @property
    def return_type(self):
        return self.__return_type

    @property
    def args(self):
        return self.__args

# https://github.com/eliben/pycparser/blob/master/examples/cdecl.py
def _explain_type(decl):
    """ Recursively explains a type decl node
    """
    typ = type(decl)

    if typ == c_ast.TypeDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type)
    elif typ == c_ast.Typename or typ == c_ast.Decl:
        return _explain_type(decl.type)
    elif typ == c_ast.IdentifierType:
        return ' '.join(decl.names)
    elif typ == c_ast.PtrDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type) + '*'
    elif typ == c_ast.ArrayDecl:
        arr = 'array'
        if decl.dim: 
            arr = '[%s]' % decl.dim.value
        else:
            arr = '[]'

        return _explain_type(decl.type) + arr

    elif typ == c_ast.FuncDecl:
        if decl.args:
            params = [_explain_type(param) for param in decl.args.params]
            args = ', '.join(params)
        else:
            args = ''

        return ('function(%s) returning ' % (args) +
                _explain_type(decl.type))

    elif typ == c_ast.Struct:
        decls = [_explain_decl_node(mem_decl) for mem_decl in decl.decls]
        members = ', '.join(decls)

        return ('struct%s ' % (' ' + decl.name if decl.name else '') +
                ('containing {%s}' % members if members else ''))

def analyze_func(filename):
    ast = parse_file(filename, use_cpp=False,
                     cpp_args=r'-Iutils/fake_libc_include')
    if not isinstance(ast, c_ast.FileAST):
        return []
    function_list = []
    for ext in ast.ext:
        if type(ext.type) != c_ast.FuncDecl:
            continue
        func = FunctionData(ext.name,  _explain_type(ext.type.type))
        if ext.type.args:
            for arg in ext.type.args:
                param = ParameterData(arg.name, _explain_type(arg))
                func.args.append(param)
        function_list.append(func)
    return function_list

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("python create_stub.py プリプロセス実行済みヘッダ 出力フォルダ")
        exit(-1)
    filename  = sys.argv[1]
    dst_folder  = sys.argv[2]
    function_list = analyze_func(filename)
    if len(function_list) == 0:
        print("関数を見つけることができませんでした")
        exit(-1)
    data = {
        'name' : os.path.splitext(filename)[0],
        'function_list' : function_list
    }
    # スタブのヘッダーファイルの作成
    with open(f"{dst_folder}/stub_{data['name']}.h", mode='w', encoding='utf8') as f:
        f.write(Template(stub_header_tpl).render(data))

    # スタブのソースファイルの作成
    with open(f"{dst_folder}/stub_{data['name']}.c", mode='w', encoding='utf8') as f:
        f.write(Template(stub_source_tpl).render(data))

このツールを利用して作成したスタブは以下の通りです。

stub_static1.h


# ifndef STUB_static1_H
# define STUB_static1_H
# include "static1.h"

typedef int (*pfunc_add_def) ( int x , int y );

typedef void (*pfunc_calc_def) ( in_data_t* i , out_data_t* o );

typedef int (*pfunc_max_def) ( int data[] , int length );

typedef struct {

    int count_add;
    pfunc_add_def pfunc_add;

    int count_calc;
    pfunc_calc_def pfunc_calc;

    int count_max;
    pfunc_max_def pfunc_max;

} stub_data_def_static1;
extern stub_data_def_static1 stub_data_static1;
# endif

stub_static.c


# include "stub_static1.h"
stub_data_def_static1 stub_data_static1;

int add ( int x , int y ) {
    ++stub_data_static1.count_add;

    return stub_data_static1.pfunc_add( x , y );

}

void calc ( in_data_t* i , out_data_t* o ) {
    ++stub_data_static1.count_calc;

    stub_data_static1.pfunc_calc( i , o );

}

int max ( int data[] , int length ) {
    ++stub_data_static1.count_max;

    return stub_data_static1.pfunc_max( data , length );

}

C言語のパーサーが利用できないとき

Pythonが使えなかったりC言語のパーサーが利用できなかったりする状況もよくあります。
そういう場合は、doxygenを利用します。

Doxygenはソースファイルを解析して関数の情報をXMLに出力することができます。このXMLをもとにスタブ用のファイルを作成することも可能です。CやC++をやっている現場の多くはdoxygenを使用しているケースが多いので、おそらくPython使うよりは導入の障壁が低いと考えられます。※

※なお、私はDoxyge+VBSでスタブを作っていました。

その他スタブやモックの作成方法について

実際には採用しませんでしたが、以下のような方法もあります。

CMock
https://github.com/ThrowTheSwitch/CMock
Rubyを使用してモック用のコードを作成する模様。当該環境でRubyが使えるならありかもしれません。

C言語のテストでスタブ関数を使うためのアイデア
https://qiita.com/HAMADA_Hiroshi/items/e1dd3257573ea466d169
「既存ソースに手を入れずに同一ソース内の関数をスタブにしてユニットテストを実施する方法」をまとめたもので、基本的な考えはマクロを駆使して既存関数を別名に置き換えるということをやっています。
幸い、同一ソース内の関数を呼ぶようなテストを書く必要がなかったので実際には採用していません。

網羅率の計測方法

テストコードを記述した場合、そのテストコードが網羅している割合を計測することで、もれなくテストを実行しているかを判断することが可能になります。
幸いなことにgccにはgcovという網羅率を計測するツールがついています。

10 gcov—a Test Coverage Program
https://gcc.gnu.org/onlinedocs/gcc/Gcov.html

gcov の使い方
https://mametter.hatenablog.com/entry/20090721/p1

カバレッジの計測例

先ほど使用したスタブの例でカバレッジを計測してみます。今回はわざと未到達なルートを作るため、ライブラリ2のstatic2.cを以下のように修正します。

static2.c

# include "static2.h"

int proc(int x, int y, int z) {
    int a = add(x,y);
    return add(a, z);
}

int proc2() {
    /** 未到達 **/
    return 0;
}

その後、カバレッジを計測するために-coverageオプションを付与してコンパイルをします。

gcc -c -Wall -Wextra static2.c -coverage
ar r libstatic2.a static2.o 
gcc -c -Wall -Wextra stub_static1.c
ar r libstub_static1.a stub_static1.o
gcc test.c -coverage  -Wall -Wextra -L. -lstatic2 -lstub_static1   -L/usr/local/lib -lcunit -o test

上記のコマンドを実行すると以下のファイルが作成されます。

  • static2.gcno
  • test.gcno
  • test

その後、testを実行することでさらに以下のファイルが作成されます。

  • static2.gcda
  • test.gcda

テスト対象のstatic2.cのカバレッジを調べるには以下のコマンドを実行します。

gcov static2.gcda 

このコマンドを実行すると下記のメッセージが表示されます。

File 'static2.c'
Lines executed:60.00% of 5
Creating 'static2.c.gcov'

ここで網羅率が60%であることが確認でき、さらにどこが網羅されていないかを調べるにはstatic2.c.gcovを表示します。
今回の例ではproc2が通っていないことが確認できます。
image.png

その他自動テストについて

効果について

以下の点で効果的と判断します。

・現状の機能の挙動を変えずに機能を拡張する際のリスクが抑えられる
・テストコードを記載することで、今まで闇の中だった機能を明確にできる。
・テストコードを書くと既存コードの中に眠り続けてきた不具合を検出できる
  →直すかどうかは別問題。発生頻度や発生時の影響度と修正コストの兼ね合い。

ただ、幾つの点で課題があるのでそれについては次項で考えていきましょう。

SQLの試験はどうするの?

レガシーな環境だと、ソースコードにSQLを書いていて、実際それをデータベースにつなげて動かさなければテストにならないことがあります。
この場合、開発者毎に使用するDBの領域を区切って単体テストを行うのが望ましいです。

しかしながら、大きな組織だとDBを専門に扱う部門があったりして、安易にデータベースをいじれなかったりします。
またローカル環境にデータベース自体を入れるとした場合、貧弱なPCがネックになったり、仮にそれを突破しても、個人の環境でデータベースの管理を任せた場合、開発者によっては古い環境を使い続けたりするリスクもあったりします。

こういう時は自動テストが終わったらロールバックするという意識の低い方法で対応することができます。

①自動テスト開始
②DBのトランザクションを開始する
③テスト用のデータをDBに格納
④自動テストを行う
⑤自動テストの結果でDBがどう変わったか確認
⑥DBのロールバック

この方法は完璧な案ではありませんが、実現が容易な案です。

大量の組み合わせテスト

構造体の配列で入力値と期待値を書いておいておけば大量のテストが行えます。
よく使う手としてはExcelで入力と出力の一覧を書いておいて、VBAのマクロでC言語の構造体の配列に変換してテストコードに書き込むやり方があります。

保守的なウォーターフォールな環境でテストファーストを行う歪み

保守的なウォーターフォールな環境で採用されている方法だと、実装が終わった後に、単体テストのテスト仕様書やチェックリストを作成して、テストを実施することになります。
しかし、テストファースト的なことをやると、実装完=単体テスト完になるので、ここで歪みが発生してしまいます。

計画が従来の線に乗らない

従来の方式だと実装、単体テスト用チェックリスト作成、単体テストの実施の3つの工程にわけてスケジュールの線を記述します。
そして、高度に管理された完全で完璧な組織だと、それぞれの工程の時間をきっちり計測します。

テストファースト的なことをやると、実装から単体テストがり分けることのできないものになるので、個別の作業時間が計測不能となり、適当に分けて報告することになります。

単体テスト仕様書

従来の方法で作成していた単体テスト仕様書やチェックリストの取り扱いをどうするか決める必要があります。
テストコードのコードレビューをもってOKとし、単体テストの成果物としてはテストコードとするという判断ができればいいのですが、レガシーな環境だと書類が大事です。
この場合、テストコードからテスト仕様書やチェックリストといったものを作成する必要があります。

1つ目の対応策としては、テストコードの内容を手でチェックリストに転記していくという方法です。
問題点は、テストコードとテスト仕様書の同期がとれなくなっていくことです。
また、この方法は自動でテストを実行する利点をなくす非常にまずい対応ですが、時間さえかければ誰でもできるのでよく採用されます。

2つ目の方法は、テストコードのコメントからテスト仕様書を起こす方法です。
テストコードのコメントにテストの内容を記載しておいてDoxygen等で抜き出した結果をチェックリストにまとめます。
この場合、コメントと実際の処理の食い違いが発生するリスクがあります。

3つ目の方法は、テストコード中に指定するテスト名にチェックリストに出力しても違和感のない日本語を書いてしまう方法です。

    CU_add_test(max_suite, "max(a,b)を実行してa=5,b=4の場合、aの値が取得されることを確認する", test_max_001);

上記のように記載してテストを実行するとテスト結果のXMLには以下のように出力されます。
image.png

あとは、XMLをチェックリストの出力してしまえば完了です。
この方法の問題点は、CU_ASSERT_EQUALなどで行うチェック内容はXMLに出力されないので、チェックリストの粒度が大きくなってしまう点です。

4つ目の方法としては、CU_ASSERT_EQUALなどをラップする関数を作成して、その関数内で、チェックリストに出力しやすい形で検査内容をファイル出力してしまう方法です。

いずれの方法をとるにせよ、書面にこだわられると面倒臭いので注意しましょう。
 →書面にこだわる文化のところでは、そういう作業分もちゃんと見積もりに乗せるようにしましょう。

単体テストでの障害票の取り扱い

実装を終了した時点で単体テストが終わっているので、単体テストでの障害を上げるのは不可能になっています。
とはいえ、高度に管理された完全で完璧な組織だと単体テストの障害件数を重視する場合があります。
この場合は、実装中に出た問題を、それっぽいバグとして適当にでっちあげることになります。

本来あるべき論としては説得して新しいルールを作ることですが、文化がちがうので無理だと思います。

テスト件数の桁が変わるので従来の指標が役に立たなくなる

自動テストを導入すると組み合わせ系のテストを機械的に大量に処理できるようになります。
結果、従来の方法で行ったテストの件数より自動で行ったテストの件数が多くなります。(それこそ桁違い)

いままで精々数百のチェックリストの項目数だったものが、普通に千とか万とかになります。
「テストを大量にやってなにが悪いのかね」という話なんですが、高度に管理された完全で完璧な組織だと異常値扱いされるので、粒度を適当に調整して、報告用のそれっぽい件数に調整したりする必要があったりします。

なお、まっとうな対応をするのであるならば、自動テストは別集計として新しい指標を作っていくのが筋でしょう。

結合テスト以降の障害が従来より減るので異常値扱いされる

単体テストを網羅的にやると、結合テストでもなかなか不具合がでなくなります。

これはこれで問題になります。
たとえば、従来、何十件も障害が発生していたところ、不具合件数がほぼでなくなったとしたとしましょう。
数値しか見てない場合、結合テストの観点が間違っているんじゃないだろうかという疑念を抱く人がいてもしょうがないでしょう。

テストコード書きたがらない人が多い

以下でも書きましたが、そういうもんです。あきらめましょう。

意識が高くないVisualStudioを使用した単体テストの自動化
https://needtec.sakura.ne.jp/wod07672/?p=9213

自動テストの効果を工数や品質で利を説いたり、金額に換算して危機感あおったり、教育用の資料を用意することはできますが、正直、そんな話をいくら積んだところで通じない文化には全く通じません。

ただし、どんな組織においても、志のある人間というのが少数ながらいるモノなので、そういう人間に期待しましょう。

静的解析

前項で自動テストの話をしました。
これは結局、テストコードを書いた範囲を実際に動かしてチェックしているにすぎません。
この項では、実際にテストコードを書いて動かすことなく、怪しげな実装箇所を見つける方法について説明します。

静的解析ツールで怪しい箇所を見つける

静的解析ツールを使用することで怪しげな実装をしている箇所を検出可能です。
お高いツールを使うと効果的な発見をしてくれますが、オープンソースでも十分効果的です。

C言語の場合だとCppCheckを使用することができます。
http://cppcheck.sourceforge.net/

cppcheckはWindowsからGUIで動かすこともできますし、linuxでCUIベースに動かすこともできます。

コマンドラインから使用する方法

Windows10 + Ubuntu 18.04の環境下でcppcheckを使用する方法を説明します。

まず下記のコマンドでインストールします。

sudo apt-get install cppcheck

インストールが完了したら以下のようなコマンドを実行することでcppcheckが実行されます。

$ cppcheck --enable=all .
Checking cov.c ...
1/7 files checked 14% done
Checking main.c ...
2/7 files checked 28% done
Checking static1.c ...
3/7 files checked 42% done
Checking static2.c ...
4/7 files checked 57% done
Checking stub_static1.c ...
5/7 files checked 71% done
Checking test.c ...
6/7 files checked 85% done
Checking unit.c ...
7/7 files checked 100% done
[cov.c:7]: (style) The function 'bar' is never used.
[static1.c:7]: (style) The function 'calc' is never used.
[static2.c:8]: (style) The function 'proc2' is never used.
(information) Cppcheck cannot find all the include files (use --check-config for details)

WindowsのGUIでcppcheckを使用してみる

(1)以下のページからInstallerをダウンロードしてください。
http://cppcheck.sourceforge.net/

(2)インストール後、CppCheckを起動すると以下のような画面が開きます。
image.png

(3)メニューから「ファイル」→「新規プロジェクト」を選択します。
image.png

(4)プロジェクトファイルを保存する任意のパスを設定して「保存」を押下します。
image.png

(5)「プロジェクトファイル」ダイアログが開くので「追加」ボタンを押下してC言語のソースファイルがあるパスを追加してください。
image.png

image.png

image.png

(6)「プロジェクトファイル」ダイアログでOKを押下すると、ビルドディレクトリを作成するか聞かれるので「Yes」を選択します。
image.png

(7)指定したディレクトリ以下のCファイルが列挙されます。
image.png

(8)列挙されたファイルのツリー構造を展開すると警告の詳細が表示されます。
image.png

Jenkinsとの連携

JenkinsのCppcheck Plulginを使用することでCppCheckの結果をJenkinsで集計させることが可能です。
https://plugins.jenkins.io/cppcheck

(1)JenkinsのプラグインマネージャーでCppCheckを選択してインストールします。
image.png

(2)cppcheckを以下のように実行してxmlで結果を取得します。

cppcheck --enable=all --xml --xml-version=2 ~/libsample/. 2>cppcheck.xml

(3)ビルド後の処理にPublsh Cppcheck resultsを追加します。
image.png

image.png

(4)ビルドを実行すると、結果にCppCheckの項目が表示されます。
image.png

image.png

メトリクスを計測する

ソースコードのステップ数や、循環的複雑度はソースコードの品質を予測するための一つの指標になります。

循環的複雑度(Cyclomatic complexity)
https://ja.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E7%9A%84%E8%A4%87%E9%9B%91%E5%BA%A6

循環的複雑度は簡単にいうと、ループや分岐が多いほどこの複雑度は高くなります。
複雑度が高ければ高いほどバグを含む可能性が高くなるので、この指標をもとにテストを重点的に行うべき場所にあたりをつけることができます。

Source Monitor

Source MonitorはC ++、C、C#、VB.NET、Java、Delphi、Visual Basic(VB6)で書かれたソースコードのメトリクスを計測することが可能なフリーソフトです。

(1)下記のページからダウンロードして任意のフォルダに展開してください。
http://www.campwoodsw.com/sourcemonitor.html

(2)展開されたSourceMonitor.exeを実行すると以下のような画面が表示されるので「Start SourceMonitor」を選択してください。
image.png

(3)メトリクスを集計するプロジェクトを作成するのでメニューから[File]->[New Project]を選択します。
image.png

(4)「Select Language」ダイアログが開くので「C」を選択して「次へ」を押下します。
image.png

(5)プロジェクトファイルのファイル名と格納するフォルダを指定して「次へ」を押下します。
image.png

(6)解析対象のソースコードを含むフォルダを指定して「次へ」を押下します。
image.png

(7)オプションを選択し「次へ」を押下します。
image.png

オプション 説明
Use Modified Complexity Metrix デフォルトの複雑度メトリックは、各switchステートメント内のcaseをカウントして計算していますが、チェックをONにすることで各switchブロックは1回だけカウントされるようになり、個々のcaseステートメントはModified Complexityに寄与しなくなります。
Do not count blank line 行数を数える際に、空行を無視します。
Ignore Continuous Header and Footer Comments CVSやVisual SourceSafeなどのバージョン管理ユーティリティによって自動的に生成されたファイル履歴などの情報を含む連続したコメント行を無視したい場合にチェックをつけます。

(8)「次へ」を押下します。
image.png

(9)UTF8のファイルを含むなら、「Allow parsing of UTF-8 files」にチェックを付けて「次へ」を押下します。
image.png

(10)「完了」を押下します。
image.png

(11)計測対象のファイルの一覧が表示されるので、確認後、OKを押下します。
image.png

(12)計測結果が表示されます。
image.png

リストをクリックするとファイルの一覧が表示されます。
image.png
image.png

リスト中のファイルのアイテムをクリックすると詳細が表示されます。
image.png

リスト中のファイルのアイテムを右クリックしてコンテキストメニューを表示して「View Source File」を選択することでファイルの内容が確認できます。
image.png
image.png

よく利用する数値

名前 説明
Lines ソースファイル内の物理行の数。プロジェクト作成に「Do not count blank line」をチェックした場合は空白行を含まない。ざっくりした規模をみるのに使用する
Max Complexity 循環的複雑度。プロジェクト作成時の「Use Modified Complexity Metrix」のチェックの有無によりswitch句のcaseの取り扱いが変わる。この数値が多いほどテストしずらい。
Max Depth ネストの深さの最大。ネストが深いコードも複雑になる傾向があるので注視する
% Comment コメントの割合。0%とか逆に大きすぎる場合、チェックした方がいい。

類似コードを探す

以下で紹介したPMD-CPDを使用することでコピペされたコードを検出することが可能です。

僕にそのコピペを直せというのか
https://needtec.sakura.ne.jp/wod07672/?p=9264

開発時と違い、類似のコードを探し出して共通化することが目的で使用はしていません。
すでに稼働してしまったシステムは、開発中と違い、コードがコピペされていようが、共通化などのリファクタリングを安易にすることはできません。

こういう状況でコードクローンを検出する目的は、例えば障害対応や機能追加が発生して、あるソースコードを修正する時、他に修正が必要な類似箇所があるかを調べるために使用します。

例えば、障害を出すと当然、「別の箇所は大丈夫?」とか問われることになります。
この際、「コードクローン検出ツールを使用して類似箇所を網羅的に調べて問題ないことを確認しました」とか、それっぽい事を言ってごまかすことができます。

静的解析ツール関係まとめ

実際にコードを動かさずに、コードの評価を行い修正すべき箇所を見つけることができます。

保守的な環境でテストコードを導入するコストは滅茶苦茶かかりますが、静的解析ツールの採用は低コストで行えるので、多くの組織で活用できると思います。

テスト環境まわりの話

テスト時の環境についての話をいくつかさせていただきます。
なお、この章から結合テストの話も出てくるので「C言語で作られたシステム」のタイトル詐欺になりつつあります。

単体テスト環境の分離

別システムが関係したりしている結合テスト時ならばともかく、単体テスト時にも下記のような構成でテストを行う現場があります。
image.png

この構成のまずい点は以下の通りです。
・お互いのテストが影響を与えて、本来合格するケースが不合格になったり、その逆のケースがあったりすることが頻発する
・単体テストという不安定な状況のため頻繁にテスト環境を入れ替える必要があり、そのたびに他の開発者の作業が止まる。

つまりテスト容易性が極めて悪くなります。このため、単体テストあたりまでは以下のように開発者毎にテスト環境を用意するのが望ましいです。

image.png

開発者毎にテスト環境をあたえるため、ひと昔前はLinuxで動くプログラムを無理やりWindowsのVisualC++でビルドして動かしたりしてました。
しかしながら、昨今はVMWareやVirtualBoxで仮想環境を構築できるのでそれを利用すべきでしょう。

なお、開発者に与えられたPCのメモリが4GBだったときは、仮想環境で云々はあきらめることになると思います。

テスト環境へのリリースの自動化の話

複数人が触るテスト環境にリリースする際は、なるべく自動で行えるようにしたほうがいいです。
リリース時の影響を最小限におさえるために、テスト環境が止まっている時間を短くしたり、だれも使っていないときにリリースする必要があります。これはテスト環境へのリリース作業を自動化しておくことで対応可能になります。

また、テスト環境へのリリースを記録として残すことで、どの時点で、どのリビジョンの資材が使われていたかを追跡可能になります。
Jenkinsとかの特定のツールにこだわる必要はなく、シェルスクリプトやバッチファイルでいいので自動でできるようにするといいでしょう。

なお、以下の記事のテクニックを使うことでWindowsから資材をWinSCPでアップしたあとにTeraTerm経由でスクリプトを実行して結果をメールで送信するということも可能になると思います。

WinSCPを自動化しても別にかまわんのだろ
https://needtec.sakura.ne.jp/wod07672/?p=9178

TeraTermのマクロをためしてみる
https://needtec.sakura.ne.jp/wod07672/?p=9177

RedmineをあきらめたオレたちのPowerShellでのOutlookの自動操作
https://needtec.sakura.ne.jp/wod07672/?p=9188

結合テスト時の大量データ作成の自動化

結合テスト時に、大量のデータを作成が必要な場合があります。
この際、結合テストの観点で「使用するデータはシステムで作成できるものとする」とあると、データベースに対してSQLでデータを流し込むという方法ができません。

システムが用意している画面を操作して大量のデータを登録するには、以下の「Windowsの画面操作を自動化しよう」で紹介しているテクニックが使用できます。

自称IT企業があまりにITを使わずに嫌になって野に下った俺が紹介するWindowsの自動化の方法
https://needtec.sakura.ne.jp/wod07672/?p=9163

ただし、採用するツールや操作対象のアプリケーションの特性上、入力エラー等をすり抜けるケースがあるので、そこは気をつけて採用する必要があります。

結合テストの画面操作を自動化できない?

以下の理由から対象を絞ったほうがいいと思います。

・採用するツールや操作対象のアプリケーションの特性上、自動操作が手動操作とまったく一緒にならないケースがある。
・画面操作の自動化は基本的に安定しない
・画面操作の自動操作は作成コストがかかる。

私がやるなら、スモークテストのための画面操作テストや、大量の繰り返し処理(大量データの作成や負荷試験)に絞って行います。

敗北の記録

理想的だが無駄だった話

世の中には理想的ではあるが、実際問題として導入できない無駄な話がいくつかあります。
ここでは、そういった無駄だった話を紹介したいと思います。

テスト管理ツールの導入の話

image.png

image.png

テスト工程の管理をするツール、TestLinkについて
https://needtec.sakura.ne.jp/wod07672/?p=9198

…ってなことを10年来いろんなとこで提案してますが、基本、SIerのような環境では厳しいと思います。

こういうツールに興味がある組織はすでに使っていますし、興味のない組織はテスト管理自体に本当に興味ないので、勝算の薄いツールの導入に色々なコストをかけるくらいなら、やるべきことは他にあると思います。

バグトラッキングシステムの導入の話

バグトラキングシステムを導入することでバグの履歴を管理できたり、だれがどれだけバグを抱えているかの状況判断ができたり、ソースコードとの紐づけなどが管理できるようになります。

残念ながら、ある種の状況では、そういうあるべき論に時間かけるより、「バグ票の書き方」といった基本的な教育に力を使った方がいい状況があるのだと思い知りました。

Wikiによる検索可能なナレッジの蓄積

検索性や履歴の保持を考えた場合、Office文章でナレッジを蓄えるよりWikiを採用した方が望ましいのです。

しかしながら、ある種の環境にいるIT技術者の多くは、文章を書くのにExcelしか使ったことがないケースがあるので、無理な導入はやめましょう。

無駄な話の次善策

上記の理想的な話が全て無駄話になっている現場においては、テスト仕様書、バグ管理票、ナレッジ文章はExcelやWordで記述されて共有フォルダで管理されていることでしょう。

この問題点は2点です。
1つ目は検索性の問題
2つ目は構成管理の問題です。

これらの問題にどう対応すべきか考えましょう。

検索性の問題

Office文章の検索については、いくつかのフリーソフトを使えば行えます。
なんなら、VBSなどで作成することができます。

なお、Officeの検索には時間がかかると思うので、可能であればキャッシュを使っているソフトを採用した方がストレスが溜まらないと思います。

構成管理の問題

共有フォルダに無造作に置かれたファイルは、いつだれが、どんな修正したかが不明になります。
また、誤操作かなにかで稀によく消えたりします。

この対応として共有フォルダでなく構成管理ツールを使用してドキュメントを管理しましょう。

なお、構成管理ツール導入が不可能だった場合は下記の記事を参考にプランBを考えてください。

共有フォルダ教が支配する世界に転生した場合の対応案
https://needtec.sakura.ne.jp/wod07672/?p=9173

さいごに

長い年月を経た保守的な環境でテスト工程を改善してみようとして私が得た知見を紹介しました。
以下の記事でも書きましたが、特に長い年月を経た保守的な環境を改善しようとする場合、とりあえず現状を受け入れたうえで現実的な着地点を探す必要があります。

ぼくのかんがえたさいきょーの提案方法
https://needtec.sakura.ne.jp/wod07672/?p=9223

なお、そもそも「テスト工程を改善しよう」とかいうセリフ自体が

image.png

という反応をされてもしょうがないものなので、たまたま受け入れられたら喜べばいい程度のもんだと思います。