楽器モデルの追加

モデルの追加

楽器(Instrumental)クラスを作成します。Title に belongs_to したモデルで、それ以外は並び順(sort_order)の属性を保持します。並び順は10置きに設定し、クラスメソッド renumber により、再整列できるようにします。この時、モデルのジェネレーターは以下のようになります。

r g model instrumental title_id:integer sort_order:integer

取り急ぎ spec/models/instrumental_spec.rb で作成のテストを書いてみます。前回の Title 同様、生成とバリデーションです。

require 'spec_helper'
describe Instrumental do
  let(:instrumental) { FactoryGirl.build(:instrumental) }

  it '妥当なオブジェクトが生成されること' do
    expect(instrumental).to be_valid
  end

  [ :sort_order, :title_id ].each do |key|
    it "#{key} は空であってはならない" do
      instrumental[key] = ""
      expect(instrumental).not_to be_valid
      expect(instrumental.errors[key]).to be_present
    end
  end

このテストのために FactoryGirl が書きますが、今回は関連が絡んでくるので気をつける必要があります。spec/factories/instrumentals.rb は以下のようになります。title だけの行は Title の FactoryGirl へのリンクになります。

# Read about factories at https://github.com/thoughtbot/factory_girl

FactoryGirl.define do
  factory :instrumental do
    sort_order 1
    title
  end
end

app/models/instrumental.rb に実装を追加します。

class Instrumental < ActiveRecord::Base
  validates :sort_order, :title_id, presence:true
  belongs_to :title

また、app/models/title.rb に has_one を追加します。

has_one :instrumental

次にお互いを削除した時の挙動を考えます。楽器は今のところ削除可能で、削除した時にはタイトルから見えなくなります。逆にタイトルは楽器が登録されている時には削除できないようにすべきです。この条件をテストします。

it "削除したときに対応するタイトルの楽器のリンクが消えること" do
    instrumental.save
    title = instrumental.title
    expect(title.instrumental).to eq(instrumental)
    instrumental.destroy
    expect(title.instrumental(true)).to be_nil
  end

  it "楽器が所属するタイトルが削除できないこと" do
    instrumental.save
    title = instrumental.title
    expect { title.destroy }.not_to change(Title, :count)
  end

前者は特に何もする必要はありませんが、後者は destroy に対するバリデーションになります。先ほどの has_one の前に before_destroy が必要となります。記述順に注意する必要があるそうです(Railsでbefore_destroyを使うときの注意点参照)。

  before_destroy do
    self.instrumental == nil
  end
  has_one :instrumental

次にタイトルに関するテストをします。楽器はタイトルへのリンクを保持するだけですので自分では表示ができません。そのため、name(is_ja) というメソッドを準備します。テストは以下のようになります。

  it "name(flag) にてタイトルが取得できること" do
    expect(instrumental.name(true)).to eq('日本語 英語')
    expect(instrumental.name(false)).to eq('English Japanese')
  end
end

name の実装ですが、これから作成する多くのクラスでも同じ処理になりますので、concerns にモジュールを作成し、それを include することにします。app/models/concerns/name.rb は以下のようになります。name という名前は安直でしょうか。

module Name
  def name(flag)
    title.name(flag)
  end
end

楽器のメソッドにしたいので、app/models/instrumental.rb に include を追加します。

class Instrumental < ActiveRecord::Base
  include Name

とりあえず、モデルについては以上です。受け入れテストで必要なメソッド(renumber等)があるのですが、それはテストを書いてから追記します。

受け入れテスト

今回は一気に受け入れテストを作ってしまいます。シナリオは以下の通りです。

