«前10日分

ClearCode - ククログ

2008|05|06|07|08|09|10|11|12|

インターンシップ参加希望者募集開始

クリアコードでは来年の夏にインターンシップを実施する予定です。(2009年8月〜9月を予定)

そこで、インターンシップ参加希望者の募集を開始しました。

詳しくは募集ページに書いていますが、ここにも簡単に概要を書いておきます。

応募条件

応募条件は「プログラミングが好きなこと」です。他は、学生であることとまとまった時間がとれることなので、「プログラミングが好きなこと」だけが条件といってよいかと思います。もっと具体的な条件にしたり、細かく条件を並べるよりも、一番大切なこと1つだけとしました。「プログラミングが好きなこと」がプログラミング技術が飛躍的に上達するために必須のことだからです。

カリキュラム

インターンシップでは、フリーソフトウェアの開発を参加者とクリアコードのエンジニアがペアプログラミングで行います。このようなカリキュラムにしたことにはいくつが理由がありますが、一番の理由はこのカリキュラムが参加者もクリアコードのエンジニアもともに成長できると信じているからです。

開発対象としてフリーソフトウェアを選んだことには2つの理由があります。1つはクリアコードのエンジニアが業務で活かしている技術はフリーソフトウェアの開発で身につけたものだからです。もう1つは業務でFirefox/ThunderbirdやRubyなどの既存のフリーソフトウェアを利用したり、新規に開発することも多いからです。フリーソフトウェアではない開発業務もありますが、その場合でもフリーソフトウェアの開発で身につけた技術は活きています。クリアコードの業務が成り立つのはフリーソフトウェアの開発に関わっているからといっても過言ではありません。そして、参加者にとってもフリーソフトウェアの開発に関わることが有益だと思っています。

インターンシップという短い期間で、参加者には2つのことを学んでほしいと考えています。1つはプログラミング技術の向上、あるいは、向上するための方法です。もう1つはソフトウェア開発業務についてです。フリーソフトウェアの開発をペアプログラミングで行うことでそれらを実現できると考えています。

ペアプログラミングで開発を行うことにより、参加者には技術面において短い期間で大きな効果がでることを期待しています。また、クリアコードのエンジニアも教えることで学んでいけると期待しています。経験がある方も多いと思いますが、教えることで自分の理解が進むことは多いものです。

また、フリーソフトウェアの開発という内容はクリアコードでのソフトウェア開発業務にも結びつきます。クリアコードでは業務の中でフリーソフトウェアを利用するだけではなく、開発することもあるからです。例えば、C言語用のテスティングフレームワークのCutterや迷惑メール対策を支援するmilter-managerなどです。

フリーソフトウェアの開発をペアプログラミングで行うというカリキュラムで、参加者もクリアコードのエンジニアもともに有意義なインターンシップになることを期待しています。

参加希望者の方へ

参加者もそうなると思いますが、このインターンシップはクリアコードにとっても初めてのインターンシップになります。よりよい内容にするためには試行錯誤を重ねていく必要があり、今はまだ最初の一歩です。そのため、現段階ではよりていねいにインターンシップを行うことができるように、今回は1名のみの募集としました。

興味がある方はインターンシップ応募申込書(Calc用|Excel用)に必要事項を記入し、minami@clear-code.comにメールで提出してください。疑問点なども同じアドレスで受け付けています。

余談ですが、インターンシップ参加希望者募集にあわせて会社紹介パンフレットを作成しました。(インターンシップページの最下部にリンクがあります。)

2008-12-25

会社紹介は一番最後

2週間ほど前になりますが、オブジェクト倶楽部2008冬イベント『オブラブ忘年会 〜ふりかえり2008〜』のライトニングトークスで話してきました。

発表資料: xUnit 2008

一番伝えたかったことは「テスティングフレームワークはデバッグ支援の機能を提供することが重要」だということです。時間もないので、その中でも「期待値と実際の値の違いの見せ方」についてだけ触れました。(資料でいうと24枚目からの話)

表向きは上の通りなのですが、今回の発表では1つ試してみたことがあります。それは、「会社紹介は一番最後がよいのではないか」ということです。一般的には自己紹介・会社紹介をしてから内容に入ることが多い気がしますが、それとは少し異なります。

最後の会社紹介は次のアクションの誘導

先日、Email Security Expo & Conference 2008(来年の今頃はこのURLになっていそう)に参加しました。参加の目的は情報収集です。商用の迷惑メール対策用製品がどこをウリにしているのかとどのようなWebインターフェイスを提供しているかに興味がありました。

現在、IPAの2008年度 オープンソフトウェア利用促進事業 上期テーマ型(開発) 公募に採択された「迷惑メール対策ポリシーを柔軟に実現するためのmilterの開発」のために、milter-managerを開発しているのですが、それを開発する上で参考にするためです。

イベントでは製品紹介のセッションを中心に受講したのですが、その中で気づいたことがありました。 プレゼン資料の中で、一番最後に会社紹介を持ってきているセッションが多かったのです。そして、いくつかのセッションを聴いているうちにこれはとてもよい方法だということがわかりました。

この方法が有効なのは、セッションを聴いて、この製品がよさそうだなぁと思ったときです。プレゼンの一番最後に会社紹介を持ってくることで、よさそうだと思ってもらった後のアクションを誘導することができるのです。

プレゼンしている側としては、製品を紹介し、興味を持ってもらった人に問い合わせてもらいたいものです。一番最後に会社紹介を持ってくることで問い合わせ先を伝え、問い合わせるというアクションを誘導しているのです。(イベントではブースコーナーもあったので会社名さえわかれば、ブースコーナーに行って、デモを試したりより詳しい説明を受けることができた)

一番最後に会社紹介がないセッションを聴いているときにも、この製品はよさそうだなぁと思い、デモを見にいこうと思うことがありました。しかし、プレゼンの最後の頃には最初の方にあった会社紹介の内容はすでに覚えていなかったため、配布されていた資料を見直して、自分でどの会社かを調べ直す必要がありました。興味を持った人にこのような手間をかけさせると、その手間のために次のアクションを起こさないかもしれません。せっかく興味を持った人がスムーズに次のアクションに進めるように、ちょうど良いタイミングで次のアクションのための情報を提供することは有効だと思います。

試したこと

このような気づきがあったので、オブジェクト倶楽部でのライトニングトークスでは「一番最後に会社紹介をする」ということを試してみました。今回は、話す内容のベースに「動作するきれいなコードを維持し続けることが重要」ということがあったので、それと会社紹介をつなげやすそうだったということもあります。(クリアコード→きれいなコード)

