LE Blog

Инженер с поэтической душой

16.06.2009 firtree_right Управление фотокамерой с помощью руби

Введение

По заказу neq4 в рамках одного из проектов сделал библиотеку для работы с камерами. Точнее, это оболочка вокруг библиотеки на C (libgphoto2) для использования в руби.

Пришлось, конечно, изучать тему расширения руби с помощью C, но сегодня я хотел написать не об этом, а наоборот о том, как пользоваться библиотекой на руби. Что называется, программировал на C, чтобы другим не пришлось :)

Оболочка собрана в виде gem и работает на Linux и Mac OS. То есть везде, где работает gphoto2.

Установка

Для того, чтобы собрать gem, кроме средств сборки и версии руби для разработчиков, нужна установденная версия libgphoto2 для разработчиков:

sudo apt-get install ruby-dev build-essential libgphoto2-dev

Теперь можно ставить gem:

sudo gem i gphoto4ruby

Инициализация и настройки камеры

О том, какие камеры совместимы, и как их подключать для работы достаточно много написано у создателей gphoto2. Скажу лишь, что те камеры, с которыми я работал, для подключения нужно было переводить в PTP-mode. После подключения:

require "rubygems"
require "gphoto4ruby"

c = GPhoto2::Camera.new

Чтобы получить текущее значение настройки камеры и управлять состоянием конкретной настройки, нужно проделать примерно следующее.

cfg = c.config      #{"capturemode" => "Single Shot",
                    # "exptime" => "0.125",
                    # "f-number" => "f/3.2", ...}
cfg.keys            # возможные настройки
c["f-number", :all] # ["f/2.8", "f/3.2", "f/3.5", ...]
                    # возможные значения
c["f-number"]       # текущее значение
c["f-number"] = "f/8" # вуаля! настройка на камере изменена

Не все настройки, имеющие несколько возможных значений, можно переключить с компьютера. Нельзя переключить то, для чего нужно что-то поворачивать или открывать на камере. Если вы что-то поменяли вручную на камере, то чтобы увидеть изменения параметра в объекте программы, нужно написать, например:

c["focusmode", :no_cache] # на Nikon D80 меняется переключателем

Съёмка

c.capture
c.capture.save.delete # сохранить снятую фотографию в текущую
                      # папку и удалить её с камеры
c.save :file => :last, :to_folder => "/home/sweet/home"
                      # сохранить последний из файлов в папку
c.save :file => c.files[3], :type => :preview, :new_name => "PREVIEW.JPG"
                      # загрузить превью фотографии под номером 4
                      # в списке файлов на карточке

При сохранении, если вы не делали съемку, могут быть тонкости связанные с тем, что нужно перейти в камере в нужную папку. Для этого есть ряд методов. Они хорошо описаны в документации по ссылке ниже.

Материалы для самостоятельного изучения

Библиотека libgphoto2 и инструмент для работы с камерами из командной строки gphoto2: есть FAQ. Документация оболочки gphoto4ruby: полный список классов и методов с примерами. Исходный код gphoto4ruby Расширение руби с помощью C

10.06.2009 firtree_right Ruby daemon, или как сделать демона на руби

Задача

Иногда в процессе работы с разными сторонними библиотеками, которые содержат тяжелые блокирующие методы, не обойтись обычным Thread. В таком случае на помощь приходят демоны :) Вот несколько приемов, которые я освоил в работе с нимим.

Базовый механизм

Запустить демона можно с помощью метода Kernel.fork, передав ему блок. Метод возвращает pid процесса, который можно записать в файл и просто использовать в дальнейшем.

pid = fork do
  puts "from daemon"
  exit!(1)
end

Определение статуса

Я не нашёл удобного способа определения методами руби, запущен ли процесс. Есть возможность фильтровать вывод ps ax, ища в нём pid процесса. Но есть метод Process.waitpid, который можно использовать хитрым образом. Так же для будущих задач, упакуем наш код в класс:

require 'timeout'

class Daemon
  class << self
    def start
      @pid = fork do
        puts "from daemon"
        sleep 1
        exit!(1)
      end
    end

    def running?
      if @pid
        begin
          Timeout::timeout(0.01) do
            Process.waitpid(@pid)
            if $?.exited?
              return false
            end
          end
        rescue Timeout::Error
        end
        return true
      else
        return false
      end
    end
  end
end

Daemon.start

puts "running: #{Daemon.running?}"
sleep 1.5

puts "running: #{Daemon.running?}"

Сообщение об ошибках

Предположим, что у нашего демона есть некоторый процесс инициализации, и мы хотим знать, завершился ли он или произошла ошибка до того, как покинем метод Daemon.start.

Для передачи подобных сообщений хорошо подходит IO.pipe. Для двустороннего общения нужно создавать два канала, но нам хватит и одного:

require 'timeout'