機能: 楽器に関する CRUD を確認する
  シナリオ: 楽器ページにアクセスする
    前提 トップページを表示する
    もし "楽器" をクリックする
      ならば 画面に "楽器一覧" と表示されていること
    もし "English" をクリックする
      ならば 画面に "List of instrumentals" と表示されていること

  シナリオ: 運用環境では楽器追加ができない
    前提 運用環境である
    かつ 楽器一覧を表示する
      ならば 画面に "楽器追加" と表示されていないこと

  シナリオ: 開発環境で新規楽器を作成する
    前提 タイトルにテスト用データを登録する
    かつ タイトル一覧を表示する
    もし "" をクリックする
    かつ id が "ボーカル" である行の "楽器追加" をクリックする
      ならば 画面に "楽器一覧" と表示されていること
      かつ id が "ボーカル" である行に "10" と表示されていること
    もし "English" をクリックする
      ならば id が "Vocal" である行に "10" と表示されていること
    もし タイトル一覧を表示する
    かつ "English" をクリックする
    かつ "HE" をクリックする
    かつ id が "Arrangement" である行の "Add an instrumental" をクリックする
      ならば id が "Arrangement" である行に "20" と表示されていること
    もし "Japanese" をクリックする
      ならば id が "編曲" である行に "20" と表示されていること

  シナリオ: 開発環境で楽器を編集する
    前提 楽器にテスト用データを登録する
    もし 楽器一覧を表示する
    かつ id が "編曲" である行の "編集" をクリックする
      ならば 画面に "楽器編集 (編曲)" と表示されていること
    もし "並び順""5" と入力する
    かつ "更新する" ボタンをクリックする
      ならば 画面に "楽器一覧" と表示されていること
      かつ id が "編曲" である行に "10" と表示されていること
    かつ "English" をクリックする
    かつ id が "Arrangement" である行の "Edit" をクリックする
      ならば 画面に "Edit the instrumental (Arrangement)" と表示されていること
    かつ "Sort order""30" と入力する
    かつ "Update Instrumental" ボタンをクリックする
      ならば 画面に "List of instrumentals" と表示されていること
      かつ id が "Arrangement" である行に "20" と表示されていること

  シナリオ: パラメータ設定が間違えていたときのエラー確認(楽器編集)
    前提 楽器にテスト用データを登録する
    もし 楽器一覧を表示する
    かつ id が "編曲" である行の "編集" をクリックする
    かつ "並び順""" と入力する
    かつ "更新する" ボタンをクリックする
      ならば 画面に "楽器一覧" と表示されていないこと
      かつ 画面に "並び順を入力してください。" と表示されていること
    もし 楽器一覧を表示する
    かつ "English" をクリックする
    かつ id が "Vocal" である行の "Edit" をクリックする
    かつ "Sort order""" と入力する
    かつ "Update Instrumental" ボタンをクリックする
      ならば 画面に "List of instrumentals" と表示されていないこと
      かつ 画面に "Sort order can't be blank" と表示されていること

  シナリオ: 開発環境で楽器を削除する
    前提 楽器にテスト用データを登録する
    もし 楽器一覧を表示する
    かつ id が "ボーカル" である行の "削除" をクリックする
      ならば 画面に "楽器一覧" と表示されていること
      かつ 画面に "ボーカル" と表示されていないこと
      かつ id が "編曲" である行に "10" と表示されていること
    かつ "English" をクリックする
    かつ id が "Arrangement" である行の "Delete" をクリックする
      ならば 画面に "List of instrumentals" と表示されていること
      かつ 画面に "Arrangement" と表示されていないこと

  シナリオ: 楽器が登録されているときにタイトルをロックする
    前提 楽器にテスト用データを登録する
    もし タイトル一覧を表示する
    かつ "" をクリックする
      ならば id が "ボーカル" である行に "削除" と表示されていないこと
    もし "English" をクリックする
    かつ "HE" をクリックする
      ならば id が "Arrangement" である行に "削除" と表示されていないこと

タイトルとほぼ変わりませんが、以下の点を重点的に確認しています。

  1. タイトル一覧で楽器追加リンクをクリックするだけで、楽器を作成
  2. 並び順は自動的に 10 置きに採番
  3. 並び順を適当な値に設定すると並び順を再度整列して採番
  4. 削除した時も整列して採番

これらのテストを通過させるために、step を記述します。spec/acceptance/steps/titles_page_steps.rb に以下の step を追加します。

step 'タイトルにテスト用データを登録する' do
  [ %w(ボーカル Vocal ぼーかる), %w(編曲 Arrangement へんきょく) ].each do |(japanese, english, yomi)|
    Title.create(japanese:japanese, english:english, yomi:yomi)
  end
end

また、spec/acceptance/steps/top_page_steps.rb に行の確認 step を追加します。

step 'id が :name である行に :string と表示されていること' do |name, string|
  within("//tr[@id='#{name}']") do
    expect(page).to have_content(string)
  end
end

step 'id が :name である行に :string と表示されていないこと' do |name, string|
  within("//tr[@id='#{name}']") do
    expect(page).not_to have_content(string)
  end
end

さらに、spec/acceptance/steps/instrumentals_page_steps.rb に楽器用の step を新規作成します。step の中で send を呼ぶことで別の step を呼ぶことができます。

# encoding: utf-8

step '楽器一覧を表示する' do
  visit instrumentals_path(locate: :ja)
end

step '楽器にテスト用データを登録する' do
  send 'タイトルにテスト用データを登録する'
  %w(Vocal Arrangement).zip([10, 20]).each do |(english, sort_order)|
    Title.english_value_is(english).first.create_instrumental(sort_order:sort_order)
  end
end

楽器のコントローラを作成します。

r g controller instrumentals

config/routes.rb も修正します。

resources :instrumentals

途中何度もテスト失敗・実装追加の繰り返しを行いますが、最終的なコントローラ(app/controllers/instrumentals_controller.rb)は以下のようになります。create が view から直接呼ばれるためにパラメータエラーを確認していない以外はほとんどタイトルと同じです。ただし、更新・削除時に並び順を renumber しています。