今回は、発表の内容で「動作するきれいなコードを維持し続けることが重要」だと思ってくれた人が「話している人の会社もきれいなコードを維持し続けるのか、よさそうな会社だな」と思ってくれるかどうかを試してみました。つまり、今回の「次のアクション」は「動作するきれいなコードを維持し続ける」人たちがいる会社としてクリアコードを認識してもらうということでした。

何人かには成功したようなので、次に話すときも試してみようと思っています。そして、次の「次のアクション」はもう少し行動を促すことにしようと思っています。

2008-12-17

tDiaryのデータをHTML化する

tDiaryをローカルなネットワークに配置して、tDiaryが表示する内容を静的なHTMLとして公開したい場合はよくありますよね。ククログもそんなよくある使い方の1つです。

tDiaryには静的なHTMLを生成するためのsqueezeプラグインがありますが、squeezeプラグインが出力するHTMLは以下の点でCGIで表示される内容と異なります。

  • 各日付のページしか生成しない
    • 最新の日記n件ページや月別ページやカテゴリページは生成しない
  • リンクがCGI用のリンクのままで、次の日記のページに移動するリンクが壊れている
  • テーマファイルや画像はコピーしてくれないので、生成したHTMLの入ったディレクトリ以下だけでは完結しない

ただし、これはsqueezeプラグインが検索エンジンへの入力データとしてのHTML生成を目的としているためです。よくある使い方では、生成されたHTMLはCGIで出力されているように表示できることが目的なので、上記のようなミスマッチが発生します。

そこで、ククログではhtml-archiver.rbという静的なHTMLを生成するスクリプトを使っています。html-archiver.rbは最後の方に載せています。

html-archiver.rbの使い方

html-archiver.rbを使うと、CGIで出力されている内容と同じように表示されるHTMLが生成されます。生成例は今見ているこのページです。

使い方はこうなります。

% ruby html-archiver.rb --tdiary tdiayr.rbのあるディレクトリ --conf tdiary.confのあるディレクトリ 出力先ディレクトリ

例えば、以下のような場合を考えます。

  • tdiary.rbは~tdiary/work/ruby/tdiary/core/にある
  • tdiary.confは~tdiary/public_html/にある
  • HTMLは~tdiary/public_html/html/以下に出力する

この場合はこのようなコマンドになります。

% ruby html-archiver.rb --tdiary ~tdiary/work/ruby/tdiary/core/ --conf ~tdiary/public_html/ ~tdiary/public_html/html/

機能

  • 日付ページの生成:
  • 最新n件ページの生成:
  • 月別ページの生成:
  • RSS 1.0の生成:
  • テーマファイルのコピー
  • 画像のコピー

制限

  • ツッコミが生成されるかどうかは試していない
  • カテゴリ一覧ページがきちんと生成されるかは(最近は)試していない
  • タブインデント(tDiary本体のコーディングスタイルに合わせているため)
  • 思ったほど使う場面が少ないかもしれない(もしかしたら、tDiaryが表示する内容を静的なHTMLとして公開することがそんなにないかもしれない)

ライセンス

GPL3あるいは3以降の新しいバージョンのGPL

html-archiver.rb

#!/usr/bin/env ruby
# -*- coding: utf-8; ruby-indent-level: 3; tab-width: 3; indent-tabs-mode: t -*-

require 'uri'
require 'cgi'
require 'fileutils'
require 'pathname'
require 'optparse'
require 'ostruct'
require 'enumerator'
require 'rss'

options = OpenStruct.new
options.tdiary_path = "./"
options.conf_dir = "./"
opts = OptionParser.new do |opts|
	opts.banner += " OUTPUT_DIR"

	opts.on("-t", "--tdiary=TDIARY_DIRECTORY",
			  "a directory that has tdiary.rb") do |path|
		options.tdiary_path = path
	end

	opts.on("-c", "--conf=TDIARY_CONF", "a path of tdiary.conf") do |conf|
		options.conf_dir = conf
	end
end
opts.parse!

output_dir = ARGV.shift

Dir.chdir(options.conf_dir) do
	$LOAD_PATH.unshift(File.expand_path(options.tdiary_path))
	require "tdiary"
end

