vivisector 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/gem/lib/vivisector/exceptions.rb +15 -0
- data/gem/lib/vivisector/filesystem.rb +22 -0
- data/gem/lib/vivisector/image.rb +19 -0
- data/gem/lib/vivisector/imageloader.rb +60 -0
- data/gem/lib/vivisector/imageset.rb +119 -0
- data/gem/lib/vivisector/report.rb +110 -0
- data/gem/lib/vivisector/reportset.rb +45 -0
- data/gem/lib/vivisector/target.rb +9 -0
- data/gem/lib/vivisector/version.rb +3 -0
- data/gem/lib/vivisector.rb +40 -0
- metadata +110 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: cd1749784defc5a1e9d266f8facd3423f42b4a5e
|
4
|
+
data.tar.gz: ca695ab523abff57b7fd7f2d1673c2b3579245ff
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6f89aa97f267fc8552d9be9aa2a993a90f896636be4d1831d21fba09ed78b9080146da12c2fa6629c35b3712c0560a07a95bdc96e1ca4697efaeb3029842d31e
|
7
|
+
data.tar.gz: c02b5c7656672f41bbde11059a0f393e786ed249bd68fc30b189ba0d33ad2d37ba2f9d3b595ca05607c2d4cb7789ecdac66d07c5d9b6b82f91dbca3ce94b0e11
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
# Vivisector exceptions
|
3
|
+
|
4
|
+
module Vivisector
|
5
|
+
|
6
|
+
# For enforcing the use of certain classes even though they may share a base class
|
7
|
+
class IncompatibilityError < TypeError; end
|
8
|
+
|
9
|
+
# For reporting improper extension of the Vivisector base classes (developer error)
|
10
|
+
class ImplementationError < NotImplementedError; end
|
11
|
+
|
12
|
+
# For reporting assertions that fail at runtime (logic errors in extender's code)
|
13
|
+
class OperationalError < RuntimeError; end
|
14
|
+
|
15
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require_relative 'exceptions'
|
3
|
+
|
4
|
+
# return true if the other_path is deeper than or equal to the base path
|
5
|
+
# http://stackoverflow.com/a/26878510/2063546
|
6
|
+
def ancestor?(base, other_path)
|
7
|
+
base_parts = File.expand_path(base).split('/')
|
8
|
+
path_parts = File.expand_path(other_path).split('/')
|
9
|
+
path_parts[0..base_parts.size-1] == base_parts
|
10
|
+
end
|
11
|
+
|
12
|
+
# return the relative path from a document in a web root to a media element, given full paths to each
|
13
|
+
def web_relative_path(web_root, base_document, child_element)
|
14
|
+
c = File.expand_path(child_element)
|
15
|
+
r = File.expand_path(web_root)
|
16
|
+
unless ancestor?(r, c)
|
17
|
+
raise Vivisector::OperationalError, "Child element '#{c}' is not an ancestor of the web root '#{r}'"
|
18
|
+
end
|
19
|
+
base = Pathname.new (File.dirname(File.expand_path(base_document)))
|
20
|
+
elem = Pathname.new c
|
21
|
+
(elem.relative_path_from base).to_s
|
22
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
|
2
|
+
module Vivisector
|
3
|
+
|
4
|
+
# A simple container for image information
|
5
|
+
class Image
|
6
|
+
attr_accessor :path # the location on disk
|
7
|
+
attr_accessor :id # an application-specific unique identifier
|
8
|
+
attr_accessor :description # a friendly description
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
# Design images may optionally specify an attribute
|
13
|
+
class DesignImage < Image
|
14
|
+
attr_accessor :attribute
|
15
|
+
end
|
16
|
+
|
17
|
+
# App images are the same as images, but create this alias for symmetry / clarity
|
18
|
+
class AppImage < Image; end
|
19
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
|
2
|
+
class ImageLoader
|
3
|
+
|
4
|
+
attr_reader :image_set
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@image_set = nil # the image set for this loader
|
8
|
+
|
9
|
+
end
|
10
|
+
|
11
|
+
def load
|
12
|
+
image_set = self.fetch_image_set
|
13
|
+
self.allow_image_set image_set
|
14
|
+
@image_set = image_set
|
15
|
+
end
|
16
|
+
|
17
|
+
# implementation-specific: return a filled ImageSet
|
18
|
+
def fetch_image_set
|
19
|
+
puts " +++ If you're seeing this, #{self.class.name}.#{__method__} was not overridden"
|
20
|
+
end
|
21
|
+
|
22
|
+
# implementation-specific: verify that an ImageSet is appropriate
|
23
|
+
def allow_image_set image_set
|
24
|
+
puts " +++ If you're seeing this, #{self.class.name}.#{__method__} was not overridden"
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
module Vivisector
|
31
|
+
|
32
|
+
class Anatomy < ImageLoader
|
33
|
+
|
34
|
+
def initialize
|
35
|
+
super
|
36
|
+
@order = [] # will hold the sorted indexes into the image set array
|
37
|
+
end
|
38
|
+
|
39
|
+
def allow_image_set image_set
|
40
|
+
raise ImplementationError, "Got a nil DesignImageSet object; was #{self.class.name} properly extended?" if image_set.nil?
|
41
|
+
|
42
|
+
# Ensure that we are only looking at design images
|
43
|
+
raise IncompatibilityError, "Tried to add a non- DesignImageSet object to Anatomy" unless image_set.is_a? DesignImageSet
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
class Appography < ImageLoader
|
50
|
+
|
51
|
+
def allow_image_set image_set
|
52
|
+
raise ImplementationError, "Got a nil DesignImageSet object; was #{self.class.name} properly extended?" if image_set.nil?
|
53
|
+
|
54
|
+
# Ensure that we are only looking at implementation images
|
55
|
+
raise IncompatibiltyError, "Tried to add a DesignImageSet object to Appography" if image_set.is_a? DesignImageSet
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
|
2
|
+
module Vivisector
|
3
|
+
|
4
|
+
# Container class for sets of images
|
5
|
+
class ImageSet
|
6
|
+
|
7
|
+
attr_reader :images # the container for all the images in the set
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@images = []
|
11
|
+
@cache_valid = false # cache invalidation is so easy
|
12
|
+
end
|
13
|
+
|
14
|
+
# Safe way to add images to the container
|
15
|
+
def add image
|
16
|
+
self.allow image
|
17
|
+
# fix relative paths
|
18
|
+
image.path = File.expand_path(image.path)
|
19
|
+
@images << image
|
20
|
+
@cache_valid = false
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
# Raise an error if the image is not appropriate for this type of set
|
25
|
+
def allow image
|
26
|
+
puts " +++ If you're seeing this, #{self.class.name}.#{__method__} was not overridden"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
# Design image sets need to be able to report on the images they contain
|
32
|
+
class DesignImageSet < ImageSet
|
33
|
+
|
34
|
+
def allow image
|
35
|
+
# Ensure that image objects have an "attribute" field, among other things
|
36
|
+
raise IncompatibilityError, "Tried to add a non- DesignImage object to a DesignImageSet" unless image.is_a? DesignImage
|
37
|
+
|
38
|
+
# no duplicates allowed
|
39
|
+
if (@images.map { |i| [i.id, i.attribute] }).include? [image.id, image.attribute]
|
40
|
+
raise OperationalError, "Tried to add an image with duplicate ID and attribute"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get the list of unique attributes contained by the images within
|
45
|
+
def contained_attributes
|
46
|
+
(@images.map { |i| i.attribute}).uniq
|
47
|
+
end
|
48
|
+
|
49
|
+
# cache the image attributes
|
50
|
+
def cache_image_attributes
|
51
|
+
return if @cache_valid
|
52
|
+
|
53
|
+
# make a hash -- hash[image id] = list of attributes defined for this image id
|
54
|
+
# we use this for convenience later
|
55
|
+
@attributes_by_id = {}
|
56
|
+
@images.each do |img|
|
57
|
+
proper_key = img.id.to_s
|
58
|
+
@attributes_by_id[proper_key] = [] unless @attributes_by_id.has_key? proper_key
|
59
|
+
@attributes_by_id[proper_key] << img.attribute
|
60
|
+
end
|
61
|
+
|
62
|
+
@cache_valid = true
|
63
|
+
end
|
64
|
+
|
65
|
+
# get the list of images that are valid for a particular attribute
|
66
|
+
def images_for_attribute attribute
|
67
|
+
self.cache_image_attributes
|
68
|
+
|
69
|
+
# return the pared-down list
|
70
|
+
@images.select do |img|
|
71
|
+
# this covers nil == nil and attribute == attribute
|
72
|
+
next true if img.attribute == attribute
|
73
|
+
|
74
|
+
# if there is no attribute for this image, it should be pulled in
|
75
|
+
# ... unless there's an exact match elsewhere in the set.
|
76
|
+
next true if img.attribute.nil? unless @attributes_by_id[img.id.to_s].include? attribute
|
77
|
+
|
78
|
+
false
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
# App image sets are tied to a target
|
86
|
+
class AppImageSet < ImageSet
|
87
|
+
|
88
|
+
attr_accessor :target
|
89
|
+
|
90
|
+
def allow image
|
91
|
+
# no duplicates
|
92
|
+
if (@images.map { |i| i.id}).include? image.id
|
93
|
+
raise OperationalError, "Tried to add an image with duplicate ID"
|
94
|
+
end
|
95
|
+
|
96
|
+
# App image sets don't need to worry about specific fields, but we keep it clean and symmetric.
|
97
|
+
raise IncompatibilityError, "Tried to add a DesignImage object to a non- DesignImageSet" if image.is_a? DesignImage
|
98
|
+
end
|
99
|
+
|
100
|
+
def cache_image_lookups
|
101
|
+
return if @cache_valid
|
102
|
+
|
103
|
+
@image_lookup = {}
|
104
|
+
@images.each do |img|
|
105
|
+
@image_lookup[img.id.to_s] = img
|
106
|
+
end
|
107
|
+
|
108
|
+
@cache_valid = true
|
109
|
+
end
|
110
|
+
|
111
|
+
def best_image_for(id)
|
112
|
+
self.cache_image_lookups
|
113
|
+
@image_lookup.fetch(id.to_s, nil)
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'markaby'
|
2
|
+
require_relative 'filesystem'
|
3
|
+
|
4
|
+
module Vivisector
|
5
|
+
|
6
|
+
class Report
|
7
|
+
attr_accessor :title
|
8
|
+
attr_accessor :attribute
|
9
|
+
attr_accessor :anatomy
|
10
|
+
attr_accessor :appographies
|
11
|
+
attr_accessor :web_document_root
|
12
|
+
attr_accessor :destination_path
|
13
|
+
attr_accessor :img_width_px
|
14
|
+
|
15
|
+
def css
|
16
|
+
width_string = ""
|
17
|
+
width_string = "width: #{@img_width_px}px;" unless img_width_px.nil?
|
18
|
+
%(
|
19
|
+
body {color:white; background-color:#333;}
|
20
|
+
table.comparison th {border-top: 1px solid #ccc;}
|
21
|
+
table.comparison td {padding-bottom:1ex; text-align:center;}
|
22
|
+
.masterimg, .appimg {background-color:white; #{width_string}}
|
23
|
+
.missing {white-space: pre; text-align:center;}
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
# if necessary, modify the path to be relative (for web-based reports)
|
28
|
+
def path_transform element_path
|
29
|
+
unless @web_document_root.nil?
|
30
|
+
element_path = web_relative_path(@web_document_root, @destination_path, element_path)
|
31
|
+
end
|
32
|
+
element_path
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_s
|
36
|
+
# initial calculations
|
37
|
+
candidates = @appographies.select do |appog|
|
38
|
+
next true if @attribute.nil? # unless there is nothing in this candidate???? might be expensive to check.
|
39
|
+
|
40
|
+
appog.image_set.target.attribute == @attribute
|
41
|
+
end
|
42
|
+
design_images = @anatomy.image_set.images_for_attribute(@attribute)
|
43
|
+
columns = candidates.length + 1
|
44
|
+
|
45
|
+
me = self
|
46
|
+
|
47
|
+
# build html
|
48
|
+
mab = Markaby::Builder.new
|
49
|
+
mab.html do
|
50
|
+
|
51
|
+
head do
|
52
|
+
title "#{me.title} | Vivisector"
|
53
|
+
style :type => "text/css" do
|
54
|
+
me.css
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
body do
|
59
|
+
h1 me.title
|
60
|
+
if design_images.empty?
|
61
|
+
p "No input images were found."
|
62
|
+
else
|
63
|
+
table.comparison do
|
64
|
+
# print out the first row of the table -- the target names
|
65
|
+
tr do
|
66
|
+
td "Design"
|
67
|
+
candidates.each do |c|
|
68
|
+
td c.image_set.target.name
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# print out all the compared images
|
73
|
+
design_images.each do |design_image|
|
74
|
+
# title
|
75
|
+
tr do
|
76
|
+
th :colspan => columns do
|
77
|
+
a :name => design_image.description do
|
78
|
+
design_image.description
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# images
|
84
|
+
tr do
|
85
|
+
td :align => "right", :valign => "top" do
|
86
|
+
img.masterimg :src => me.path_transform(design_image.path), :alt => design_image.description
|
87
|
+
end
|
88
|
+
|
89
|
+
candidates.each do |candidate|
|
90
|
+
app_image = candidate.image_set.best_image_for(design_image.id)
|
91
|
+
if app_image.nil?
|
92
|
+
td { div.missing "#{design_image.description} on #{candidate.image_set.target.name}" }
|
93
|
+
else
|
94
|
+
td :align => "left", :valign => "top" do
|
95
|
+
div.holder { img.appimg :src => me.path_transform(app_image.path), :alt => app_image.description }
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
return mab.to_s
|
106
|
+
end
|
107
|
+
|
108
|
+
end #Report
|
109
|
+
|
110
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require_relative 'filesystem'
|
2
|
+
|
3
|
+
module Vivisector
|
4
|
+
|
5
|
+
class ReportSet
|
6
|
+
attr_accessor :anatomy
|
7
|
+
attr_accessor :appographies
|
8
|
+
attr_accessor :title_for_attribute_fn
|
9
|
+
attr_accessor :path_for_attribute_fn
|
10
|
+
attr_accessor :web_document_root
|
11
|
+
attr_accessor :img_width_px
|
12
|
+
|
13
|
+
# Check whether the path is correct, particularly if we are making a web-based report
|
14
|
+
def allow_path path
|
15
|
+
unless @web_document_root.nil?
|
16
|
+
unless ancestor?(@web_document_root, path)
|
17
|
+
raise OperationalError, "Report #{path} is not an ancestor of the web root #{@web_document_root}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def write
|
23
|
+
@anatomy.image_set.contained_attributes.map do |attr|
|
24
|
+
|
25
|
+
# first check whether the destination is ok
|
26
|
+
path = @path_for_attribute_fn.call(attr)
|
27
|
+
self.allow_path path
|
28
|
+
|
29
|
+
r = Report.new
|
30
|
+
r.title = @title_for_attribute_fn.call(attr)
|
31
|
+
r.attribute = attr
|
32
|
+
r.anatomy = @anatomy
|
33
|
+
r.appographies = @appographies
|
34
|
+
r.web_document_root = @web_document_root
|
35
|
+
r.destination_path = path
|
36
|
+
r.img_width_px = @img_width_px
|
37
|
+
|
38
|
+
File.open(path, 'w') {|f| f.write(r.to_s) }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
end #Vivisector
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'vivisector/version'
|
2
|
+
|
3
|
+
require 'vivisector/exceptions'
|
4
|
+
require 'vivisector/target'
|
5
|
+
require 'vivisector/image'
|
6
|
+
require 'vivisector/imageset'
|
7
|
+
require 'vivisector/imageloader'
|
8
|
+
require 'vivisector/report'
|
9
|
+
require 'vivisector/reportset'
|
10
|
+
|
11
|
+
|
12
|
+
module Vivisector
|
13
|
+
|
14
|
+
def self.report(anatomy, appographies, title_for_attribute_fn, path_for_attribute_fn, web_document_root, img_width_px)
|
15
|
+
# load source images
|
16
|
+
anatomy.load
|
17
|
+
appographies.each { |appog| appog.load }
|
18
|
+
|
19
|
+
unless web_document_root.nil?
|
20
|
+
appographies.each do |appog|
|
21
|
+
# if the path isn't within web_document_root, throw
|
22
|
+
end
|
23
|
+
|
24
|
+
# copy all images to an images/ directory inside web_document_root
|
25
|
+
# mangle all image sets to point to there
|
26
|
+
# use dirnames like "images/design", "images/target1", "images/target2", etc
|
27
|
+
end
|
28
|
+
|
29
|
+
rs = Vivisector::ReportSet.new
|
30
|
+
rs.anatomy = anatomy
|
31
|
+
rs.appographies = appographies
|
32
|
+
rs.title_for_attribute_fn = title_for_attribute_fn
|
33
|
+
rs.path_for_attribute_fn = path_for_attribute_fn
|
34
|
+
rs.web_document_root = web_document_root
|
35
|
+
rs.img_width_px = img_width_px
|
36
|
+
|
37
|
+
rs.write
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: vivisector
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ian Katz
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-05-28 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
- - '>='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 1.3.6
|
23
|
+
type: :development
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.3'
|
30
|
+
- - '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 1.3.6
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: rake
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ~>
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '10.0'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ~>
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '10.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: markaby
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ~>
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0.8'
|
54
|
+
- - '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: 0.8.0
|
57
|
+
type: :runtime
|
58
|
+
prerelease: false
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ~>
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0.8'
|
64
|
+
- - '>='
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: 0.8.0
|
67
|
+
description: Vivisector helps you see inside your apps, specifically so that designers
|
68
|
+
can be part of a QA / Continuous Integration process. It's a framework to help you
|
69
|
+
compare design 'master' images to actual screenshots from various implementations.
|
70
|
+
email:
|
71
|
+
- ifreecarve@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- gem/lib/vivisector.rb
|
77
|
+
- gem/lib/vivisector/exceptions.rb
|
78
|
+
- gem/lib/vivisector/filesystem.rb
|
79
|
+
- gem/lib/vivisector/image.rb
|
80
|
+
- gem/lib/vivisector/imageloader.rb
|
81
|
+
- gem/lib/vivisector/imageset.rb
|
82
|
+
- gem/lib/vivisector/report.rb
|
83
|
+
- gem/lib/vivisector/reportset.rb
|
84
|
+
- gem/lib/vivisector/target.rb
|
85
|
+
- gem/lib/vivisector/version.rb
|
86
|
+
homepage: http://github.com/ifreecarve/vivisector
|
87
|
+
licenses:
|
88
|
+
- Apache 2.0
|
89
|
+
metadata: {}
|
90
|
+
post_install_message:
|
91
|
+
rdoc_options: []
|
92
|
+
require_paths:
|
93
|
+
- gem/lib
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - '>='
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '>='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
requirements: []
|
105
|
+
rubyforge_project:
|
106
|
+
rubygems_version: 2.4.6
|
107
|
+
signing_key:
|
108
|
+
specification_version: 4
|
109
|
+
summary: Design QA tool
|
110
|
+
test_files: []
|