class Daemon
  def initialize
    puts "from daemon: initializing"
  end

  class << self
    def start
      @rd, @wr = IO.pipe
      @pid = fork do
        @rd.close
        begin
          dmn = new
          @wr.write "ok"
          @wr.close
          sleep 1
        rescue Exception => e
          @wr.write e.to_s
          @wr.close
        ensure
          exit!(1)
        end
      end
      @wr.close
      str = @rd.read
      if str == "ok"
        puts "daemon started ok"
      else
        puts "error while initializing daemon: #{str}"
      end
      @rd.close
    end

    def running?
      if @pid
        begin
          Timeout::timeout(0.01) do
            Process.waitpid(@pid)
            if $?.exited?
              return false
            end
          end
        rescue Timeout::Error
        end
        return true
      else
        return false
      end
    end
  end
end

Daemon.start

puts "running: #{Daemon.running?}"
sleep 1.5

puts "running: #{Daemon.running?}"

Остановка

У каждого демона (для того они обычно и создаются) есть циклическая часть. Нам хотелось бы запускать и останавливать процесс тогда, когда нам нужно. Если с запуском всё понятно, то для остановки потребуется ещё одна вещь. Ведь в тот момент, когда мы создали демона, он создает копии всех переменных и дальнейшее их изменение внутри и снаружи демона становится независимым. Ещё один способ общения с демоном — сигнал:

require 'timeout'

class Daemon
  def initialize
    puts "from daemon: initializing"
    @cnt = 0
  end

  def main_loop
    @cnt += 1
    puts "from daemon: running loop ##{@cnt}"
    sleep 0.1
  end

  class << self
    def start
      @rd, @wr = IO.pipe
      @pid = fork do
        @rd.close
        running = true
        Signal.trap("TERM") do
          running = false
        end
        begin
          dmn = new
          @wr.write "ok"
          @wr.close
          while running
            dmn.main_loop
          end
        rescue Exception => e
          @wr.write e.to_s
          @wr.close
        ensure
          exit!(1)
        end
      end
      @wr.close
      str = @rd.read
      if str == "ok"
        puts "daemon started ok"
      else
        puts "error while initializing daemon: #{str}"
      end
      @rd.close
    end

    def stop
      unless @pid.nil?
        Process.kill("TERM", @pid)
        @pid = nil
      end
    end

    def running?
      if @pid
        begin
          Timeout::timeout(0.01) do
            Process.waitpid(@pid)
            if $?.exited?
              return false
            end
          end
        rescue Timeout::Error
        end
        return true
      else
        return false
      end
    end
  end
end

Daemon.start

puts "running: #{Daemon.running?}"
sleep 1
Daemon.stop
puts "running: #{Daemon.running?}"

Теперь, если наследовать от этого класса свой класс и переопределить методы initialize и main_loop, получится вполне себе демон :)

Для самостоятельного изучения

Есть, конечно, ещё недостатки. Например:

  1. Если ошибка возникает в main_loop, то канал вывода уже закрыт. А если не закрывать канал, то метод IO#read не позволит нам выйти из метода start. Что делать?
  2. Если нужно периодически общаться с демоном общирными объемами информации, что делать? (я в своей задаче использовал TCPSocket, но это, ведь, не панацея)
  3. Хорошо бы хранить pid в pid-файле на случай нашествия зомби. И, соответственно, обрабатывать возникающие зомбо-проблемы.

Но это я оставлю на самостоятельное решение пытливым читателям.

28.05.2009 firtree_right Тестирование в ActionScript с помощью AsUnit

Введение

Сейчас занимаюсь тестированием и отладкой достаточно большого проекта на флексе. При каждом обновлении продукт проходит длительный и подробный этап тестирования. Как нельзя более актуальной становится проблема «исправив одну ошибку не сделать новых».

Один из инструментов, который мог бы мне помочь, если бы я его использовал сразу — это тестирование, а точнее Unit Tests (изолированное тестирование).

Понятно, что ActionScript не самый удобный язык для изолированного тестирования. Сложно сделать связанные объекты независимыми друг от друга, чтобы тестировать их по отдельности. Да и в целом продукты на Flash и Flex имеют большую интерфейсную составляющую. И тестировать зачастую нужно впечатления пользователя. Но тем не менее, покрытие тестами, пусть и небольшое, — это удобно.

Задача

Протестировать объект, который получает данные из xml и выдает их в качестве собственных параметров. Попробуем сделать это в лучших традициях TDD

Ресурсы

Нам понадобится AsUnit — прекрасная библиотека с открытыми исходниками для изолированного тестирования (unit testing).

Так же используем лучшие традиции TDD:

  1. Никакого кода, пока нет провалившегося теста.
  2. Тест пишется до тех пор, пока он не начнет проваливаться.
  3. Кода нужно писать ровно столько, чтобы проваливающийся тест прошёл.

Для решения будем использовать Adobe Flash CS3 в качестве редактора.

Решение

Итак, скачиваем AsUnit с сайта по ссылке выше. Создаем среду для тестирования. ConfigTest.as:

package {
  import asunit.framework.TestCase;

  public class ConfigTest extends TestCase {
    private var instance:Config;

    /**
     * Запускается перед каждый тестом
     */
    protected override function setUp():void {
      instance = new Config();
    }

    /**
     * Запускается после каждого теста
     */
    protected override function tearDown():void {
      instance = null;
    }

    /**
     * Тест-проверка, что созданный объект нужного класса
     */
    public function testIsConfig():void {
      assertTrue("Example is Config", instance is Config);
    }
  }
}