module HTMLArchiver
	class CGI < ::CGI
		def referer
			nil
		end

		private
		def env_table
			{"REQUEST_METHOD" => "GET", "QUERY_STRING" => ""}
		end
	end

	module Image
		def init_image_dir
			@image_dest_dir = @dest + "images"
		end
	end

	module Base
		include Image

		def initialize(rhtml, dest, conf)
			@ignore_parser_cache = true

			cgi = CGI.new
			setup_cgi(cgi, conf)
			@dest = dest
			init_image_dir
			super(cgi, rhtml, conf)
		end

		def eval_rhtml(*args)
			link_detect_re = /(<(?:a|link)\b.*?\bhref|<img\b.*?\bsrc)="(.*?)"/
			super.gsub(link_detect_re) do |link_attribute|
				prefix = $1
				link = $2
				uri = URI(link)
				if uri.absolute? or link[0] == ?/
					link_attribute
				else
					%Q[#{prefix}="#{relative_path}#{link}"]
				end
			end
		end

		def save
			return unless can_save?
			filename = output_filename
			if !filename.exist? or filename.mtime != last_modified
				filename.open('w') {|f| f.print(eval_rhtml)}
				filename.utime(last_modified, last_modified)
			end
		end

		protected
		def output_component_name
			dir = @dest + output_component_dir
			name = output_component_base
			FileUtils.mkdir_p(dir.to_s, :mode => 0755)
			filename = dir + "#{name}.html"
			[dir, name, filename]
		end

		def mode
			self.class.to_s.split(/::/).last.downcase
		end

		def cookie_name; ''; end
		def cookie_mail; ''; end

		def load_plugins
			result = super
			@plugin.instance_eval(<<-EOS, __FILE__, __LINE__ + 1)
				def anchor( s )
					case s
					when /\\A(\\d+)#?([pct]\\d*)?\\z/
						day = $1
						anchor = $2
						if /\\A(\\d{4})(\\d{2})(\\d{2})?\\z/ =~ day
							day = [$1, $2, $3].compact
							day = day.collect {|component| component.to_i.to_s}
							day = day.join("/")
						end
						if anchor then
							"\#{day}.html#\#{anchor}"
						else
							"\#{day}.html"
						end
					when /\\A(\\d{8})-\\d+\\z/
						@conf['latest.path'][$1]
					else
						""
					end
				end

				def category_anchor(category)
					href = "category/\#{u category}.html"
					if @category_icon[category] and !@conf.mobile_agent?
						%Q|<a href="\#{href}"><img class="category" src="\#{h @category_icon_url}\#{h @category_icon[category]}" alt="\#{h category}"></a>|
					else
						%Q|[<a href="\#{href}">\#{h category}</a>]|
					end
				end

				def navi_admin
					""
				end

				@image_dir = #{@image_dest_dir.to_s.dump}
				@image_url = "#{@conf.base_url}#{@image_dest_dir.basename}"
			EOS
			result
		end

		private
		def setup_cgi(cgi, conf)
		end
	end

	class Day < TDiary::TDiaryDay
		include Base

		def initialize(diary, dest, conf)
			@target_date = diary.date
			@target_diaries = {@target_date.strftime("%Y%m%d") => diary}
			super("day.rhtml", dest, conf)
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			dir, name, filename = output_component_name
			filename
		end

		def [](date)
			@target_diaries[date.strftime("%Y%m%d")] or super
		end

		def relative_path
			"../../"
		end

		private
		def output_component_dir
			Pathname(@target_date.strftime("%Y")) + @target_date.month.to_s
		end

		def output_component_base
			@target_date.day.to_s
		end

		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m%d")]
		end
	end

	class Month < TDiary::TDiaryMonth
		include Base
		def initialize(date, dest, conf)
			@target_date = date
			super("month.rhtml", dest, conf)
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			dir, name, filename = output_component_name
			filename
		end

		def relative_path
			"../"
		end

		private
		def output_component_dir
			@target_date.strftime("%Y")
		end

		def output_component_base
			@target_date.month.to_s
		end

		private
		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m")]
		end
	end

	class Category < TDiary::TDiaryView
		include Base

		def initialize(category, diaries, dest, conf)
			@category = category
			diaries = diaries.reject {|date, diary| !diary.visible?}
			_, diary = diaries.sort_by {|date, diary| diary.last_modified}.last
			@target_date = diary.date
			super("latest.rhtml", dest, conf)
			@diaries = diaries
			@diary = diary
		end

		def can_save?
			not @diary.nil?
		end

		def output_filename
			category_dir = @dest + "category"
			category_dir.mkpath
			category_dir + "#{@category}.html"
		end

		def relative_path
			"../"
		end

		def latest(limit=5)
			@diaries.keys.sort.reverse_each do |date|
				diary = @diaries[date]
				yield(diary)
			end
		end

		protected
		def setup_cgi(cgi, conf)
			super
			cgi.params["date"] = [@target_date.strftime("%Y%m")]
		end
	end

	class Latest < TDiary::TDiaryLatest
		include Base

		def initialize(date, index, dest, conf)
			@target_date = date
			@index = index
			super("latest.rhtml", dest, conf)
		end

		def relative_path
			if @index.zero?
				""
			else
				"../"
			end
		end

		def can_save?
			true
		end

		def output_filename
			if @index.zero?
				@dest + "index.html"
			else
				latest_dir = @dest + "latest"
				FileUtils.mkdir_p(latest_dir.to_s, :mode => 0755)
				latest_dir + "#{@index}.html"
			end
		end

		protected
		def setup_cgi(cgi, conf)
			super
			return if @index.zero?
			date = @target_date.strftime("%Y%m%d") + "-#{conf.latest_limit}"
			cgi.params["date"] = [date]
		end
	end

	class RSS < TDiary::TDiaryLatest
		include Base

		def initialize(dest, conf)
			super("latest.rhtml", dest, conf)
		end

		def mode
			"latest"
		end

		def relative_path
			""
		end

		def can_save?
			true
		end

		def output_filename
			@dest + output_base_name
		end

		def output_base_name
			"index.rdf"
		end

		def do_eval_rhtml(prefix)
			load_plugins
			make_rss
		end

		private
		def make_rss
			base_uri = @conf['html_archiver.base_url'] || @conf.base_url
			rss_uri = base_uri + output_base_name

			@conf.options['apply_plugin'] = true
			feed = ::RSS::Maker.make("1.0") do |maker|
				setup_channel(maker.channel, rss_uri, base_uri)
				setup_image(maker.image, base_uri)

				@diaries.keys.sort.reverse[0, 15].each do |date|
					diary = @diaries[date]

					maker.items.new_item do |item|
						setup_item(item, diary, base_uri)
					end
				end
			end

			feed.to_s
		end

		def setup_channel(channel, rss_uri, base_uri)
			channel.about = rss_uri
			channel.link = base_uri
			channel.title = @conf.html_title
			channel.description = @conf.description
			channel.dc_creator = @conf.author_name
			channel.dc_rights = @conf.copyright
		end

		def setup_image(image, base_uri)
			return if @conf.banner.nil?
			return if @conf.banner.empty?

			if /^http/ =~ @conf.banner
				rdf_image = @conf.banner
			else
				rdf_image = base_uri + @conf.banner
			end

			maker.image.url = rdf_image
			maker.image.title = @conf.html_title
			maker.link = base_uri
		end

		def setup_item(item, diary, base_uri)
			section = nil
			diary.each_section do |_section|
				section = _section
				break if section
			end
			return if section.nil?

			item.link = base_uri + @plugin.anchor(diary.date.strftime("%Y%m%d"))
			item.dc_date = diary.last_modified
			@plugin.instance_variable_set("@makerss_in_feed", true)
			subtitle = section.subtitle_to_html
			body_enter = @plugin.send(:body_enter_proc, diary.date)
			body = @plugin.send(:apply_plugin, section.body_to_html)
			body_leave = @plugin.send(:body_leave_proc, diary.date)
			@plugin.instance_variable_set("@makerss_in_feed", false)

			subtitle = @plugin.send(:apply_plugin, subtitle, true).strip
			subtitle.sub!(/^(\[([^\]]+)\])+ */, '')
			description = @plugin.send(:remove_tag, body).strip
			subtitle = @conf.shorten(description, 20) if subtitle.empty?
			item.title = subtitle
			item.description = description
			item.content_encoded = body
			item.dc_creator = @conf.author_name
			section.categories.each do |category|
				item.dc_subjects.new_subject do |subject|
					subject.content = category
				end
			end
		end
	end

	class Main < TDiary::TDiaryBase
		include Image

		def initialize(cgi, dest, conf, src=nil)
			super(cgi, nil, conf)
			calendar
			@dest = dest
			@src = src || './'
			init_image_dir
		end

		def run
			@date = Time.now
			load_plugins
			copy_images

			all_days = archive_days
			archive_categories
			archive_latest(all_days)

 			make_rss
			copy_theme
		end

		private
		def copy_images
			image_src_dir = @plugin.instance_variable_get("@image_dir")
			image_src_dir = Pathname(image_src_dir)
			unless image_src_dir.absolute?
				image_src_dir = Pathname(@src) + image_src_dir
			end
			@image_dest_dir.rmtree if @image_dest_dir.exist?
			if image_src_dir.exist?
				FileUtils.cp_r(image_src_dir.to_s, @image_dest_dir.to_s)
			end
		end

		def archive_days
			all_days = []
			@years.keys.sort.each do |year|
				@years[year].sort.each do |month|
					month_time = Time.local(year.to_i, month.to_i)
					month = Month.new(month_time, @dest, conf)
 					month.save
					month.send(:each_day) do |diary|
						all_days << diary.date
 						Day.new(diary, @dest, conf).save
					end
				end
			end
			all_days
		end

		def archive_categories
			cache = @plugin.instance_variable_get("@category_cache")
			cache.categorize([], @years).each do |category, diaries|
				categorized_diaries = {}
				diaries.keys.each do |date|
					date_time = Time.local(*date.scan(/^(\d{4})(\d\d)(\d\d)$/)[0])
					@io.transaction(date_time) do |diaries|
						categorized_diaries[date] = diaries[date]
						DIRTY_NONE
					end
				end
 				Category.new(category, categorized_diaries, @dest, conf).save
			end
		end

		def archive_latest(all_days)
			conf["latest.path"] = {}

			latest_days = []
			all_days.reverse.each_slice(conf.latest_limit) do |days|
				latest_days << days
			end

			latest_days.each_with_index do |days, i|
				date = days.first.strftime("%Y%m%d")
				if i.zero?
					latest_path = "./"
				else
					latest_path = "latest/#{i}.html"
				end
				conf["latest.path"][date] = latest_path
			end
			latest_days.each_with_index do |days, i|
				latest = Latest.new(days.first, i, @dest, conf)
				latest.save
				conf["ndays.prev"] = nil
				conf["ndays.next"] = nil
			end
		end

		def make_rss
			RSS.new(@dest, conf).save
		end

		def copy_theme
			theme_dir = @dest + "theme"
			theme_dir.rmtree if theme_dir.exist?
			theme_dir.mkpath
			tdiary_theme_dir = Pathname(File.join(TDiary::PATH, "theme"))
			FileUtils.cp((tdiary_theme_dir + "base.css").to_s, theme_dir.to_s)
			if @conf.theme
				FileUtils.cp_r((tdiary_theme_dir + @conf.theme).to_s,
									(theme_dir + @conf.theme).to_s)
			end
		end
	end
end

cgi = HTMLArchiver::CGI.new
conf = TDiary::Config.new(cgi)
conf.show_comment = true
conf.hide_comment_form = true
def conf.bot?; false; end
output_dir ||= Pathname(conf.data_path) + "cache" + "html"
output_dir = Pathname(output_dir).expand_path
output_dir.mkpath
HTMLArchiver::Main.new(cgi, output_dir, conf, options.conf_dir).run
2008-12-05

UxU(UnitTest.XUL)を利用したFirefoxアドオンのデバッグの例

Firefoxアドオン開発者向け自動テストツールのUxUは、新たに発見したバグの修正にも活用することができます。本日リリースされたXUL/Migemo バージョン0.11.7で行われた修正の場合を例に、実際のデバッグ作業の流れを解説します。

状況

XUL/Migemoは、Firefoxで表示しているページ内のテキストを検索する機能を提供するアドオンですが、検索を開始する際に、「現在のスクロール位置から検索を開始する」という処理を含んでいます。0.11.6以前では、この機能を使用している時に、ページ先頭から検索が始まるべき場面で、先頭以外の場所から検索が始まってしまうことがあるという問題が起こっていました。

再現条件の特定

いくつか条件を変えて調査した結果、スクロールが発生しているページでは期待通りの結果になっているのに対して、スクロールが全く発生していないページ(ページ全体がウィンドウの現在の大きさの中に収まっているページ)では期待と異なる結果になっていることが判明しました。

原因箇所の特定

前述の処理の肝となっているのは、pXMigemoFindクラスfindFirstVisibleNode()というメソッドです。このメソッドは、渡されたフレーム(DOMWindow)において現在のスクロール位置で見えている最初の要素を検索して返す物です。このメソッドの戻り値を確認した所、前述の条件下では戻り値が期待と異なっている事が判明しました。

このことから、今回主な修正対象になるのはこのfindFirstVisibleNode()というメソッドであるということになります。

テストケースの作成

上記メソッドの実装を見直す前に、UxU用のテストケースを作成します。これにより、これから行う修正で目指すべきゴールが明確になります。つまり、このテストが成功する状況まで持って行くことが、今回の修正のゴールとなります。

テストケースはJavaScriptのコードだけで完結する場合もありますが、今回のような場合は実際のWebページを使ってテストを行う必要があります。そこで、問題が発生する条件と発生しない条件の両方の事例としてHTMLドキュメントを用意します。

これらのドキュメントを使い、shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する、という条件の下でテストを行うテストケースをこれから作成することになります。

ところで、現在の実装で問題が起こっている場合だけでなく、すでに正常に動いている場合の事例も同時に作成していることに気がついたでしょうか? 両方の場合を常にテストすることで、「ある問題を修正したら、今度は、今までは正常に動いていた物が動かなくなった」という状況、いわゆる後退バグを未然に防ぐことができます。 *1

それではテストケースを作成します。

utils.include('pXMigemoClasses.inc.js');

var findModule;

function setUp()
{
  yield utils.setUpTestWindow();
  findModule = new pXMigemoFind();
  findModule.target = utils.getTestWindow().gBrowser;
}

function tearDown()
{
  findModule.destroy();
  findModule = null;
  utils.tearDownTestWindow();
}

function testFindFirstVisibleNode()
{
  // ここに実際のテスト内容を記述する
}

pXMigemoFindクラスの単体テストはまだ作成されていなかったので、今回はsetUpとtearDownから作成します。すでに作成済みのテストケースがあり、それにテスト項目を追加する場合、この作業は不要となります。

pXMigemoFindクラスはtabbrowser要素を用いて初期化する必要があるため、setUpでテスト用のFirefoxウィンドウを開き、そのウィンドウのtabbrowser要素で初期化します。また、tearDownではsetUpで開いたテスト用のFirefoxウィンドウを閉じて、pXMigemoFindクラスのインスタンスを破棄します。UxUは関数名を見て自動的にその種類を判別するため、これだけで、これらの関数はテストの初期化処理と終了処理として認識されるようになります。

なお、インクルードしているpXMigemoClasses.inc.jsというファイルは、pXMigemoFindクラスやそのクラスが依存しているすべての関連クラスの定義を読み込む物です。

次に、テストの内容を作成していきます。

function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  // ここに実際のテスト内容を記述する
}

