scarpe-components 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +22 -0
- data/Gemfile.lock +86 -0
- data/lib/scarpe/components/base64.rb +25 -0
- data/lib/scarpe/components/calzini/alert.rb +49 -0
- data/lib/scarpe/components/calzini/art_widgets.rb +203 -0
- data/lib/scarpe/components/calzini/button.rb +39 -0
- data/lib/scarpe/components/calzini/misc.rb +146 -0
- data/lib/scarpe/components/calzini/para.rb +35 -0
- data/lib/scarpe/components/calzini/slots.rb +155 -0
- data/lib/scarpe/components/calzini/text_widgets.rb +65 -0
- data/lib/scarpe/components/calzini.rb +149 -0
- data/lib/scarpe/components/errors.rb +20 -0
- data/lib/scarpe/components/file_helpers.rb +66 -0
- data/lib/scarpe/components/html.rb +131 -0
- data/lib/scarpe/components/minitest_export_reporter.rb +75 -0
- data/lib/scarpe/components/minitest_import_runnable.rb +98 -0
- data/lib/scarpe/components/minitest_result.rb +86 -0
- data/lib/scarpe/{logger.rb → components/modular_logger.rb} +11 -6
- data/lib/scarpe/components/print_logger.rb +47 -0
- data/lib/scarpe/components/promises.rb +454 -0
- data/lib/scarpe/components/segmented_file_loader.rb +189 -0
- data/lib/scarpe/components/string_helpers.rb +10 -0
- data/lib/scarpe/components/tiranti.rb +225 -0
- data/lib/scarpe/components/unit_test_helpers.rb +257 -0
- data/lib/scarpe/components/version.rb +7 -0
- metadata +28 -4
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Scarpe::Components::Calzini
|
4
|
+
def slot_element(props, &block)
|
5
|
+
HTML.render do |h|
|
6
|
+
h.div((props["html_attributes"] || {}).merge(id: html_id, style: slot_style(props)), &block)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def flow_element(props, &block)
|
11
|
+
HTML.render do |h|
|
12
|
+
h.div((props["html_attributes"] || {}).merge(id: html_id, style: flow_style(props)), &block)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def stack_element(props, &block)
|
17
|
+
HTML.render do |h|
|
18
|
+
h.div((props["html_attributes"] || {}).merge(id: html_id, style: stack_style(props)), &block)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def documentroot_element(props, &block)
|
23
|
+
HTML.render do |h|
|
24
|
+
# DocumentRoot rendering intentionally uses flow styles.
|
25
|
+
h.div((props["html_attributes"] || {}).merge(id: html_id, style: flow_style(props)), &block)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def slot_style(props)
|
32
|
+
styles = drawable_style(props)
|
33
|
+
styles = background_style(props, styles)
|
34
|
+
styles = border_style(props, styles)
|
35
|
+
styles = spacing_styles_for_attr("margin", props, styles)
|
36
|
+
styles = spacing_styles_for_attr("padding", props, styles)
|
37
|
+
|
38
|
+
styles[:width] = dimensions_length(props["width"]) if props["width"]
|
39
|
+
styles[:height] = dimensions_length(props["height"]) if props["height"]
|
40
|
+
|
41
|
+
styles
|
42
|
+
end
|
43
|
+
|
44
|
+
def flow_style(props)
|
45
|
+
{
|
46
|
+
display: "flex",
|
47
|
+
"flex-direction": "row",
|
48
|
+
"flex-wrap": "wrap",
|
49
|
+
"align-content": "flex-start",
|
50
|
+
"justify-content": "flex-start",
|
51
|
+
"align-items": "flex-start",
|
52
|
+
}.merge(slot_style(props))
|
53
|
+
end
|
54
|
+
|
55
|
+
def stack_style(props)
|
56
|
+
{
|
57
|
+
display: "flex",
|
58
|
+
"flex-direction": "column",
|
59
|
+
"align-content": "flex-start",
|
60
|
+
"justify-content": "flex-start",
|
61
|
+
"align-items": "flex-start",
|
62
|
+
overflow: props["scroll"] ? "auto" : nil,
|
63
|
+
}.compact.merge(slot_style(props))
|
64
|
+
end
|
65
|
+
|
66
|
+
def border_style(props, styles)
|
67
|
+
bc = props["border_color"]
|
68
|
+
return styles unless bc
|
69
|
+
|
70
|
+
opts = props["options"] || {}
|
71
|
+
|
72
|
+
border_style_hash = case bc
|
73
|
+
when Range
|
74
|
+
{ "border-image": "linear-gradient(45deg, #{bc.first}, #{bc.last})" }
|
75
|
+
when Array
|
76
|
+
{ "border-color": "rgba(#{bc.join(", ")})" }
|
77
|
+
else
|
78
|
+
{ "border-color": bc }
|
79
|
+
end
|
80
|
+
styles.merge(
|
81
|
+
"border-style": "solid",
|
82
|
+
"border-width": "#{opts["strokewidth"] || 1}px",
|
83
|
+
"border-radius": "#{opts["curve"] || 0}px",
|
84
|
+
).merge(border_style_hash)
|
85
|
+
end
|
86
|
+
|
87
|
+
def background_style(props, styles)
|
88
|
+
bc = props["background_color"]
|
89
|
+
return styles unless bc
|
90
|
+
|
91
|
+
color = case bc
|
92
|
+
when Array
|
93
|
+
"rgba(#{bc.join(", ")})"
|
94
|
+
when Range
|
95
|
+
"linear-gradient(45deg, #{bc.first}, #{bc.last})"
|
96
|
+
when ->(value) { File.exist?(value) }
|
97
|
+
"url(data:image/png;base64,#{encode_file_to_base64(bc)})"
|
98
|
+
else
|
99
|
+
bc
|
100
|
+
end
|
101
|
+
|
102
|
+
styles.merge(background: color)
|
103
|
+
end
|
104
|
+
|
105
|
+
SPACING_DIRECTIONS = [:left, :right, :top, :bottom]
|
106
|
+
|
107
|
+
# We extract the appropriate margin and padding from the margin and
|
108
|
+
# padding properties. If there are no margin or padding properties,
|
109
|
+
# we fall back to props["options"] margin or padding, if it exists.
|
110
|
+
#
|
111
|
+
# Margin or padding (in either props or props["options"]) can be
|
112
|
+
# a Hash with directions as keys, or an Array of left/right/top/bottom,
|
113
|
+
# or a constant, which means all four are that constant. You can
|
114
|
+
# also specify a "margin" plus "margin-top" which is constant but
|
115
|
+
# margin-top is overridden, or similar.
|
116
|
+
#
|
117
|
+
# If any margin or padding property exists in props then we don't
|
118
|
+
# check props["options"].
|
119
|
+
def spacing_styles_for_attr(attr, props, styles, with_options: true)
|
120
|
+
spacing_styles = {}
|
121
|
+
|
122
|
+
case props[attr]
|
123
|
+
when Hash
|
124
|
+
props[attr].each do |dir, value|
|
125
|
+
spacing_styles[:"#{attr}-#{dir}"] = dimensions_length value
|
126
|
+
end
|
127
|
+
when Array
|
128
|
+
SPACING_DIRECTIONS.zip(props[attr]).to_h.compact.each do |dir, value|
|
129
|
+
spacing_styles[:"#{attr}-#{dir}"] = dimensions_length(value)
|
130
|
+
end
|
131
|
+
when String, Numeric
|
132
|
+
spacing_styles[attr.to_sym] = dimensions_length(props[attr])
|
133
|
+
end
|
134
|
+
|
135
|
+
SPACING_DIRECTIONS.each do |dir|
|
136
|
+
if props["#{attr}_#{dir}"]
|
137
|
+
spacing_styles[:"#{attr}-#{dir}"] = dimensions_length props["#{attr}_#{dir}"]
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
unless spacing_styles.empty?
|
142
|
+
return styles.merge(spacing_styles)
|
143
|
+
end
|
144
|
+
|
145
|
+
# We should see if there are spacing properties in props["options"],
|
146
|
+
# unless we're currently doing that.
|
147
|
+
if with_options && props["options"]
|
148
|
+
spacing_styles = spacing_styles_for_attr(attr, props["options"], {}, with_options: false)
|
149
|
+
styles.merge spacing_styles
|
150
|
+
else
|
151
|
+
# No "options" or we already checked it? Return the styles we were given.
|
152
|
+
styles
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Scarpe::Components::Calzini
|
4
|
+
def link_element(props)
|
5
|
+
HTML.render do |h|
|
6
|
+
h.a(**link_attributes(props)) do
|
7
|
+
props["text"]
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def span_element(props, &block)
|
13
|
+
HTML.render do |h|
|
14
|
+
h.span(**span_options(props), &block)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def code_element(props, &block)
|
19
|
+
HTML.render do |h|
|
20
|
+
h.code(&block)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def em_element(props, &block)
|
25
|
+
HTML.render do |h|
|
26
|
+
h.em(&block)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def strong_element(props, &block)
|
31
|
+
HTML.render do |h|
|
32
|
+
h.strong(&block)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def link_attributes(props)
|
39
|
+
{
|
40
|
+
id: html_id,
|
41
|
+
href: props["click"],
|
42
|
+
onclick: (handler_js_code("click") if props["has_block"]),
|
43
|
+
style: drawable_style(props),
|
44
|
+
}.compact
|
45
|
+
end
|
46
|
+
|
47
|
+
def span_style(props)
|
48
|
+
{
|
49
|
+
color: props["stroke"],
|
50
|
+
"font-size": span_font_size(props),
|
51
|
+
"font-family": props["font"],
|
52
|
+
}.compact
|
53
|
+
end
|
54
|
+
|
55
|
+
def span_options(props)
|
56
|
+
(props["html_attributes"] || {}).merge(id: html_id, style: span_style(props))
|
57
|
+
end
|
58
|
+
|
59
|
+
def span_font_size(props)
|
60
|
+
sz = props["size"]
|
61
|
+
font_size = SIZES.key?(sz.to_s.to_sym) ? SIZES[sz.to_s.to_sym] : sz
|
62
|
+
|
63
|
+
dimensions_length(font_size)
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "html"
|
4
|
+
require_relative "base64"
|
5
|
+
require_relative "errors"
|
6
|
+
|
7
|
+
# Require all drawable rendering code under calzini directory
|
8
|
+
Dir.glob("calzini/*.rb", base: __dir__) do |drawable|
|
9
|
+
require_relative drawable
|
10
|
+
end
|
11
|
+
|
12
|
+
# The Calzini module expects to be included by a class defining
|
13
|
+
# the following methods:
|
14
|
+
#
|
15
|
+
# * html_id - the HTML ID for the specific rendered DOM object
|
16
|
+
# * handler_js_code(event_name) - the JS handler code for this DOM object and event name
|
17
|
+
# * (optional) shoes_styles - the Shoes styles for this object, unless overridden in render()
|
18
|
+
module Scarpe::Components::Calzini
|
19
|
+
extend self
|
20
|
+
|
21
|
+
HTML = Scarpe::Components::HTML
|
22
|
+
include Scarpe::Components::Base64
|
23
|
+
|
24
|
+
SIZES = {
|
25
|
+
inscription: 10,
|
26
|
+
ins: 10,
|
27
|
+
para: 12,
|
28
|
+
caption: 14,
|
29
|
+
tagline: 18,
|
30
|
+
subtitle: 26,
|
31
|
+
title: 34,
|
32
|
+
banner: 48,
|
33
|
+
}.freeze
|
34
|
+
private_constant :SIZES
|
35
|
+
|
36
|
+
# Render the Shoes drawable of type `drawable_name` with
|
37
|
+
# the given properties to HTML and return it. If the
|
38
|
+
# drawable type takes a block (e.g. Stack or Flow) then
|
39
|
+
# the block will be properly rendered.
|
40
|
+
#
|
41
|
+
# @param drawable_name [String] the drawable name like "alert", "button" or "rect"
|
42
|
+
# @param properties [Hash] a drawable-specific hash of property names to values
|
43
|
+
# @block the block which, when called, will return the contents for drawable types with contents
|
44
|
+
# @return [String] the rendered HTML
|
45
|
+
def render(drawable_name, properties = shoes_styles, &block)
|
46
|
+
send("#{drawable_name}_element", properties, &block)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return HTML for an empty page element, to be filled with HTML
|
50
|
+
# renderings of the DOM tree.
|
51
|
+
#
|
52
|
+
# The wrapper-wvroot element is where Scarpe will fill in the
|
53
|
+
# DOM element.
|
54
|
+
#
|
55
|
+
# @return [String] the rendered HTML for the empty page object.
|
56
|
+
def empty_page_element
|
57
|
+
<<~HTML
|
58
|
+
<html>
|
59
|
+
<head id='head-wvroot'>
|
60
|
+
<style id='style-wvroot'>
|
61
|
+
/** Style resets **/
|
62
|
+
body {
|
63
|
+
font-family: arial, Helvetica, sans-serif;
|
64
|
+
margin: 0;
|
65
|
+
height: 100%;
|
66
|
+
overflow: hidden;
|
67
|
+
}
|
68
|
+
p {
|
69
|
+
margin: 0;
|
70
|
+
}
|
71
|
+
</style>
|
72
|
+
</head>
|
73
|
+
<body id='body-wvroot'>
|
74
|
+
<div id='wrapper-wvroot'></div>
|
75
|
+
</body>
|
76
|
+
</html>
|
77
|
+
HTML
|
78
|
+
end
|
79
|
+
|
80
|
+
def text_size(sz)
|
81
|
+
case sz
|
82
|
+
when Numeric
|
83
|
+
sz
|
84
|
+
when Symbol
|
85
|
+
SIZES[sz]
|
86
|
+
when String
|
87
|
+
SIZES[sz.to_sym] || sz.to_i
|
88
|
+
else
|
89
|
+
raise "Unexpected text size object: #{sz.inspect}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def dimensions_length(value)
|
94
|
+
case value
|
95
|
+
when Integer
|
96
|
+
if value < 0
|
97
|
+
"calc(100% - #{value.abs}px)"
|
98
|
+
else
|
99
|
+
"#{value}px"
|
100
|
+
end
|
101
|
+
when Float
|
102
|
+
"#{value * 100}%"
|
103
|
+
else
|
104
|
+
value
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def drawable_style(props)
|
109
|
+
styles = {}
|
110
|
+
if props["hidden"]
|
111
|
+
styles[:display] = "none"
|
112
|
+
end
|
113
|
+
styles
|
114
|
+
end
|
115
|
+
|
116
|
+
# Convert an [r, g, b, a] array to an HTML hex color code
|
117
|
+
# Arrays support alpha. HTML hex does not. So premultiply.
|
118
|
+
def rgb_to_hex(color)
|
119
|
+
return color if color.nil?
|
120
|
+
|
121
|
+
r, g, b, a = *color
|
122
|
+
if r.is_a?(Float)
|
123
|
+
a ||= 1.0
|
124
|
+
r_float = r * a
|
125
|
+
g_float = g * a
|
126
|
+
b_float = b * a
|
127
|
+
else
|
128
|
+
a ||= 255
|
129
|
+
a_float = (a / 255.0)
|
130
|
+
r_float = (r.to_f / 255.0) * a_float
|
131
|
+
g_float = (g.to_f / 255.0) * a_float
|
132
|
+
b_float = (b.to_f / 255.0) * a_float
|
133
|
+
end
|
134
|
+
|
135
|
+
r_int = (r_float * 255.0).to_i.clamp(0, 255)
|
136
|
+
g_int = (g_float * 255.0).to_i.clamp(0, 255)
|
137
|
+
b_int = (b_float * 255.0).to_i.clamp(0, 255)
|
138
|
+
|
139
|
+
"#%0.2X%0.2X%0.2X" % [r_int, g_int, b_int]
|
140
|
+
end
|
141
|
+
|
142
|
+
def degrees_to_radians(degrees)
|
143
|
+
degrees * Math::PI / 180
|
144
|
+
end
|
145
|
+
|
146
|
+
def radians_to_degrees(radians)
|
147
|
+
radians * (180.0 / Math::PI)
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Also defined in scarpe_core
|
4
|
+
class Scarpe::Error < StandardError; end
|
5
|
+
|
6
|
+
module Scarpe
|
7
|
+
class InternalError < Scarpe::Error; end
|
8
|
+
|
9
|
+
class FileContentError < Scarpe::Error; end
|
10
|
+
|
11
|
+
class NoOperationError < Scarpe::Error; end
|
12
|
+
|
13
|
+
class DuplicateFileError < Scarpe::Error; end
|
14
|
+
|
15
|
+
class NoSuchFile < Scarpe::Error; end
|
16
|
+
|
17
|
+
class MustOverrideMethod < Scarpe::Error; end
|
18
|
+
|
19
|
+
class InvalidHTMLTag < Scarpe::Error; end
|
20
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tempfile"
|
4
|
+
|
5
|
+
# These can be used for unit tests, but also more generally.
|
6
|
+
|
7
|
+
module Scarpe; module Components; end; end
|
8
|
+
module Scarpe::Components::FileHelpers
|
9
|
+
# Create a temporary file with the given prefix and contents.
|
10
|
+
# Execute the block of code with it in place. Make sure
|
11
|
+
# it gets cleaned up afterward.
|
12
|
+
#
|
13
|
+
# @param prefix [String] the prefix passed to Tempfile to identify this file on disk
|
14
|
+
# @param contents [String] the file contents that should be written to Tempfile
|
15
|
+
# @param dir [String] the directory to create the tempfile in
|
16
|
+
# @yield The code to execute with the tempfile present
|
17
|
+
# @yieldparam the path of the new tempfile
|
18
|
+
def with_tempfile(prefix, contents, dir: Dir.tmpdir)
|
19
|
+
t = Tempfile.new(prefix, dir)
|
20
|
+
t.write(contents)
|
21
|
+
t.flush # Make sure the contents are written out
|
22
|
+
|
23
|
+
yield(t.path)
|
24
|
+
ensure
|
25
|
+
t.close
|
26
|
+
t.unlink
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create multiple tempfiles, with given contents, in given
|
30
|
+
# directories, and execute the block in that context.
|
31
|
+
# When the block is finished, make sure all tempfiles are
|
32
|
+
# deleted.
|
33
|
+
#
|
34
|
+
# Pass an array of arrays, where each array is of the form:
|
35
|
+
# [prefix, contents, (optional)dir]
|
36
|
+
#
|
37
|
+
# I don't love inlining with_tempfile's contents into here.
|
38
|
+
# But calling it iteratively or recursively was difficult
|
39
|
+
# when I tried it the obvious ways.
|
40
|
+
#
|
41
|
+
# This method should be equivalent to calling with_tempfile
|
42
|
+
# once for each entry in the array, in a set of nested
|
43
|
+
# blocks.
|
44
|
+
#
|
45
|
+
# @param tf_specs [Array<Array>] The array of tempfile prefixes, contents and directories
|
46
|
+
# @yield The code to execute with those tempfiles present
|
47
|
+
# @yieldparam An array of paths to tempfiles, in the same order as tf_specs
|
48
|
+
def with_tempfiles(tf_specs, &block)
|
49
|
+
tempfiles = []
|
50
|
+
tf_specs.each do |prefix, contents, dir|
|
51
|
+
dir ||= Dir.tmpdir
|
52
|
+
t = Tempfile.new(prefix, dir)
|
53
|
+
tempfiles << t
|
54
|
+
t.write(contents)
|
55
|
+
t.flush # Make sure the contents are written out
|
56
|
+
end
|
57
|
+
|
58
|
+
args = tempfiles.map(&:path)
|
59
|
+
yield(args)
|
60
|
+
ensure
|
61
|
+
tempfiles.each do |t|
|
62
|
+
t.close
|
63
|
+
t.unlink
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Scarpe::Components::HTML
|
4
|
+
CONTENT_TAGS = [
|
5
|
+
:div,
|
6
|
+
:p,
|
7
|
+
:button,
|
8
|
+
:ul,
|
9
|
+
:li,
|
10
|
+
:textarea,
|
11
|
+
:a,
|
12
|
+
:video,
|
13
|
+
:strong,
|
14
|
+
:style,
|
15
|
+
:progress,
|
16
|
+
:em,
|
17
|
+
:code,
|
18
|
+
:defs,
|
19
|
+
:marker,
|
20
|
+
:u,
|
21
|
+
:line,
|
22
|
+
:span,
|
23
|
+
:svg,
|
24
|
+
:h1,
|
25
|
+
:h2,
|
26
|
+
:h3,
|
27
|
+
:h4,
|
28
|
+
:h5,
|
29
|
+
].freeze
|
30
|
+
VOID_TAGS = [:input, :img, :polygon, :source, :link, :path, :rect].freeze
|
31
|
+
|
32
|
+
TAGS = (CONTENT_TAGS + VOID_TAGS).freeze
|
33
|
+
|
34
|
+
class << self
|
35
|
+
def render(&block)
|
36
|
+
new(&block).value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(&block)
|
41
|
+
@buffer = ""
|
42
|
+
block.call(self)
|
43
|
+
end
|
44
|
+
|
45
|
+
def value
|
46
|
+
@buffer
|
47
|
+
end
|
48
|
+
|
49
|
+
def respond_to_missing?(name, include_all = false)
|
50
|
+
TAGS.include?(name) || super(name, include_all)
|
51
|
+
end
|
52
|
+
|
53
|
+
def p(*args, &block)
|
54
|
+
method_missing(:p, *args, &block)
|
55
|
+
end
|
56
|
+
|
57
|
+
def option(**attrs, &block)
|
58
|
+
tag(:option, **attrs, &block)
|
59
|
+
end
|
60
|
+
|
61
|
+
def tag(name, **attrs, &block)
|
62
|
+
if VOID_TAGS.include?(name)
|
63
|
+
raise Shoes::Errors::InvalidAttributeValueError, "void tag #{name} cannot have content" if block_given?
|
64
|
+
|
65
|
+
@buffer += "<#{name}#{render_attributes(attrs)} />"
|
66
|
+
else
|
67
|
+
@buffer += "<#{name}#{render_attributes(attrs)}>"
|
68
|
+
|
69
|
+
if block_given?
|
70
|
+
result = block.call(self)
|
71
|
+
else
|
72
|
+
result = attrs[:content]
|
73
|
+
@buffer += result if result.is_a?(String)
|
74
|
+
end
|
75
|
+
@buffer += result if result.is_a?(String)
|
76
|
+
|
77
|
+
@buffer += "</#{name}>"
|
78
|
+
end
|
79
|
+
|
80
|
+
nil
|
81
|
+
end
|
82
|
+
|
83
|
+
def select(**attrs, &block)
|
84
|
+
tag(:select, **attrs, &block)
|
85
|
+
end
|
86
|
+
|
87
|
+
def method_missing(name, *args, &block)
|
88
|
+
raise Scarpe::InvalidHTMLTag, "no method #{name} for #{self.class.name}" unless TAGS.include?(name)
|
89
|
+
|
90
|
+
if VOID_TAGS.include?(name)
|
91
|
+
raise Shoes::Errors::InvalidAttributeValueError, "void tag #{name} cannot have content" if block_given?
|
92
|
+
|
93
|
+
@buffer += "<#{name}#{render_attributes(*args)} />"
|
94
|
+
else
|
95
|
+
@buffer += "<#{name}#{render_attributes(*args)}>"
|
96
|
+
|
97
|
+
if block_given?
|
98
|
+
result = block.call(self)
|
99
|
+
else
|
100
|
+
result = args.first
|
101
|
+
@buffer += result if result.is_a?(String)
|
102
|
+
end
|
103
|
+
@buffer += result if result.is_a?(String)
|
104
|
+
|
105
|
+
@buffer += "</#{name}>"
|
106
|
+
end
|
107
|
+
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def render_attributes(attributes = {})
|
114
|
+
return "" if attributes.empty?
|
115
|
+
|
116
|
+
attributes[:style] = render_style(attributes[:style]) if attributes[:style]
|
117
|
+
attributes.compact!
|
118
|
+
|
119
|
+
return if attributes.empty?
|
120
|
+
|
121
|
+
result = attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
|
122
|
+
" #{result}"
|
123
|
+
end
|
124
|
+
|
125
|
+
def render_style(style)
|
126
|
+
return style unless style.is_a?(Hash)
|
127
|
+
return if style.empty?
|
128
|
+
|
129
|
+
style.map { |k, v| "#{k}:#{v}" }.join(";")
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Have to require this to get DefaultReporter and the Minitest::Reporters namespace.
|
4
|
+
ENV["MINITEST_REPORTER"] = "ShoesExportReporter"
|
5
|
+
require "minitest/reporters"
|
6
|
+
require "json"
|
7
|
+
require "json/add/exception"
|
8
|
+
|
9
|
+
module Minitest
|
10
|
+
module Reporters
|
11
|
+
# To use this Scarpe component, you'll need minitest-reporters in your Gemfile,
|
12
|
+
# probably in the "test" group. You'll need to require and activate ShoesExportReporter
|
13
|
+
# to register it as Minitest's reporter:
|
14
|
+
#
|
15
|
+
# require "scarpe/components/minitest_export_reporter"
|
16
|
+
# Minitest::Reporters::ShoesExportReporter.activate!
|
17
|
+
#
|
18
|
+
# Select a destination to export JSON test results to:
|
19
|
+
#
|
20
|
+
# export SHOES_MINITEST_EXPORT_FILE=/tmp/shoes_test_export.json
|
21
|
+
#
|
22
|
+
# This class overrides the MINITEST_REPORTER environment variable when you call activate.
|
23
|
+
# If MINITEST_REPORTER isn't set then when you run via Vim, TextMate, RubyMine, etc,
|
24
|
+
# the reporter will be automatically overridden and print to console instead.
|
25
|
+
#
|
26
|
+
# Based on https://gist.github.com/davidwessman/09a13840a8a80080e3842ac3051714c7
|
27
|
+
class ShoesExportReporter < DefaultReporter
|
28
|
+
def self.activate!
|
29
|
+
unless ENV["SHOES_MINITEST_EXPORT_FILE"]
|
30
|
+
raise "ShoesExportReporter is available, but no export file was specified! Set SHOES_MINITEST_EXPORT_FILE!"
|
31
|
+
end
|
32
|
+
|
33
|
+
Minitest::Reporters.use!
|
34
|
+
end
|
35
|
+
|
36
|
+
def serialize_failures(failures)
|
37
|
+
failures.map do |fail|
|
38
|
+
case fail
|
39
|
+
when Minitest::UnexpectedError
|
40
|
+
["unexpected", fail.to_json, fail.error.to_json]
|
41
|
+
when Exception
|
42
|
+
["exception", fail.to_json]
|
43
|
+
else
|
44
|
+
raise "Not sure how to serialize failure object! #{fail.inspect}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def report
|
50
|
+
super
|
51
|
+
|
52
|
+
results = tests.map do |result|
|
53
|
+
failures = serialize_failures result.failures
|
54
|
+
{
|
55
|
+
name: result.name,
|
56
|
+
klass: test_class(result),
|
57
|
+
assertions: result.assertions,
|
58
|
+
failures: failures,
|
59
|
+
time: result.time,
|
60
|
+
metadata: result.respond_to?(:metadata) ? result.metadata : {},
|
61
|
+
source_location: begin
|
62
|
+
result.source_location
|
63
|
+
rescue
|
64
|
+
["unknown", -1]
|
65
|
+
end,
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
out_file = File.expand_path ENV["SHOES_MINITEST_EXPORT_FILE"]
|
70
|
+
puts "Writing Minitest results to #{out_file.inspect}."
|
71
|
+
File.write(out_file, JSON.dump(results))
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|