Это набор тестов для нашего будущего класса, который будет называться Config. Теперь, если бы у нас было несколько классов и несколько наборов тестов, их нужно было бы собрать воедино. AllTests.as:

package {
import asunit.framework.TestSuite;

  public class AllTests extends TestSuite {
    public function AllTests() {
      super();
      addTest(new ConfigTest());
    }
  }
}

Теперь нам нужен, собственно, тестировщик. AsUnitRunner.as:

package {
import asunit.textui.TestRunner;

  public class AsUnitRunner extends TestRunner {
    public function AsUnitRunner() {
      start(AllTests);
    }
  }
}

Теперь создаем AsUnitRunner.fla, в настройках File->Publish Settings->Flash->Settings прописываем в Document class базовый класс AsUnitRunner и добавляем путь к asunnit/as3/src в Classpath.

Попробуем запустить (ctrl+enter) — неудача! :) Можно сказать, провалившийся тест. Чтобы тест прошёл достаточно создать описание класса. Config.as:

package {
  public class Config {
  }
}

И теперь когда мы запускаем наш тестировщик мы видим:

AsUnit 3.0 by Luke Bayes and Ali Mills

Flash Player version: WIN 9,0,115,0

.

Time: 0.024

OK (1 test)


Time Summary:

23ms : ConfigTest

Все тесты проходят. Пора закончить писать код и снова перейти к написанию тестов. Добавим тестирование желаемого функционала. Я хочу загружать в Config xml и получать значения из него в виде заданных параметров. В описание ConfigTest.as добавим метод:

    /**
     * Тест-проверка, что из xml получаются параметры x и y
     */
    public function testParsesCoordinates():void {
      var xml:XML = <root>
                      <point>
                        <x>10</x>
                        <y>20</y>
                      </point>
                    </root>
      instance.fromXML(xml);
      assertEquals("X property should equal 10", 10, instance.x);
      assertEquals("Y property should equal 20", 20, instance.y);
    }

Попробуем запустить тестировщик — не компилируется, говоря, что у класса отсутствуют методы. Создаем описания методов в Config.as. Наша задача исправить только те ошибки, о которых нам сообщили. Теперь он выглядит так:

package {
  public class Config {
    public function fromXML(xml:XML):void {
    }
    public function get x():int {
      return 0;
    }
    public function get y():int {
      return 0;
    }
  }
}

Результат тестирования теперь выглядит так:

AsUnit 3.0 by Luke Bayes and Ali Mills

Flash Player version: WIN 9,0,115,0

..F

Time: 0.037
There was 1 failure:
0) ConfigTest.testParsesCoordinates
AssertionFailedError: X property should equal 10 expected:<10> but was:<0>
    at asunit.framework::Assert$/fail()
    at asunit.framework::Assert$/failNotEquals()
    at asunit.framework::Assert$/assertEquals()
    at ConfigTest/testParsesCoordinates()
    at asunit.framework::TestCase/runMethod()
    at asunit.framework::TestCase/runBare()
    at Function/http://adobe.com/AS3/2006/builtin::apply()
    at <anonymous>()
    at SetIntervalTimer/onTimer()
    at flash.utils::Timer/_timerDispatch()
    at flash.utils::Timer/tick()

FAILURES!!!
Tests run: 2,  Failures: 1,  Errors: 0


Time Summary:

36ms : ConfigTest

Заметьте, что после первой ошибки тестирование прекращается. Теперь мы можем написать код, чтобы пройти этот тест. Наш класс выглядит теперь так:

package {
  public class Config {
    private var _x:int;
    private var _y:int;
    public function fromXML(xml:XML):void {
      _x = int(xml.point.x);
      _y = int(xml.point.y);
    }
    public function get x():int {
      return _x;
    }
    public function get y():int {
      return _y;
    }
  }
}

А результат теста так:

AsUnit 3.0 by Luke Bayes and Ali Mills

Flash Player version: WIN 9,0,115,0

..

Time: 0.037

OK (2 tests)


Time Summary:

36ms : ConfigTest

Ураа!! Можно перестать писать код и написать ещё тесты! :)

Выводы

Понятно, что с непривычки это выглядит дико. Вместо одной программы нужно писать две. И вроде бы кажется, что я же знаю, как дальше писать, я четко вижу функционал. И в простых проектах это действительно так. Но...

В более сложных системах, когда множество одних объектов опираются на функционал других, не так легко удержать в голове все связи и все последствия исправлений. Возможно, именно то, что я только что исправил, использовалось другим объектом в том виде, в каком оно было.

Так же покрытие кода тестами обеспечивает более легкий вход изменений. Чисто даже психологически проще начать что-то менять.

Естественно, изолированное тестирование не оставляет без работы тестеров-людей. Но позволяет им сосредоточиться на тестировании именно того, что нельзя протестировать автоматически.

Материалы для изучения

Прекрасное видео про tdd в руби и просто прекрасное видео Документация и примеры AsUnit