関数名を「test」で始めると、その関数はテストの内容として自動的に認識されます。

最初に、ウィンドウの大きさを調整して、「shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する」という条件を整えておきます。ここでは、テスト自体が期待通りの条件下で実行されていることを確認するために、assert.compare()でテスト用フレームの大きさを調べています。

  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });

  var frame = utils.contentWindow;
  var node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p3');
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

テスト用のドキュメントを新しいタブで開き、findFirstVisibleNode()メソッドの返り値が期待通りかどうかを検証します。1つ目の検証は前方検索、2つ目は後方検索です。

同様にして、スクロールが発生する場合のテストも作成します。

  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });

  frame = utils.contentWindow;
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p10');
  frame.scrollTo(0, item.offsetTop);
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(item, node);

  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

  item = frame.document.getElementById('p21');
  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

スクロールされていない時、ページ途中までスクロールされている時、ページの最後までスクロールされている時の各ケースで前方検索と後方検索を行い、結果を検証します。

ここで、かなりの部分のコードが重複していることに気がついたでしょうか。このような場合、それぞれの検証の前で重複しているコードと検証とをひとまとめにして実行する関数(カスタムアサーション)を定義しておくと、テスト項目の追加が簡単になります。以下は、カスタムアサーションを使ってここまでのテスト内容を書き直した物です。