class InstrumentalsController < ApplicationController
  before_action :reject_production, except:[ :index, :show ]
  before_action :get_instrumental, only:[ :edit, :update, :destroy, :show ]
  def index
    @instrumentals = Instrumental.order_sort_order
  end

  def create
    @instrumental = Instrumental.create(instrumental_params)
    redirect_to instrumentals_path
  end

  def edit
  end

  def update
    if @instrumental.update(instrumental_params)
      Instrumental.renumber
      redirect_to instrumentals_path
    else
      render action: :edit
    end
  end

  def destroy
    @instrumental.destroy
    Instrumental.renumber
    redirect_to instrumentals_path
  end

  def get_instrumental
    @instrumental, @ids = get_objects_and_ids [ Instrumental ]
  end
  private :get_instrumental

  def instrumental_params
    params.require(:instrumental).permit(:sort_order, :title_id)
  end
  private :instrumental_params
end

モデル(app/models/instrumental.rb)に足りないメソッドを追加します。

  scope :order_sort_order, -> { order self.arel_table[:sort_order] }
  scope :english_value_is, -> v { joins(:title).merge(Title.english_value_is(v)) }

  def self.renumber
    self.order_sort_order.each_with_index do |obj, i|
      count = (i + 1) * 10
      unless obj.sort_order == count
        obj.sort_order = count
        obj.save
      end
    end
  end

  def can_delete?
    true
  end

また、タイトルモデル(app/models/title.rb)にも足りないメソッドを追加します。

  scope :english_value_is, -> v { where self.arel_table[:english].eq(v) }

また、楽器が存在しない時にはタイトルを削除できないようにするため、can_delete? を修正します。

   def can_delete?
    instrumental ? false : true
   end

app/views/instrumentals/edit.html.haml はタイトルのものとほとんど一緒です。

%h1 #{t '.title'} (#{@instrumental.name(@is_ja)})

=render partial:'form'

また、app/views/instrumentals/_form.html.haml もほぼ一緒です。new がないので分ける必要はなかったかもしれません。

=form_for @instrumental do |f|
  =display_error(@instrumental)
  %table.table-bordered.table-striped
    -[ :sort_order ].each do |key|
      %tr
        %th=f.label key
        %td=f.text_field key
    %tr
      %td.text-center{colspan:2}=f.submit nil, class:"btn btn-default"

app/views/instrumentals/index.html.haml は並び順に全てを表示するだけで、ほぼタイトルと同じです。

%h1 #{t '.title'}

%table.table-bordered.table-striped.table-hover
  %thead
    %tr
      -%w(.sort_order link.instrumentals).each do |k|
        %th=t k
      %th=t 'head.control'
  %tbody
    -@instrumentals.each do |instrumental|
      -name = instrumental.name(@is_ja)
      %tr{id:name}
        %td=instrumental.sort_order
        %td=name
        %td
          -if @is_dev
            .btn-group.btn-group-xs
              =link_to t('link.edit'), edit_instrumental_path(instrumental), class:"btn btn-default"
              =link_to t('link.destroy'), instrumental_path(instrumental), class:"btn btn-default", method: :delete, data:{ confirm:t('confirm.destroy') } if instrumental.can_delete?

ナビゲーションリンク(pp/views/shared/_top_link_bar.html.haml)も追加しておきます。

  %li{active_from_class(InstrumentalsController)}=link_to t('link.instrumentals'), instrumentals_path

最後にタイトルのページを修正します。まず、表タイトルに楽器を追加します。

      -%w(link.titles, link.instrumentals).each do |k|

表の中身の楽器列を追加します。開発環境では、create への直リンクを用意します。並び順は現在の最大値+10に自動設定します。一つもなかった場合には10です。タイトルに楽器が存在した場合、楽器を表示するリンクがありますが、この部分はまだ実装していません。

        %td
          -if instrumental = title.instrumental
            .btn-group.btn-group-xs
              =link_to t('link.show'), instrumental, class:'btn btn-default'
          -elsif @is_dev
            .btn-group.btn-group-xs
              -max = Instrumental.maximum(:sort_order) || 0
              =link_to t('instrumentals.new.title'), instrumentals_path(instrumental:{title_id:title.id, sort_order:max+10}), method: :post, class:'btn btn-default'

後はローカライズの文字列が設定されれば、テストは通過するはずです。

まとめ

関連が絡む部分で若干違う部分はありましたが、中身はほとんどタイトルの時と同じでしたね。今回は英語版も同時生成する目的があったため、config/locales/*/{ja,en}.yml を使いました。仮に日本語のサイトしか作らない場合でも、この仕組みを使った方が楽なのではないかと思いました。某システムではページごとに表記の揺らぎがあり、いろいろと迷惑をかけているのです。