16.05.2009 firtree_right Эволюция алгоритма замены в строке ActionScript

В последнее время моя работа в текущем проекте заключается в отладке, оптимизации и отлове багов. Поэтому в самой работе практически не встречается того, о чём можно было бы здесь написать. Но на помощь пришла гугл-группа ruFlash и комьюнити молодых программистов. :)

Задача

Один из участников попросил составить выражение для удаления из текста ссылок с определенным текстом внутри целиком. Например, в выражении:

var str:String = '<a href="somelink">_some text_</a> ';
str += 'More text! ';
str += '<a href="anotherlink">**remove me**</a> ';
str += '<a href="yetanotherlink"><s>another text</s></a>';

Нужно удалить целиком ссылку, содержащую фразу remove me.

Понятно, что первое приходящее в голову выражение /<a.+?remove me.*?<\/a>/ захватит две первые ссылки. И «жадность» не поможет, т.к. поиск осуществляется по порядку, и, найдя первый <a, выражение не остановится до самого remove me.

Решение номер один

Поскольку к концу недели отладки и пересмотра одного и того же кода голова моя не была готова что-то изобретать, я последовал пути, предлагавшемуся одним из ответивших, слегка его доделав:

var re0:RegExp = /<a[^>]+>[^a]*remove me.*?<\/a>/g;

trace(str.replace(re0, "!removed!"));

Недостаток его очевиден. Хотя для приведенного примера он работает, но всё-таки, может и отказать, если встретит a между > и remove me. Например:

str += '<a href="anotherlink">eh! ah? **remove me**</a> ';

Решение номер два (рекурсивное)

Поскольку к этому моменту мозг ещё не расслабился окончательно и не готов был отказаться от выбранного способа думать о задаче, второе решение, пришедшее сразу за первым, было значительно сложнее.

Оно использовало возможность подсовывать функцию в качестве аргумента. Вот оно:

var re1:RegExp = /<a(.+?remove me.*?<\/a>)/g;

var replacer1:Function = function():String {
  var s:String = arguments[1].toString();
  if (s.indexOf("<a") > 0) {
    return "<a" + s.replace(re1, replacer1);
  } else {
    return "!removed!";
  }
}

trace(str.replace(re1, replacer1));

Здесь речь идёт о том, чтобы в группе (см. скобки), следующей после <a проверять наличие ещё одного <a. И в случае его наличия запускать ту же процедуру замены, но уже на группе.

Довольный собой, я запостил своё решение в ruFlash и поехал домой. По дороге домой мозг окончательно расслабился, и я смог увидеть задачу в отрыве от способа думать, который я выбрал изначально. И мне пришло

Решение номер три

Зачем городить рекурсию, когда можно просто перебирать все ссылки и заменять (удалять) только те, что нужно?

var re2:RegExp = /<a[^>]+>(.+?)<\/a>/g;

var replacer2:Function = function():String {
   var s:String = arguments[1].toString();
  if (s.indexOf("remove me") > 0) {
    return "!removed!";
  } else {
    return arguments[0];
  }
}

trace(str.replace(re2, replacer2));

Это ли не чудесно?

Выводы

  1. Решайте задачи.
  2. Решив (или не решив), записывайте то, что получилось, покажите кому-нибудь. Это позволит выкинуть решение из головы.
  3. Если есть решение лучше, то оно придет на освободившееся место.

07.05.2009 firtree_right ActionScript: асинхронная замена выражений в строке

Введение

Я снова вернулся к работе над флэшовым проектом. Поэтому немного об ActionScript. Описанная здесь задача сейчас мне не кажется такой сложной, какой она казалась, когда я впервые с ней столкнулся. Но тем не менее.

Задача

Имеется строка, содержащая разметку для замены её составляющих. Одна из разметок: #[some_url] должна быть заменена содержимым этого самого some_url. Для замены с помощью регулярных выражений в ActionScript 3 существует функция String#replace. Но всё, что связано с загрузкой из внешних источников, создает асинхронность. А любая попытка остановить код, сделать паузу, приводит к огромной потере производительности и ошибкам, которые генерирует плеер, когда долго не может завершить вызов. «Как быть?» — спросит меня пытливый читатель.

Решение

Решение состоит в том, чтобы заменять все вхождения ключевого выражения по-очереди и когда всё заменено, создавать событие.

Приведу основную часть. Файл AsyncStringReplaceExample.as

package {
  import flash.events.Event;
  import flash.net.URLLoader;
  import flash.net.URLRequest;

  public class AsyncStringReplaceExample {
    public static const RE_URL:RegExp = /#\[([^\]]+)\]/g;

    private var _str:String;
    private var _currentExpr:String;
    private var _ldr:URLLoader;

    private var _loaded:Boolean;

    public function AsyncStringReplaceExample(str:String) {
      _str = str;
      _ldr = new URLLoader();
      _ldr.addEventListener(Event.COMPLETE, ldrCompleteHandler);
      _loaded = false;
    }

    public function replace():Boolean {
      _loaded = true;
      _str = _str.replace(RE_URL, replaceURL);
      return _loaded;
    }

    public function get string():String {
      return _str;
    }

    private function replaceURL():String {
      if (!_currentExpr) {
        _loaded = false;
        _currentExpr = arguments[0];
        _ldr.load(new URLRequest(arguments[1]));
      }
      return arguments[0];
    }

    private function ldrCompleteHandler(evt:Event):void {
      _str = _str.replace(_currentExpr, evt.target.data);
      _currentExpr = null;
      if (replace()) {
        trace(_str); // здесь желанное событие
      }
    }
  }
}

