diff --git a/app/decorators/asciicast_decorator.rb b/app/decorators/asciicast_decorator.rb index 85170f6..6d56c47 100644 --- a/app/decorators/asciicast_decorator.rb +++ b/app/decorators/asciicast_decorator.rb @@ -35,23 +35,8 @@ class AsciicastDecorator < ApplicationDecorator end def thumbnail(width = THUMBNAIL_WIDTH, height = THUMBNAIL_HEIGHT) - if @thumbnail.nil? - lines = model.snapshot.to_s.split("\n") - - top_lines = lines[0...height] - top_text = prepare_lines(top_lines, width, height).join("\n") - - bottom_lines = lines.reverse[0...height].reverse - bottom_text = prepare_lines(bottom_lines, width, height).join("\n") - - if top_text.gsub(/\s+/, '').size > bottom_text.gsub(/\s+/, '').size - @thumbnail = top_text - else - @thumbnail = bottom_text - end - end - - @thumbnail + thumbnail = model.snapshot.crop(width, height) + SnapshotPresenter.new(thumbnail).to_html end def description diff --git a/app/models/asciicast.rb b/app/models/asciicast.rb index 2a5ff62..e753262 100644 --- a/app/models/asciicast.rb +++ b/app/models/asciicast.rb @@ -6,7 +6,7 @@ class Asciicast < ActiveRecord::Base mount_uploader :stdout, BasicUploader mount_uploader :stdout_timing, BasicUploader - serialize :snapshot, Snapshot::Serializer.new + serialize :snapshot, Snapshot validates :stdout, :stdout_timing, :presence => true validates :terminal_columns, :terminal_lines, :duration, :presence => true diff --git a/app/models/brush.rb b/app/models/brush.rb index 39b2fa0..30f3ef3 100644 --- a/app/models/brush.rb +++ b/app/models/brush.rb @@ -1,5 +1,4 @@ class Brush - attr_reader :attributes def initialize(attributes = {}) @attributes = attributes @@ -24,4 +23,9 @@ class Brush def inverse? !!attributes[:inverse] end + + private + + attr_reader :attributes + end diff --git a/app/models/snapshot.rb b/app/models/snapshot.rb index 54ce0f6..3fbe9c4 100644 --- a/app/models/snapshot.rb +++ b/app/models/snapshot.rb @@ -1,4 +1,5 @@ class Snapshot + attr_reader :lines def initialize(lines = []) @@ -9,13 +10,9 @@ class Snapshot other.lines == lines end - class Serializer - def dump(snapshot) - YAML.dump(snapshot.lines) - end - - def load(value) - value.present? ? Snapshot.new(YAML.load(value)) : Snapshot.new - end + def crop(width, height) + new_lines = lines.drop(lines.size - height).map { |line| line.crop(width) } + self.class.new(new_lines) end + end diff --git a/app/models/snapshot_fragment.rb b/app/models/snapshot_fragment.rb new file mode 100644 index 0000000..8b3d839 --- /dev/null +++ b/app/models/snapshot_fragment.rb @@ -0,0 +1,22 @@ +class SnapshotFragment + + attr_reader :text, :brush + + def initialize(text, brush) + @text = text + @brush = brush + end + + def ==(other) + other.text == text && other.brush == brush + end + + def crop(size) + if size >= text.size + self + else + self.class.new(text[0...size], brush) + end + end + +end diff --git a/app/models/snapshot_line.rb b/app/models/snapshot_line.rb new file mode 100644 index 0000000..c4dcaae --- /dev/null +++ b/app/models/snapshot_line.rb @@ -0,0 +1,31 @@ +class SnapshotLine + + attr_reader :fragments + + def initialize(fragments) + @fragments = fragments + end + + def ==(other) + other.fragments == fragments + end + + def crop(size) + new_fragments = [] + current_size = 0 + + fragments.each do |fragment| + break if current_size == size + + if current_size + fragment.size > size + fragment = fragment.crop(size - current_size) + end + + new_fragments << fragment + current_size += fragment.size + end + + self.class.new(new_fragments) + end + +end diff --git a/app/presenters/brush_presenter.rb b/app/presenters/brush_presenter.rb new file mode 100644 index 0000000..6961988 --- /dev/null +++ b/app/presenters/brush_presenter.rb @@ -0,0 +1,33 @@ +class BrushPresenter < SimpleDelegator + + def to_css_class + if default? + nil + else + [fg_class, bg_class, bold_class, underline_class, inverse_class].compact.join(' ') + end + end + + private + + def fg_class + "fg#{fg}" if fg + end + + def bg_class + "bg#{bg}" if bg + end + + def bold_class + 'bold' if bold? + end + + def underline_class + 'underline' if underline? + end + + def inverse_class + 'inverse' if inverse? + end + +end diff --git a/app/presenters/snapshot_fragment_presenter.rb b/app/presenters/snapshot_fragment_presenter.rb new file mode 100644 index 0000000..6688af4 --- /dev/null +++ b/app/presenters/snapshot_fragment_presenter.rb @@ -0,0 +1,15 @@ +class SnapshotFragmentPresenter < Draper::Decorator + + delegate :text, :brush + + def to_html + h.content_tag(:span, text, :class => css_class) + end + + private + + def css_class + BrushPresenter.new(brush).to_css_class + end + +end diff --git a/app/presenters/snapshot_line_presenter.rb b/app/presenters/snapshot_line_presenter.rb new file mode 100644 index 0000000..d72a51d --- /dev/null +++ b/app/presenters/snapshot_line_presenter.rb @@ -0,0 +1,19 @@ +class SnapshotLinePresenter < Draper::Decorator + + delegate :fragments + + def to_html + h.content_tag(:span, fragment_strings.join.html_safe, :class => 'line') + end + + private + + def fragment_strings + fragments.map { |fragment| fragment_string(fragment) } + end + + def fragment_string(fragment) + SnapshotFragmentPresenter.new(fragment).to_html + end + +end diff --git a/app/presenters/snapshot_presenter.rb b/app/presenters/snapshot_presenter.rb new file mode 100644 index 0000000..8f6a7d5 --- /dev/null +++ b/app/presenters/snapshot_presenter.rb @@ -0,0 +1,19 @@ +class SnapshotPresenter < Draper::Decorator + + delegate :lines + + def to_html + h.content_tag(:pre, line_strings.join.html_safe, :class => 'thumbnail') + end + + private + + def line_strings + lines.map { |line| line_string(line) } + end + + def line_string(line) + SnapshotLinePresenter.new(line).to_html + end + +end diff --git a/spec/decorators/asciicast_decorator_spec.rb b/spec/decorators/asciicast_decorator_spec.rb index 9ee0872..f7996e2 100644 --- a/spec/decorators/asciicast_decorator_spec.rb +++ b/spec/decorators/asciicast_decorator_spec.rb @@ -134,7 +134,24 @@ describe AsciicastDecorator do end describe '#thumbnail' do - pending + let(:snapshot) { double('snapshot', :crop => thumbnail) } + let(:thumbnail) { double('thumbnail') } + let(:presenter) { double('presenter') } + + before do + allow(asciicast).to receive(:snapshot) { snapshot } + allow(SnapshotPresenter).to receive(:new).with(thumbnail) { presenter } + allow(presenter).to receive(:to_html) { '
' } + end + + it 'crops the snapshot' do + decorated.thumbnail(21, 13) + expect(snapshot).to have_received(:crop).with(21, 13) + end + + it 'returns html snapshot rendered by SnapshotPresenter#to_html' do + expect(decorated.thumbnail).to eq('') + end end describe '#author' do @@ -230,7 +247,7 @@ describe AsciicastDecorator do end it 'returns avatar_image_tag' do - decorated.stub!(:h => h) + decorated.stub(:h => h) h.should_receive(:avatar_image_tag).with(nil).and_return(avatar_image) subject.should == avatar_image end @@ -243,7 +260,7 @@ describe AsciicastDecorator do describe '#embed_script' do before do - asciicast.stub!(:id => 123) + asciicast.stub(:id => 123) end it 'should be an async script tag including asciicast id' do diff --git a/spec/models/asciicast_spec.rb b/spec/models/asciicast_spec.rb index 9c0c270..e3b6430 100644 --- a/spec/models/asciicast_spec.rb +++ b/spec/models/asciicast_spec.rb @@ -120,24 +120,31 @@ describe Asciicast do describe '#snapshot' do let(:asciicast) { Asciicast.new } + let(:snapshot) { + Snapshot.new([ + SnapshotLine.new([ + SnapshotFragment.new('foo', { :fg => 1 }) + ]) + ]) + } it 'is empty Snapshot instance initially' do expect(asciicast.snapshot).to eq(Snapshot.new) end it 'is a Snapshot instance before persisting' do - asciicast.snapshot = Snapshot.new({ :foo => 1 }) + asciicast.snapshot = snapshot - expect(asciicast.snapshot).to eq(Snapshot.new({ :foo => 1 })) + expect(asciicast.snapshot).to eq(snapshot) end it 'is a Snapshot instance after persisting and loading' do asciicast = build(:asciicast) - asciicast.snapshot = Snapshot.new({ :foo => 1 }) + asciicast.snapshot = snapshot asciicast.save! - expect(asciicast.snapshot).to eq(Snapshot.new({ :foo => 1 })) - expect(Asciicast.find(asciicast.id).snapshot).to eq(Snapshot.new({ :foo => 1 })) + expect(asciicast.snapshot).to eq(snapshot) + expect(Asciicast.find(asciicast.id).snapshot).to eq(snapshot) end end end diff --git a/spec/models/snapshot_fragment_spec.rb b/spec/models/snapshot_fragment_spec.rb new file mode 100644 index 0000000..af97e02 --- /dev/null +++ b/spec/models/snapshot_fragment_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe SnapshotFragment do + describe '#==' do + let(:snapshot_fragment) { SnapshotFragment.new('foo', brush_1) } + let(:brush_1) { double('brush_1') } + let(:brush_2) { double('brush_2') } + + subject { snapshot_fragment == other } + + context "when fragments have the same texts and brushes" do + let(:other) { SnapshotFragment.new('foo', brush_1) } + + it { should be(true) } + end + + context "when fragments have different texts" do + let(:other) { SnapshotFragment.new('bar', brush_1) } + + it { should be(false) } + end + + context "when fragments have different brushes" do + let(:other) { SnapshotFragment.new('foo', brush_2) } + + it { should be(false) } + end + end + + describe '#crop' do + let(:snapshot_fragment) { SnapshotFragment.new('foobar', brush) } + let(:brush) { double('brush') } + + context "when size is smaller than fragment's size" do + subject { snapshot_fragment.crop(3) } + + it 'returns a new instance of SnapshotFragment' do + expect(subject).to be_kind_of(SnapshotFragment) + expect(subject).to_not be(snapshot_fragment) + end + + it 'trims the text to the requested size' do + expect(subject.text).to eq('foo') + end + + it 'returns SnapshotFragment with the same brush' do + expect(subject.brush).to be(brush) + end + end + + context "when size is equal or larger than the fragment's size" do + it 'returns self' do + expect(snapshot_fragment.crop(6)).to be(snapshot_fragment) + end + end + end +end diff --git a/spec/models/snapshot_line_spec.rb b/spec/models/snapshot_line_spec.rb new file mode 100644 index 0000000..43c5bfa --- /dev/null +++ b/spec/models/snapshot_line_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe SnapshotLine do + describe '#==' do + let(:snapshot_line) { SnapshotLine.new([:foo]) } + + subject { snapshot_line == other } + + context "when lines have the same fragments" do + let(:other) { SnapshotLine.new([:foo]) } + + it { should be(true) } + end + + context "when lines have different fragments" do + let(:other) { SnapshotLine.new([:foo, :bar]) } + + it { should be(false) } + end + end + + describe '#crop' do + let(:snapshot_line) { SnapshotLine.new(fragments) } + let(:fragments) { [fragment_1, fragment_2] } + let(:fragment_1) { double('fragment_1', :size => 2, :crop => nil) } + let(:fragment_2) { double('fragment_2', :size => 3, + :crop => cropped_fragment_2) } + let(:fragment_3) { double('fragment_3', :size => 4, :crop => nil) } + let(:cropped_fragment_2) { double('cropped_fragment_2', :size => 2) } + + context "when cropping point is at the end of the first fragment" do + it 'crops none of the fragments' do + snapshot_line.crop(2) + + expect(fragment_1).to_not have_received(:crop) + expect(fragment_2).to_not have_received(:crop) + expect(fragment_3).to_not have_received(:crop) + end + + it 'returns a new SnapshotLine with only the first fragment' do + expect(snapshot_line.crop(2)).to eq(SnapshotLine.new([fragment_1])) + end + end + + context "when cropping point is inside of the second fragment" do + it 'crops only the second fragment' do + snapshot_line.crop(4) + + expect(fragment_1).to_not have_received(:crop) + expect(fragment_2).to have_received(:crop).with(2) + expect(fragment_3).to_not have_received(:crop) + end + + it 'returns a new SnapshotLine with first two fragments cropped' do + expect(snapshot_line.crop(4)). + to eq(SnapshotLine.new([fragment_1, cropped_fragment_2])) + end + end + end +end diff --git a/spec/models/snapshot_spec.rb b/spec/models/snapshot_spec.rb index 23359f9..aa80f48 100644 --- a/spec/models/snapshot_spec.rb +++ b/spec/models/snapshot_spec.rb @@ -20,4 +20,25 @@ describe Snapshot do end end + describe '#crop' do + let(:snapshot) { Snapshot.new(lines) } + let(:lines) { [line_1, line_2, line_3] } + let(:line_1) { double('line_1', :crop => nil) } + let(:line_2) { double('line_2', :crop => cropped_line_2) } + let(:line_3) { double('line_3', :crop => cropped_line_3) } + let(:cropped_line_2) { double('cropped_line_2') } + let(:cropped_line_3) { double('cropped_line_3') } + + it 'crops the last "height" lines' do + snapshot.crop(3, 2) + + expect(line_1).to_not have_received(:crop) + expect(line_2).to have_received(:crop).with(3) + expect(line_3).to have_received(:crop).with(3) + end + + it 'returns a new Snapshot with cropped lines' do + expect(snapshot.crop(3, 2)).to eq(Snapshot.new([cropped_line_2, cropped_line_3])) + end + end end diff --git a/spec/presenters/brush_presenter_spec.rb b/spec/presenters/brush_presenter_spec.rb new file mode 100644 index 0000000..87dd0d4 --- /dev/null +++ b/spec/presenters/brush_presenter_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +describe BrushPresenter do + let(:brush_presenter) { BrushPresenter.new(brush) } + let(:brush) { double('brush', :fg => nil, :bg => nil, :bold? => false, + :underline? => false, :inverse? => false) } + + describe '#to_css_class' do + subject { brush_presenter.to_css_class } + + context "when brush is a default one" do + before do + allow(brush).to receive(:default?) { true } + end + + it { should be(nil) } + end + + context "when brush is not a default one" do + before do + allow(brush).to receive(:default?) { false } + end + + context "when fg is default" do + before do + allow(brush).to receive(:fg) { nil } + end + + it { should_not match(/\bfg/) } + end + + context "when fg is non-default" do + before do + allow(brush).to receive(:fg) { 1 } + end + + it { should match(/\bfg1\b/) } + end + + context "when bg is default" do + before do + allow(brush).to receive(:bg) { nil } + end + + it { should_not match(/\bbg/) } + end + + context "when bg is non-default" do + before do + allow(brush).to receive(:bg) { 2 } + end + + it { should match(/\bbg2\b/) } + end + + context "when both fg and bg are non-default" do + before do + allow(brush).to receive(:fg) { 1 } + allow(brush).to receive(:bg) { 2 } + end + + it { should match(/\bfg1\b/) } + it { should match(/\bbg2\b/) } + end + + context "when it's bold" do + before do + allow(brush).to receive(:bold?) { true } + end + + it { should match(/\bbold\b/) } + end + + context "when it's underline" do + before do + allow(brush).to receive(:underline?) { true } + end + + it { should match(/\bunderline\b/) } + end + + context "when it's inverse" do + before do + allow(brush).to receive(:inverse?) { true } + end + + it { should match(/\binverse\b/) } + end + end + end +end diff --git a/spec/presenters/snapshot_fragment_presenter_spec.rb b/spec/presenters/snapshot_fragment_presenter_spec.rb new file mode 100644 index 0000000..a4bb889 --- /dev/null +++ b/spec/presenters/snapshot_fragment_presenter_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe SnapshotFragmentPresenter do + let(:snapshot_fragment_presenter) { SnapshotFragmentPresenter.new(snapshot_fragment) } + let(:snapshot_fragment) { SnapshotFragment.new('foo > bar', brush) } + let(:brush) { double('brush') } + let(:brush_presenter) { double('brush_presenter', :to_css_class => css_class) } + let(:css_class) { 'qux' } + + describe '#to_html' do + subject { snapshot_fragment_presenter.to_html } + + before do + allow(BrushPresenter).to receive(:new).with(brush). + and_return(brush_presenter) + end + + it { should be_kind_of(ActiveSupport::SafeBuffer) } + it { should eq('foo > bar') } + + context "when css class is nil" do + let(:css_class) { nil } + + it { should eq('foo > bar') } + end + end +end diff --git a/spec/presenters/snapshot_line_presenter_spec.rb b/spec/presenters/snapshot_line_presenter_spec.rb new file mode 100644 index 0000000..5d14c39 --- /dev/null +++ b/spec/presenters/snapshot_line_presenter_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe SnapshotLinePresenter do + let(:snapshot_line_presenter) { SnapshotLinePresenter.new(snapshot_line) } + let(:snapshot_line) { SnapshotLine.new([fragment_1, fragment_2]) } + let(:fragment_1) { double('fragment_1') } + let(:fragment_2) { double('fragment_2') } + let(:fragment_1_presenter) { double(:to_html => '') } + end +end