function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  function assertScrollAndFind(aIdOrNode, aFindFlag)
  {
    var frame = utils.contentWindow;
    var item = typeof aIdOrNode == 'string' ? frame.document.getElementById(aIdOrNode) : aIdOrNode ;
    frame.scrollTo(
      0,
      (aFindFlag & findModule.FIND_BACK ?
        item.offsetTop - frame.innerHeight + item.offsetHeight :
        item.offsetTop
      )
    );
    var node = findModule.findFirstVisibleNode(aFindFlag, frame);
    assert.equals(item, node);
  }

  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p3', findModule.FIND_BACK);

  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_BACK);
  assertScrollAndFind('p21', findModule.FIND_BACK);
}

テストの実行

テストケースが完成したら、テストを実行してみましょう。実装の修正前なので、当然、このテストは「失敗」という結果が出ます。ですが、この段階では問題ありません。これから、このテストの結果が「成功」になることを目指して実装を修正していきます。

実装の修正

実装の修正内容については省略します。良いアイディアを思いついたら、それを実装に反映して、再度テストを実行してみましょう。テストに成功しないようであれば、まだ修正が必要です。

何度テストを実行しても結果が「成功」になるようになれば、実装の修正はひとまず完了です。修正内容をリポジトリにコミットするなり、修正済みの新しいバージョンとしてリリースするなりしましょう。

新たな問題が発覚した時や、仕様が変わった時は

以上で、今回発見された問題の修正は完了しました。

しかし、上記のテストだけではテストしきれないような、より複雑な条件でだけ発生するバグが新たに見つかるかもしれません。そのような場合は、テストを新たに追加して、それらがすべて「成功」するようになるまで修正してやりましょう。その時はもちろん、他のテストも同時に実行することを忘れないようにしましょう。

また、開発を進める中で、他の部分に加えた変更の影響を受けて上記のテストが失敗するようになることがあるかもしれません。そのような場合、再びテストが通るようになるように実装を修正する必要があります。

実装の仕様を変更した時にも、ここで作成したテストケースは「成功」しなくなる場合があります。このような場合は「ゴール」自体が変わったということになりますので、実装ではなくテストケースの側を修正しなくてはなりません。

まとめ

自動テストを使った開発では、メンテナンスする必要があるコードが「実装」と「テストケース」の2つになるため、一見すると、手間だけが倍増するように思えるかもしれません。

しかし、一連のテスト手順を自動化しておくことで、人の手によるテストでは見落としてしまいかねない思わぬ後退バグの発生に迅速に気づけるようになります。後退バグの発生に日々頭を悩ませている人は、是非、自動テストを開発に取り入れてみてください。

*1  もちろん、後退バグの発生自体は未然には防ぎきれません。しかし、後退バグの発生にすぐ気がつくことができれば、コミットやリリースの前にその後退バグを修正できるため、他の共同開発者やユーザには後退バグの影響を与えずに済むようになります。

2008-11-17

tDiaryのSubversionバックエンド

みなさんはtDiaryの日記データをどのようにバックアップしているのでしょうか。cronでアーカイブしていたり、dbi_ioでデータベースに保存して、データベースの内容をアーカイブしていたりしているのでしょうか。

開発者の人なら日記のデータもソースコードと同じようにバックアップしたいですよね。つまり、バージョン管理をして、レポジトリの内容をアーカイブするバックアップです。そんな人はこのようなSubversionIOはいかがでしょうか。保存する毎にSubversionリポジトリに日記データをコミットします。

require 'tdiary/defaultio'

module TDiary
	class SubversionIO < DefaultIO
		def transaction( date, &block )
			dirty = TDiaryBase::DIRTY_NONE
			result = super( date ) do |diaries|
				dirty = block.call( diaries )
				diaries = diaries.reject {|_, diary| /\A\s*\Z/ =~ diary.to_src}
				dirty
			end
			unless (dirty & TDiaryBase::DIRTY_DIARY).zero?
				run( "svn", "add", File.dirname( @dfile ) )
				run( "svn", "add", @dfile )
				Dir.chdir( @data_path ) do
					run( "svn", "ci", "-m", "update #{date.strftime('%Y-%m-%d')}" )
				end
			end
			result
		end

		private
		def run( *command )
			command = command.collect {|arg| escape_arg( arg )}.join(' ')
			result = `#{command} 2>&1`
			unless $?.success?
				raise "Failed to run #{command}: #{result}"
			end
			result
		end

		def escape_arg( arg )
			"'#{arg.gsub( /'/, '\\\'' )}'"
		end
	end
end

# Local Variables:
# ruby-indent-level: 3
# tab-width: 3
# indent-tabs-mode: t
# End:

使い方は以下の2ステップです。

  1. tdiary.confで@io_classに指定する
  2. 日記のデータディレクトリをSubversionのワーキングコピーにする

tdiary.confの設定

まず、上記のソースコードをsubversionio.rbとしてどこかに保存してください。ここでは/home/tdiary/lib/以下に保存したとします。

次にtdiary.confに以下の内容を追記します。

subversion_io_dir = "/home/tdiary/lib" # <- 保存した場所にあわせて変更
require "#{subversion_io_dir}/subversionio"
@io_class = TDiary::SubversionIO

データディレクトリの設定

データディレクトリ(tdiary.conf内の@data_pathで指定したディレクトリ)は/home/tdiary/data/として進めます。また、作業しているユーザはtDiaryのCGIを動かすユーザとします。(ここではtdiaryユーザ)

まず、レポジトリに日記データ用のパスを作ります。既存のSubversionリポジトリを利用する場合は、例えばこのようになります。

[tdiary]% svn mkdir -m 'create tDiary data path' https://.../repos/tdiary-data

ローカルに新しくSubversionのリポジトリを作成して、そこに日記データをコミットするようにする場合はこのようになります。新しく作成するリポジトリは/home/tdiary/repos/に作ることにします。

[tdiary]% svnadmin create /home/tdiary/repos

Subversionリポジトリから日記データ保存用のパスを、tDiaryのデータディレクトリにチェックアウトします。今すでにあるtDiaryのデータディレクトリはどこかによけておきます。

[tdiary]% mv /home/tdiary/data /home/tdiary/data.bak
[tdiary]% svn co file:///home/tdiary/repos /home/tdiary/data

既存のデータをワーキングコピーに移動し、日記データだけをレポジトリにコミットします。