Теперь остается только использовать написанное нами богатство:

var str:String = "строка для примера\n";
str += "добавим: #[http://some.url/file.txt] или";
str += "ещё добавим: #[http://another.url/another/file.txt]! хватит?";

var asyncString:AsyncStringReplaceExample = new AsyncStringReplaceExample(str);
if (asyncString.replace()) {
  trace(asyncString.string); //не нужно ничего заменять
} else {
  asyncString.addEventListener(......)
}
...
// внутри обрабочика событий
trace(asyncString.string);

Выводы

Сразу видно, что последнее время я забросил ActionScript и занимался больше Ruby. Потому что подсветка синтаксиса в Ruby красивее. Но это не страшно. :)

Упражнения

Мне было бы интересно написать функцию, которая могла бы работать сколь угодно долго. Например, рекурсивную, или долгую по любой другой причине. Но ActionScript принудительно завершает процессы, которые долго не подают признаков жизни.

Как быть?

30.04.2009 firtree_right Тестирование Paperclip с помощью Factory Girl

Введение

Регулярно в своей работе я встречаюсь с новыми инструментами и решаю разные задачи. Иногда новые решения кажутся незначительными и слишком простыми для того, чтобы посвящать им целый пост в блоге. Но в нашем блоггерском деле главное — это регулярность. :)

Задача

Протестировать вновь изученный инструмент Paperclip с помощью вновь изученного инструмента Factory Girl.

Решение

Допустим, прикрепленный файл называется photo. То есть в модели написано следующее:

class Something < ActiveRecord::Base
  has_attached_file :photo
end

Кладем файл с фото, например, в папку с fixtures. Тогда файл test/factories.rb (или spec/factories.rb) будет содержать:

Factory.define :something do |smth|
  smth.photo File.new("path_to_file_in_fixtures", "rb")
end

И всё! Теперь в тестах, когда нужно создать объект пишем:

Factory.create(:something)
 # или
Factory.build(:something)

А в тестах контроллера пишем:

Factory.attributes_for(:something)

Выводы

Если вы хотите написать проект за короткое ограниченное время, то лучше всего использовать инструменты, которые вы уже опробовали и знаете.

Если вы хотите изучить новые инструменты, то лучше всего сделать небольшой проектик с их использованием.

Проекты в первом и втором случае значительно отличаются по объему. :)

Материалы для изучения

Множество прекрасных инструментов от ThoughtBot

20.04.2009 firtree_right Тестирование OpenID с помощью Cucumber

Задача

Какое-то время назад решил приобщиться к bdd (behavior driven development). С помощью такого подхода очень удобно описывать функционал приложения с точки зрения финального пользователя. И, соответственно, писать код так, чтобы выполнялись сценарии.

Для этого подхода существует прекрасный инструмент Cucumber, сценарии которого пишутся непосредственно текстом. Их, соответственно, можно даже обсуждать в качестве технического задания.

В качестве хорошего примера рассмотрим сценарий комментирования в этом блоге. Комментарии здесь можно оставлять только с помощью идентификации по OpenID.

Ресурсы

Нам понадобится, собственно, Cucumber, который для более лёгкого старта лучше использовать вместе с RSpec (фреймворк для удобного написания тестов и test driven development) и WebRat (инструмент для интегральных тестов, который содержит удобные методы работы с клиентским интерфейсом приложения).

Для работы с OpenID со стороны конечного пользователя нам понадобится локальный тестовый сервер OpenID. Есть хороший вариант: ROTS.

Затребуем библиотеки в тестовом окружении. В файле config/environments/test.rb:

config.gem "rspec", :lib => false, :version => ">=1.2.4"
config.gem "rspec-rails", :lib => false, :version => ">=1.2.4"
config.gem "webrat", :lib => false, :version => ">=0.4.4"
config.gem "cucumber", :lib => false, :version => ">=0.3.0"
config.gem "roman-rots", :lib => "rots", :version => ">=0.2.1"

Установим всё это:

sudo apt-get install libxml2-dev libxslt-dev
gem sources -a http://gems.github.com
rake gems:install RAILS_ENV=test

Решение

Предполагается, что как написать аутентификацию через OpenID мы знаем. Наша задача сейчас только протестировать функционал. В любом случае, когда мы приступаем к данному сценарию, у нас уже должны быть описаны и запрограммированы статьи и хотя бы создана модель для комментариев.

Теперь чтобы начать использовать Cucumber в приложении нужно запустить генератор:

script/generate cucumber

Процесс разработки в bdd выглядит так: сначала пишем сценарий, потом пишем всё остальное. Но в данном посте я просто покажу результат.

OpenID сервер

После установки библиотеки ROTS появляется консольная комманда:

rots

При запуске тестов необходимо, чтобы сервер был запущен. Для работы нам хватит параметров по умолчанию. В данном сервере уже есть пользователь, параметры для которого мы и будем использовать в тестах.

Сценарий Сucumber

Сценарии называются features. Создаем файл features/create_comments.feature:

Feature: Create Comments
  In order to give feedback
  As a reader
  I want to create comments

  Scenario: Create Comment
    Given I visit a page for the published post with 1 approved comment
    When I fill in "OpenID" with "http://localhost:1123/john.doe?openid.success=true"
    And I fill in "Имя" with "John Doe"
    And I fill in "Текст" with "Nice post!"
    And I press "Отправить"
    Then I should verify my OpenID
    And I should see "John Doe"
    And I should see "Nice post!"
    And the post should have 2 approved comments

Многие из шагов уже определены с помощью WebRat и RSpec. Доопределим то, чего не хватает.

Шаги для статей

Тут есть моё внутреннее соглашение (с самим собой :). Когда я использую определенный артикль the, то имею в виду статью, которую я запоминаю в течение каждого сценария. В файле features/step_definitions/post_steps.rb:

Given /^I visit a page for the (published|draft) post with (\d+) (approved )?comments?$/ do |pub, cmnts_count, appr|
  @post = Post.create!(:title => "Cucumber test post",
                       :body => "This is test post for cucumber comments",
                       :published => (pub == "published"))
  cmnts_count.to_i.times do |cnt|
    comment = @post.comments.build(:name => "Test User", :body => "Test body #{cnt}")
    comment.openid_url = "http://test.example"
    comment.approved = true unless appr.nil?
    comment.save!
  end
  visit post_url(@post)
end

Then /^the post should have (\d+) (approved )?comments?$/ do |count, appr|
  if appr.nil?
    @post.comments.count.should == count.to_i
  else
    @post.approved_comments.count.should == count.to_i
  end
end

Шаги для комментариев

Этот шаг специально написан для работы с ROTS, который, не требуя от пользователя никакого интерактивного взаимодействия в процессе теста отвечает на запрос редиректом подтверждая или отвергая авторизацию. В файле features/step_definitions/comment_steps.rb:

Then /^I should verify my OpenID$/ do
  response = Net::HTTP.get_response(URI.parse(headers['location']))
  response.class.should == Net::HTTPSeeOther
  visit response['location']
end

Заключение

Вот и всё. Чтобы запустить тест, нужно выполнить:

cucumber features

Далее, конечно же, следует написать сценарии для пустых полей, для отказа в авторизации, для неправильного адреса OpenID и прочее. Но это, как мне кажется, не составляет труда.

Так же известно, что Cucumber поддерживает и русский язык. То есть сценарии можно писать и на русском. Но я пока не пробовал.

Материалы для изучения

RailsCast про авторизацию с помощью OpenID RailsCast про использование Cucumber Документация по Cucumber

14.04.2009 firtree_right Фильтрация rss-потоков с помощью Sinatra и HTTParty

Задача

Для фильтрации rss-потоков сужествует множество инструментов. Для своей задачи мне захотелось написать простейшее решение и заодно попробовать пару новых инструментов.

Надо: собрать воедино несколько единообразных rss-потоков, отфильтровав только нужное, и выдать единый rss-поток.

Для удобства предположим, что потоки имеют одинаковый формат — atom. Адреса нужных нам потоков будут находиться в текстовом файле, разделенные переносом строки. Так же как и необходимые нам ключевые слова. Так же допустим, что наличие ключевых слов будем отслеживать в заголовках.

Ресурсы

Поскольку я собираюсь фильтровать на лету, мне не нужно ничего нигде хранить, я решил попробовать лёгкий руби-фреймворк под названием Sinatra.

А для работы с самими потоками, для получения их с их серверов используем простой и удобный инструмент HTTParty.

sudo gem i sinatra
sudo gem i httparty

Сбор и фильтрация

Создадим библиотечный файл feed_fetcher.rb:

require 'rubygems'
require 'httparty'