[tdiary]% cd /home/tdiary/data
[tdiary]% cp -rp ../data.bak/* ./
[tdiary]% svn add 200*
[tdiary]% svn add 199* # <- もし2000年より前のデータがあるなら
[tdiary]% svn ci -m 'import'

完了

以上で設定は完了です。日記を保存するとリポジトリにコミットされます。

制限

日記本文しか対応していません。ツッコミや画像には対応していません。

svnコマンドをインストールしている必要があります。

tDiary本体のコーディングスタイルに合わせているためタブインデントになっています。

ライセンス

AGPL3あるいは3以降の新しいバージョンのAGPL

2008-11-13

Test::Unit 2.0.1リリース

Test::Unit 2.0.1が RubyForge上でリリースされました。RubyGemsも提供されているの で以下のようにインストールできます。

% sudo gem install test-unit

経緯

Test::UnitはRuby 1.8.xに標準添付されている単体テストフレーム ワークです。しかし、Ruby 1.9.1からは miniunitが標準 添付され、Test::UnitはRubyForgeで開発が継続されることになりま した。これからもTest::Unitを使うときはRubyGemsでインストール することになるでしょう。

Ruby 1.8.xに標準添付されているTest::Unitは互換性のために、 Test::Unit 1.2.3としてリリースされています。Ruby 1.9.1でも Ruby 1.8.xに標準添付されているTest::Unitと同じTest::Unitを使 用したい場合は以下のようにします。

% sudo gem install test-unit --version '= 1.2.3'

テストファイル内(変更前):

require 'test/unit'
...

テストファイル内(変更後):

require 'rubygems'
gem 'test-unit', '= 1.2.3'
require 'test/unit'
...

余談ですが、Ruby 1.9.1でTest::Unitが標準添付から外れ、 miniunitが標準添付になったのはTest::Unitのソースがメンテナン スしづらくなっていたのが主な理由です。

Test::Unit 2.x

Ruby 1.8.xに標準添付されているTest::Unitは長い間メンテナンス はされていましたが、特に機能拡張などは行われていませんでした。 しかし、その間にもテスト環境を便利にするライブラリが公開され てきました。例えば、RSpecのような BDD用のフレームワークや、 expectations のような軽量の単体テストフレームワーク、 Shoulda/ test/spec/ Mochaのように Test::Unitを拡張するライブラリなどです。Test::Unitは少し時代 遅れになってしまったのです。

最近のテスト用のフレームワークは DSL化の方向に向かっているようにも見えます。 これはRSpecの影響が大きいのでしょう。expectationsやShouldaもテスト用の DSLを提供します。

しかし、Test::UnitはDSLを提供しません。テストを「英語らしく」 ではなく「Rubyプログラムらしく」書きます。好みにもよりますが、 これはTest::Unitのメリットの1つと言えます。

Test::Unitに他のフレームワークやライブラリの機能を、 Test::Unitの「Rubyプログラムらしく」テストを書ける特性を活か したまま追加すれば、Test::Unitはもっと便利で使いやすいテスト フレームワークになるでしょう。Test::Unit 2.x系列はRuby 1.8.x に標準添付されていた頃とは違い、そのような方針の元で活発に開 発されていく系列になります。

例えば、以下のような機能が他のフレームワークやライブラリから 移植されています。

  • 差分表示
  • ネストしたテスト定義
  • 色付け
  • C-cでテスト中断時にもテスト結果を表示
  • 複数のsetup/teardown
  • ...

ここでは「差分表示」と「ネストしたテスト定義」だけ紹介します。 *1

差分表示

RSpecでは比較結果が異なった場合に差分を表示して違いをわかり やすく表示してくれます。

diff_spec.rb:

require 'rubygems'
require 'spec'

describe String do
  it do
    ["I", "am", "a", "boy"].join("\n").should == ["I", "was", "a", "boy"].join("\n")
  end
end

実行結果(差分表示部分のみ):

% ruby diff_spec.rb -D
...
Diff:
@@ -1,5 +1,5 @@
 I
-was
+am
 a
 boy
...

同様の機能がTest::Unit 2.0.1にもあります。

test_diff.rb:

require 'rubygems'
gem 'test-unit'
require 'test/unit'

class TestDiff < Test::Unit::TestCase
  def test_diff
    assert_equal(["I", "am", "a", "boy"].join("\n"),
                 ["I", "was", "a", "boy"].join("\n"))
  end
end

実行結果(差分表示部分のみ):

% ruby test_diff.rb
...
diff:
  I
- am
+ was
  a
  boy
...

この例では、ほとんど同じ差分表示ですが、Test::Unit 2.0.1の 差分表示がRSpecの差分表示よりも便利なこともあります。

今度は"\n"ではなく" "でjoinして1行の文字列として比較します。

test_diff.rb:

require 'rubygems'
gem 'test-unit'
require 'test/unit'

class TestDiff < Test::Unit::TestCase
  def test_diff
    assert_equal(["I", "am", "a", "boy"].join(" "),
                 ["I", "was", "a", "boy"].join(" "))
  end
end

実行結果(差分表示部分のみ):

% ruby test_diff.rb
...
diff:
- I am a boy
?    ^
+ I was a boy
?   + ^
...

Test::Unit 2.0.1では必要なら同じ行のうち、どの列が異なってい るのかも表示します。RSpecでは行単位の差分までで列単位までの 差分は表示しません。

余談ですが、この差分表示形式はPythondifflib ライブラリで使われている形式です。

ネストしたテスト定義

Shouldaではcontextをネストさせることにより、便利にテストを書 くことができます。以下はShouldaのページからの引用です。 *2

class UserTest < Test::Unit::TestCase
  context "A User instance" do
    setup do
      @user = User.find(:first)
    end

    should "return its full name" do
      assert_equal 'John Doe', @user.full_name
    end

    context "with a profile" do
      setup do
        @user.profile = Profile.find(:first)
      end

      should "return true when sent #has_profile?" do
        assert @user.has_profile?
      end
    end
  end
end

ネストされた"with a profile"のcontext内では上位の"A User instance"のcontext内のsetupが実行された後に実行されます。つ まり、以下のような実行順序になります。

  • setup: "A User instance" context
    • should: "return its full name"
  • setup: "A User instance" context
    • setup: "with a profile" context
      • should: "return true when sent #has_profile?"

実行されるフィクスチャ(setup)がネストで自然に表現されていま す。

Test::Unit 2.0.1では以下のように書きます。

class UserTest < Test::Unit::TestCase
  def setup
    @user = User.find(:first)
  end

  def test_full_name
    assert_equal('John Doe', @user.full_name)
  end

  class ProfileTest < UserTest
    def setup
      super
      @user.profile = Profile.find(:first)
    end

    def test_profile
      assert_true(@user.has_profile?)
    end
  end
end

これは以下のように実行されます。

  • UserTest#setup
    • UserTest#test_full_name
  • UserTest#setup
    • UserTest::ProfileTest#setup
      • UserTest::ProfileTest#test_profile

実行されるフィクスチャ(setup)がネストとクラス階層で自然に表現 されています。

まとめ

Test::UnitはRuby 1.9.1からは標準添付ではなくなりましたが、 Test::Unit 2.xとして活発に開発が続けられています。既存の他の フレームワークやライブラリのよいところは積極的に導入している ため、Ruby 1.8.xに標準添付されているTest::Unitよりもはるかに 使いやすくなっています。

今回は紹介しませんでしたが、他のフレームワークやライブラリに はないTest::Unit 2.x独自の便利な機能もあります。Test::Unitの 「Rubyプログラムらしい」テストの書き方が好きな場合はこれから もTest::Unitを使ってみてはいかがでしょうか。

*1  「C-cでテスト中断時にもテスト結果を表示」は開発が進んでい き、たくさんのテストがある場合には地味ですがとても便利な機能 なのです。RSpecにも実装されています。

*2  ただし、Test::UnitはTest::Unit::TestCaseに修正してある。

2008-11-10

Ruby-GetText-Packageとrake gems:installの共存

Ruby-GetText-Packageだけというわけではないですが、app/controllers/application.rbで何かを行う*1gemを使っているとrake gems:installで足りないgemをインストールできません。経験したことがあるけど別に手動でインストールすればいいやということで、おそらく、わりとうやむやにされていることが多い問題ではないでしょうか。

例えば、Ruby-GetText-Packageだと以下のようにapp/controllers/application.rbを変更する必要があります。

class ApplicationController < ActionController::Base
  init_gettext "blog"
end

Ruby-GetText-Packageのgemがない場合は「init_gettext」が定義されていないため「NameError」が発生します。そのため、rake gems:installをしようとすると以下のように失敗してしまいます。

% rake gems:install
(in /tmp/blog)
rake aborted!
undefined method `init_gettext' for ApplicationController:Class

(See full trace by running task with --trace)

これを回避するために「足りないgemがあるときはinit_gettextを使わない」という方法があります。 あまりきれいな方法ではありませんが、紹介します。

class ApplicationController < ActionController::Base
  if Rails.configuration.gems.reject {|gem| gem.loaded?}.empty?
    init_gettext "blog"
  end
end

もし、config/environment.rbでconfig.active_record.observersを指定しているなどして、app/model/以下も読み込まれるのであれば、ダミーのN_を定義しておくとよいでしょう。

class ApplicationController < ActionController::Base
  if Rails.configuration.gems.reject {|gem| gem.loaded?}.empty?
    init_gettext "blog"
  else
    class ActiveRecord::Base
      def self.N_(*args); end
    end
  end
end

これでRuby-GetText-Packageを使っているときでもrake gems:installが動くようになります。

% rake gems:install
(in /tmp/blog)
gem install gettext
Bulk updating Gem source index for: http://gems.rubyforge.org/
Successfully installed gettext-1.93.0
1 gem installed
Installing ri documentation for gettext-1.93.0...
Installing RDoc documentation for gettext-1.93.0...

以下、「足りないgemがあるとき」の判断方法について少し書いてみます。

足りないgemがあるとき

Railsでは「足りないgemがあるかどうか」を示すAPIを提供しているのはRails::Initializerです。ただ、残念ながらconfig/environment.rbの中で作ったRails::Initializerはどこにも保存されていないので、無理やり引っ張り出す必要があります。具体的には以下のようなコードになります。

initializer = nil
ObjectSpace.each_object(Rails::Initializer) do |object|
  initializer = object
  break
end
initializer.gems_dependencies_loaded

しかし、この方法ではRails::InitializerがGCされてしまっていると動きません*2

また、ObjectSpaceはできれば使いたくないものです。そのため、もう少し安全で、何をしているのかがまだわかりそうな方法の方がよさそうです。そのための「足りないgemがあるかどうか」を判断する方法が以下のようになるというわけです。

Rails.configuration.gems.reject {|gem| gem.loaded?}.empty?

ただ、この方法はRails 2.1.2(や2008-10-30でのmaster)では動きますが、もし、Rails内部の「足りないgemがあるかどうか」を判断する方法が変わった場合は動かなくなる可能性もあります。 「あまりきれいな方法ではありませんが」と書いたのはこのためです。

まとめ

Ruby-GetText-Packageを使っている場合でもrake gem:installを利用する方法を紹介しました。

同じような問題は他のライブラリでも起こりうると思うので、そのような場合も同じように問題を回避できると思います。

*1  もう少しいうと、読み込まれた時に実行される場所(例えばクラス定義の中)で何かを行う場合。メソッド定義の中などその場では実行されないものは関係ない。

*2  多くの場合はそんな状況にはならないでしょう

2008-10-30

GaUnit 0.1.6リリース

GaUnit 0.1.6がリリースされました。 ([アナウンス]|[ダウンロード])

GaUnitは便利なSchemeインタプリタであるGauche用のxUnitベースの単体テストフレームワークです。0.1.6ではGauche標準のgauche.testモジュール(リンク切れしそうなリンク)用に書かれたテストを実行することができるようになりました。(簡単なものであれば)

gauche.test互換レイヤーの実装

gauche.testのテストを実行するために、テストスクリプトを読み込み、GaUnitが理解できるようにS式を変形します。変形されたS式はGaUnitが提供するgauche.test互換APIが使える無名モジュール内で評価され、GaUnitのテストとして認識されるようになります。

SchemeではマクロでS式を変換するということはよく行われます。もし、それで十分でない場合は上記のように、スクリプト自体をS式として読み込んで、変換して、評価、ということも行うことができます。これは、S式がプログラムで扱いやすいためにできると言えるでしょう。他の言語、例えばJavaScriptでは、プログラムを変換するためにtoSourceしたものをreplaceしてevalするということが行われることもあるようです。

ただ、非常に多くの場合はこのような方法は必要ありません。今回は(use gauche.test)をどうしてもうまく扱えなかったのでこの方式をとっています。

ファイルの内容をS式のリストに変換

Gaucheスクリプトを書くときのちょっとした豆知識を紹介しておきます。

Gaucheスクリプトの内容をS式のリストとして読み込む処理は以下のようになります。ファイル先頭のcoding: utf-8などのような文字エンコーディング指定を考慮するためにopen-coding-aware-portをはさむことを忘れてはいけません。

(define (file->sexp-list file)
  (call-with-input-file file
    (lambda (input)
      (port->sexp-list (open-coding-aware-port input)))))

まとめ

GaUnit 0.1.6ではgauche.testのテストも実行できるようになったため、既存のgauche.testのテスト(大事な資産)を捨てることなくGaUnitに移行できるようになりました。これを機にGaUnitも試してみてはいかがでしょうか。

gauche.testであれGaUnitであれ、自動化されたテストがあるということは安心できるものです。

2008-10-23

Rabbit 0.5.8リリース

Ruby-GNOME2を使って実装されているプレゼンテーションツールRabbit 0.5.8がリリースされました。

0.5.8では部分的にClutterをサポートしています。

Clutter

Clutterは高速で、視覚的にリッチで、アニメーションするGUIを作成するためのライブラリです。Clutterのこれらの特徴はOpenGLをバックエンドに使う事で実現されています。 ClutterはLinux/Mac OS X/Windowsなどマルチプラットフォームで動作します。さらに、組み込み環境でも動作し(OpenGL ESを利用)、デモ動画も公開されています。

ライブラリを使用する視点で見ると、GStreamer/cairo/Pango/GTK+などGNOME関連のライブラリと親和性が高いこともあり、便利で使いやすいAPIになっています。

ClutterにはRuby/Python/Perl/Valaなど各種言語用のバインディングがあります。リッチなインターフェイスを作成したい場合にClutterを利用してみてはどうでしょうか。

2008-10-20

Cutter 1.0.5リリース

ここには書いていませんでしたが、Cutter 1.0.3のリリース のリリースの約1ヶ月後に1.0.4がリリースされました。 さらにその約2ヶ月後の昨日、1.0.5がリリースされました。 CutterとはC言語用の単体テス トフレームワークです。

新機能

詳細は NEWSに 書いてありますが、1.0.5での目玉新機能は以下の3点です。

  • 画像データ入出力・操作ライブラリ gdk-pixbuf (←少し古い。最新版(英語) )をサポート
  • 多くの検証とテスト便利関数を追加
  • ユーザ定義検証作成のサポートを強化

gdk-pixbufサポート

gdk-pixbufをサポートすることにより、画像が等しいかどうかを検 証できるようになりました。また、もし、画像が異なる場合は画像 間の差分を示し、画像のどこが異なるのかをわかりやすくしていま す。

例えば、このような画像を比較したとします。

期待画像 実際の画像

これらの画像では赤い丸の部分が異なっています。

これらの画像の差分画像は以下のようになります。

差分画像

左上に期待画像、右上に実際の画像、左下に異なるピクセルを示し た画像、右下に異なるピクセルを強調表示、同じピクセルを弱めに 表示した画像を配置しています。左下の画像を見ることでどの部分 が異なるのかが具体的にわかり、右下の画像を見ることで比較画像 はどのあたりが異なるのかを相対的に確認する事ができます。

もっとよい表現方法があるかもしれませんが、しばらくはこの方法 を採用しする予定です。もしかすると、今後、よりよい表現方法に 変更されるかもしれません。

ちなみに、この機能はPDF操作・レンダリングライブラリである Popplerテスト で利用されていま す。

検証・テスト便利関数の追加

Cutterを使用していて、こんな検証・便利関数があったら便利だ、 と感じたものは積極的にCutter本体に取り込んでいます。

1.0.5では12個の検証、9個の便利関数が追加されました。追加され た検証・便利関数のリストは NEWSに 書かれています。

ユーザ定義検証作成のサポートを強化

今までもユーザが独自で検証を定義することはできたのですが、バッ クトレースを取得するためにマクロとして定義する必要がありまし た。

1.0.5では cut_trace() というマクロが追加され、検証を関数として定義してもバックトレー スを取得することができるようになりました。マクロは可変長引数 が簡単に書けるなど便利な事も多いのですが、構文エラーが見つけ にくいなどという問題もあります。1.0.5からは関数とマクロを使い 分けられるようになり、より便利にデバッグのしやすいテストが書 けるようになりました。

まとめ

Cutter 1.0.5ではテスト作成・デバッグ支援の機能が強化され、よ り便利なテスト環境を提供するようになりました。今までよりもC 言語でのテスト作成が楽しくなるかもしれません。

[チュートリアル ] [リファレンスマニュアル ] [ダウンロード ]

2008-10-16

«前10日分
2008|05|06|07|08|09|10|11|12|
©2008-2009 ClearCode Inc.