class FeedFetcher
  include HTTParty
  format :xml # позволяет получть результат сразу расфасованный
              # в Hash

  def self.get_items
    urls = nil # будет массив адресов
    titles = nil # будет массив нужных частей заголовков
    items = [] # будет массив записей
    File.open("path_to_feed_urls_file") do |f|
      urls = f.readlines.each(&:strip!)
    end
    File.open("path_to_titles_file") do |f|
      titles = f.readlines.each(&:strip!)
    end

    # составим единое регулярное выражение для фильтрации
    retitles = Regexp.union(titles.reject(&:empty?).map { |t| %r{\b#{Regexp.escape(t)}\b}i })

    # соберём записи со всех адресов в единый масив
    urls.each do |u|
      items += get(u)["rss"]["channel"]["item"] unless u.empty?
    end

    # отфильтруем по регулярному выражению и упорядочим по дате
    items.select { |i| i["title"] =~ retitles }.sort do |x, y|
      DateTime.parse(y["pubDate"]) <=> DateTime.parse(x["pubDate"])
    end
  end
end

Выдача результата

Результат будем так же выдавать в формате atom, поэтому нам понадобится builder, который, например, входит в состав active_support. Но можно установить его и отдельно.

Файл feed_filter.rb:

require 'rubygems'
require 'sinatra'
require 'active_support'
require 'feed_fetcher.rb'

get '/' do
  content_type 'application/xml', :charset => 'utf-8'
  @items = FeedFetcher.get_items
  builder :index
end

По-умолчанию Sinatra хранит шаблоны в папке views. Файл views/index.builder:

xml.instruct!
xml.rss "version" => "2.0", "xmlns:atom" => "http://www.w3.org/2005/Atom" do
  xml.channel do
    xml.title "My Filtered Feed"
    xml.link "http://lonelyelk.com"
    xml.pubDate CGI::rfc1123_date Time.parse(@items.first["pubDate"]) if @items.any?
    xml.description "Some description"
    @items.each do |item|
      xml.item do
        item.each_pair do |key, value|
          xml.tag!(key, value)
        end
      end
    end
  end
end

Запуск приложения с помощью passenger

Для запуска приложения мы будем использовать Passenger, который поддерживает не только rails, но и rack. Для этого нам понадобится создать папку public и указать к ней путь.

В установках виртуального сервера для apache:

<VirtualHost *:80>
  ServerAdmin webmaster@mydomain.ru
  ServerName feedfilter.mydomain.ru
  DocumentRoot /path/to/feed_filter/public
  ...
</VirtualHost>

А в папке приложения нужно создать файл config.ru:

require 'rubygems'
require 'sinatra'

Sinatra::Application.set(:run, false)
Sinatra::Application.set(:environment, ENV['RACK_ENV'])

require 'feed_filter'
run Sinatra::Application

Вот и всё. Естественно, ещё следует написать тесты. Так же для публикации можно использовать capistrano. Но это, я думаю, всем под силу.

Материалы для изучения

Первое знакомство с фреймворком Sinatra

28.03.2009 firtree_right Автосохранение с помощью JavaScript

О стандартизации

Ещё недавно я говорил о том, что не очень люблю JavaScript за то, что результат работает неодинаково в разных браузерах разных версий и ратовал за Flash и иже с ним, как за прекрасную альтернативу. Но судьба предоставила мне шанс убедиться, что и Flash с его ActionScript вполне может себя вести непредсказуемо в различный браузерах и разных версиях плеера, если программа достаточно сложная.

Я сразу же вспомнил о своём научном руководителе, который в школе курировал мою работу для поступления в ВУЗ. В своей работе он использовал FORTRAN. И на наши недоумённые вопросы отвечал, что это чуть ли не единственный язык, для которого можно записать текст программы любой сложности на дискетту и перенести на любой компьютер любой платформы. Видимо, так оно до сих пор и есть.

Задача

Чтобы отдать дань не очень справедливо заклейменному JavaScript, я решил написать для своего блога и для блога Иры автосохранение.

Ресурсы

Для работы нам понадобится Prototype, который входит в стандартную поставку Rails и обязательно крутилочка. Без крутилочки не стоит даже начинать. :)

Решение

Решение оказалось не очень сложное, поэтому я просто его опубликую с дополнительными пояснениями по мере надобности.

config/routes.rb

map.resources :posts, :member => {:autosave => :post}

Контроллер

...
def autosave
  @post = Post.find(params[:id])
  if @post.update_attributes(params[:post])
    render :nothing => true
  else
    render :nothing => true, :status => 400
  end
end
...

Шаблон

Привожу только код JavaScript, который понадобится для сохранения. Рекомендую обратить внимание, на решение проблемы authenticity_token

var cachedBody = $("post_body").value;
var cachedTitle = $("post_title").value;
var saveTimeout = 20000;
var modTimeout = 200;
var requestInProgress = false;

function checkSave() {
  if ((((cachedBody != $("post_body").value) && ($("post_body").value != "")) ||
       ((cachedTitle != $("post_title").value) && ($("post_title").value != ""))) &&
      !requestInProgress) {
    cachedTitle = $("post_title").value;
    cachedBody = $("post_body").value;
    $("spinner").show();
    requestInProgress = true;
    new Ajax.Request("<%= autosave_post_url(@post) -%>", {
        method: "post",
        postBody: Form.serializeElements([$("post_title"), $("post_body")]) +
              '&authenticity_token=' + encodeURIComponent('<%= form_authenticity_token -%>'),
        onSuccess: function (transport) {
              var now = new Date();
              $("spinner").hide();
              $("autosave_text").innerHTML = "Saved at " + 
                    (now.getHours() < 10 ? "0" : "") + now.getHours() + ":" +
                    (now.getMinutes() < 10 ? "0" : "") + now.getMinutes() + ":" +
                    (now.getSeconds() < 10 ? "0" : "") + now.getSeconds();
              setTimeout("checkSave()", saveTimeout);
              requestInProgress = false;
            },
        onFailure: function (transport) {
              $("spinner").hide();
              $("autosave_text").innerHTML = "Failed to autosave";
              requestInProgress = false;
            }
      });
  } else {
    setTimeout("checkSave()", saveTimeout);
  }
}

function checkMod() {
  if (((cachedBody != $("post_body").value) && ($("post_body").value != "")) ||
      ((cachedTitle != $("post_title").value) && ($("post_title").value != ""))) {
    if ($("autosave_text").innerHTML.charAt(0) != "*") {
      $("autosave_text").innerHTML = "* " + $("autosave_text").innerHTML;
    }
  }
  setTimeout("checkMod()", modTimeout);
}

function checks() {
  checkSave();
  checkMod();
}

document.observe('dom:loaded', checks);

В данном случае spinner — это id крутилочки. Крутилочка показывается, когда проходит запрос. Так же здесь имеется дополнительный цикл вызовов. Раз уж мы всё равно храним копию того, что сохранено на сервере, то почему бы не показывать, когда текст уже изменен, но не сохранен на сервере. С помощью звездочки.

А как сделать, чтобы звездочка исчезала, если текст в полях снова стал такой, оставлю на самостоятельное изучение.

Материалы для изучения

Интересный пример ненавязчивого JavaScript для создания «бесконечной» страницы

24.03.2009 firtree_right Jabber бот

Возникла идея написать jabber-бота. Выбор протокола обусловлен тем, что по нему достаточно много документации. И стандарт открытый — это хорошо. Много информации на code.google.com, но я решил пойти более простым путём и нашёл библиотеку на руби xmpp4r. Благодаря тому, что разработка ведется на руби, время разработки простого бота значительно сокращается. Устанавливается как библиотека:

sudo gem i xmpp4r

Регистрация

Это прекрасно, что бот может сам себя зарегистрировать.

require "rubygems"
require "xmpp4r"

include Jabber

client = Client.new(JID.new("login@server"))
client.connect

begin
  client.register "secretpassword"
  puts "Success!"
rescue ServerError => e
  puts "Error: #{e.error.text}"
end

При регистрации можно передавать хэш с параметрами для визитной карточки. После регистрации для последующей авторизации вместо register вызываеься:

client.auth "secretpassword"

Список контактов

Список контактов называется roster. Чтобы с ним работать нужно его запросить:

require "xmpp4r/roster"
roster = Roster::Helper.new(client)

А после этого обработать ответы при обратных вызовах. Если добавлять друзей будет тоже бот, и не будет заморачиваться с группами контактов, то для вывода списка друзей на данный момент достаточно описать вызов:

roster.add_query_callback do |iq|
  puts "current roster:"
  roster.find_by_group(nil).each do |item|
    puts "#{item.jid}"
  end
end

Простейшее разрешение видеть свой статус и добавление в контакты в ответ на то, что добавляют бота выглядит так:

roster.add_subscription_request_callback do |item, pres|
  puts "Added by #{pres.from}"
  roster.accept_subscription(pres.from) # Разрешить видеть свой статус
  new_pres = Presence.new.set_type(:subscribe).set_to(pres.from)
  client.send(new_pres) # Запросить разрешение видеть статус
end

Теперь наши контакты смогут видеть наш бот онлайн, когда мы сообщаем об этом:

client.send(Presence.new.set_type(:available))

Неплохо бы что-нибудь ещё добавить, типа аватара.

Визитная карточка

Визитная карточка называется vcard:

require "xmpp4r/vcard"
require "base64"

vcard = Vcard::IqVcard.new
vcard["FN"] = "Full Name"
vcard["NICKNAME"] = "bot-nickname"
vcard["PHOTO/TYPE"]= "image/jpeg"
avatar_file = File.new("avatar.jpg", "r")
avatar_b64 = Base64.b64encode(avatar_file.read())
avatar_file.rewind
avatar_sha1 = Digest::SHA1.hexdigest(avatar_file.read())
vcard["PHOTO/BINVAL"] = avatar_b64
avatar_file.close

begin
  Vcard::Helper.new(client).set(vcard)
  puts "Success!"
rescue Exception => e
  puts "VCARD operation failed: #{e.to_s}" 
end

В соответствии со спецификацией xmpp относительно аватаров следует транслировать с какой-то периодичностью sha своего аватара:

Thread.new do
  while true do
    unless avatar_sha1.nil?
      pres = Presence.new
      x = REXML::Element.new("x")
      x.add_namespace("vcard-temp:x:update")
      photo = REXML::Element.new("photo")
      avatar_hash = REXML::Text.new(avatar_sha1)
      photo.add(avatar_hash)
      x.add(photo)
      pres.add_element(x)
      client.send(pres)
    end
    sleep 60
  end
end

Дело за малым.

Ответ на сообщения

cl.add_message_callback do |m|
  if m.type != :error and m.body
    client.send(m.answer.set_body(bot_main_function(m.body)))
  end
end

Внутри bot_main_function находится собственно то, что будет делать ваш бот, как он будет отвечать на то, что написали ему.

Материалы для изучения

Ещё интересные примеры