trmnl_preview 0.27.0 → 0.29.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/CHANGELOG.md +17 -0
- data/db/data/framework_classes.yml +78 -2
- data/db/data/framework_prefixes.yml +252 -43
- data/lib/trmnlp/browser_pool.rb +0 -2
- data/lib/trmnlp/firefox_driver.rb +8 -0
- data/lib/trmnlp/lint/checks/arbitrary_values_in_range.rb +17 -3
- data/lib/trmnlp/lint/checks/framework_prefixes_exist.rb +14 -4
- data/lib/trmnlp/lint/checks/gray_text_has_one_bit_class.rb +39 -0
- data/lib/trmnlp/lint/checks/no_refresh_rate_field.rb +47 -0
- data/lib/trmnlp/lint/checks/no_style_restyling_framework.rb +62 -0
- data/lib/trmnlp/lint/checks/select_options_split_label_and_value.rb +38 -0
- data/lib/trmnlp/lint/diagnostic.rb +30 -1
- data/lib/trmnlp/lint/source.rb +8 -7
- data/lib/trmnlp/lint.rb +8 -0
- data/lib/trmnlp/testing/ink_count.rb +43 -0
- data/lib/trmnlp/testing/overlapping_text.js +96 -0
- data/lib/trmnlp/testing/publishable_recipe.rb +20 -2
- data/lib/trmnlp/testing/rspec.rb +44 -0
- data/lib/trmnlp/testing/screen.rb +31 -0
- data/lib/trmnlp/version.rb +1 -1
- metadata +7 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../check'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Lint
|
|
7
|
+
module Checks
|
|
8
|
+
# Small gray text dithers into dots on a 1-bit screen, so it needs a 1bit: class
|
|
9
|
+
# (1bit:text--black, or 1bit:text--white on a black panel) on the same element.
|
|
10
|
+
class GrayTextHasOneBitClass < Check
|
|
11
|
+
SMALL = /\A(?:label|text|description)--x?small\z/
|
|
12
|
+
GRAY = /\A(?:text--gray(?:-\d+)?|label--gray)\z/
|
|
13
|
+
LEARN_MORE = 'https://trmnl.com/framework/docs/3.4/text_color'
|
|
14
|
+
|
|
15
|
+
def issues
|
|
16
|
+
offending_elements.map { |_, element| self.class.class_list(element) }.uniq.map do |classes|
|
|
17
|
+
{ message: "'#{classes}' is small gray text with no 1bit: class, so it breaks up " \
|
|
18
|
+
'into dots on 1-bit screens. Add 1bit:text--black (1bit:text--white on a black background).',
|
|
19
|
+
learn_more: LEARN_MORE }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def offending_elements
|
|
24
|
+
@offending_elements ||= source.html_fragments.flat_map do |path, fragment|
|
|
25
|
+
fragment.css('[class]').select { small_gray_without_one_bit?(it['class'].split) }.map { [path, it] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.class_list(element) = element['class'].split.join(' ')
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def small_gray_without_one_bit?(classes)
|
|
34
|
+
classes.any?(SMALL) && classes.any?(GRAY) && classes.none? { it.start_with?('1bit:') }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../check'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Lint
|
|
7
|
+
module Checks
|
|
8
|
+
# Reports a custom field that sets how often the content changes. The refresh rate and the
|
|
9
|
+
# playlist already decide that, so the field repeats them. A field must both be named for
|
|
10
|
+
# it (rotation, refresh, new …) and offer intervals, so data such as "collected every 2 weeks" passes.
|
|
11
|
+
class NoRefreshRateField < Check
|
|
12
|
+
NAME = /rotat|refresh|frequen|interval|how often|\bnew\b|\bnext\b/i
|
|
13
|
+
INTERVAL_OPTION = /\A(?:daily|hourly|(?:once|twice|\d+\stimes)\s(?:a|per)\s(?:day|hour)|
|
|
14
|
+
every\s(?:\d+\s)?(?:minute|hour|day)s?|\d+\s*(?:minutes?|mins?|hours?|hrs?)\b)/ix
|
|
15
|
+
TIME_UNIT = /\b(?:minutes?|hours?)\b/i
|
|
16
|
+
LEARN_MORE = 'https://help.trmnl.com/en/articles/11395668-recipe-best-practices'
|
|
17
|
+
|
|
18
|
+
def issues
|
|
19
|
+
offending_fields.map do |field|
|
|
20
|
+
{ message: "Form field '#{field['keyname']}' sets how often the content changes. Remove it and let " \
|
|
21
|
+
'the refresh rate and the playlist decide.', learn_more: LEARN_MORE }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def offending_fields = source.custom_field_definitions.select { refresh_rate_field?(it) }
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def refresh_rate_field?(field)
|
|
30
|
+
return false unless "#{field['keyname'].to_s.tr('_', ' ')} #{field['name']}".match?(NAME)
|
|
31
|
+
|
|
32
|
+
case field['field_type']
|
|
33
|
+
when 'select' then interval_options?(field['options'])
|
|
34
|
+
when 'number', 'string' then [field['keyname'], field['name'],
|
|
35
|
+
field['description']].join(' ').match?(TIME_UNIT)
|
|
36
|
+
else false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def interval_options?(options)
|
|
41
|
+
labels = Array(options).map { it.is_a?(Hash) ? it.keys.first.to_s : it.to_s }
|
|
42
|
+
labels.count { it.match?(INTERVAL_OPTION) } >= 2
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../check'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Lint
|
|
7
|
+
module Checks
|
|
8
|
+
# Reports <style> rules that redo what the framework does across screens: fixed font
|
|
9
|
+
# sizes (no lg: scaling), hand-written line clamping and orientation reflows. CSS for
|
|
10
|
+
# art, charts and SVG text is left alone.
|
|
11
|
+
class NoStyleRestylingFramework < Check
|
|
12
|
+
STYLE_BLOCK = %r{<style\b[^>]*>(.*?)</style>}mi
|
|
13
|
+
RULE = /([^{}]*)\{([^{}]*)\}/
|
|
14
|
+
FIXED_FONT_SIZE = /font-size\s*:\s*[\d.]+\s*(?:px|pt)\b/i
|
|
15
|
+
SVG_OR_CHART = /(?:\A|[\s>+~,])(?:svg|text|tspan)\b|highcharts|chart/i
|
|
16
|
+
KINDS = {
|
|
17
|
+
font_size: ['A <style> block sets font-size in px or pt, so the text keeps one size on every screen. ' \
|
|
18
|
+
'Use the element sizes (label, value, description, title) with lg: steps.',
|
|
19
|
+
'https://trmnl.com/framework/docs/3.4/element_sizes'],
|
|
20
|
+
line_clamp: ['A <style> block clamps lines with -webkit-line-clamp. Use data-clamp="N" on the element.',
|
|
21
|
+
'https://trmnl.com/framework/docs/3.4/clamp'],
|
|
22
|
+
orientation: ['A <style> block reflows with @media (orientation …). ' \
|
|
23
|
+
'Use the portrait: and lg:portrait: prefixes.',
|
|
24
|
+
'https://trmnl.com/framework/docs/3.4/responsive']
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def issues
|
|
28
|
+
occurrences.keys.map { |kind| { message: KINDS[kind][0], learn_more: KINDS[kind][1] } }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# kind => [[path, offset], ...] for each place in a <style> block.
|
|
32
|
+
def occurrences
|
|
33
|
+
@occurrences ||= places.group_by(&:first).transform_values { it.map { |_, path, offset| [path, offset] } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def places
|
|
39
|
+
source.markup_files.flat_map do |path, markup|
|
|
40
|
+
markup.to_enum(:scan, STYLE_BLOCK).flat_map do
|
|
41
|
+
start = Regexp.last_match.begin(1)
|
|
42
|
+
style_block_offsets(Regexp.last_match(1)).map { |kind, offset| [kind, path, start + offset] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def style_block_offsets(css)
|
|
48
|
+
offsets = css.to_enum(:scan, RULE).filter_map do
|
|
49
|
+
selector, declarations = Regexp.last_match.captures
|
|
50
|
+
next if selector.match?(SVG_OR_CHART) || !declarations.match?(FIXED_FONT_SIZE)
|
|
51
|
+
|
|
52
|
+
[:font_size, Regexp.last_match.begin(2) + declarations.index(FIXED_FONT_SIZE)]
|
|
53
|
+
end
|
|
54
|
+
offsets + pattern_offsets(css, /-webkit-line-clamp/i, :line_clamp) +
|
|
55
|
+
pattern_offsets(css, /@media[^{]*orientation/i, :orientation)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def pattern_offsets(css, pattern, kind) = css.to_enum(:scan, pattern).map { [kind, Regexp.last_match.begin(0)] }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../check'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Lint
|
|
7
|
+
module Checks
|
|
8
|
+
# Reports a select option written as one quoted string, 'Voyager 1: voyager1'. TRMNL reads
|
|
9
|
+
# a string option as a label and saves it lowercased with spaces as underscores, so the
|
|
10
|
+
# value the markup sees is 'voyager_1:_voyager1'. Unquoted, YAML makes it a label and a value.
|
|
11
|
+
class SelectOptionsSplitLabelAndValue < Check
|
|
12
|
+
LABEL_AND_VALUE = /\A[^:]+:\s+\S/
|
|
13
|
+
LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder'
|
|
14
|
+
|
|
15
|
+
def issues
|
|
16
|
+
split_options.map do |field_index, option_index|
|
|
17
|
+
field = source.custom_field_definitions[field_index]
|
|
18
|
+
option = field['options'][option_index]
|
|
19
|
+
{ message: "Form field '#{field['keyname']}' has the option '#{option}' as one string, so TRMNL saves " \
|
|
20
|
+
"'#{option.downcase.tr(' ', '_')}'. Write it as 'Label': value to save the value.",
|
|
21
|
+
learn_more: LEARN_MORE }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# [field index, option index] of the first such option in each select field.
|
|
26
|
+
def split_options
|
|
27
|
+
source.custom_field_definitions.each_with_index.filter_map do |field, field_index|
|
|
28
|
+
options = field['options']
|
|
29
|
+
next unless field['field_type'] == 'select' && options.is_a?(Array)
|
|
30
|
+
|
|
31
|
+
option_index = options.index { |option| option.is_a?(String) && option.match?(LABEL_AND_VALUE) }
|
|
32
|
+
[field_index, option_index] if option_index
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -37,6 +37,7 @@ module TRMNLP
|
|
|
37
37
|
|
|
38
38
|
def rule_id = Lint.rule_id(check.class)
|
|
39
39
|
|
|
40
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity -- one branch per rule that locates its own findings
|
|
40
41
|
def locations
|
|
41
42
|
return source.yaml_location('src/settings.yml', SETTINGS_KEYS[rule_id]) if SETTINGS_KEYS.key?(rule_id)
|
|
42
43
|
return source.locations(MARKUP_PATTERNS[rule_id]) if MARKUP_PATTERNS.key?(rule_id)
|
|
@@ -47,17 +48,22 @@ module TRMNLP
|
|
|
47
48
|
when 'form_fields_valid' then form_field_locations
|
|
48
49
|
when 'custom_field_links_embedded' then custom_field_description_locations
|
|
49
50
|
when 'custom_fields_used' then project_field_locations
|
|
51
|
+
when 'no_refresh_rate_field' then settings_field_locations
|
|
50
52
|
when 'no_custom_filters', 'no_unknown_filters' then filter_locations
|
|
51
53
|
when 'title_bar_outside_layout', 'no_nested_layouts'
|
|
52
54
|
check.misplaced_elements.map { |path, element| source.element_location(path, element) }
|
|
55
|
+
when 'gray_text_has_one_bit_class' then gray_text_locations
|
|
53
56
|
when 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
|
|
57
|
+
when 'select_options_split_label_and_value' then split_option_locations
|
|
58
|
+
when 'no_style_restyling_framework' then style_block_locations
|
|
54
59
|
else []
|
|
55
60
|
end
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def class_locations
|
|
59
64
|
css_class = finding[:message][/\A'([^']+)'/, 1]
|
|
60
|
-
|
|
65
|
+
# Up to the end of the class, so `lg:title` does not also find `lg:title--small`.
|
|
66
|
+
css_class ? source.locations(Regexp.new("#{Regexp.escape(css_class)}(?![\\w-])")) : []
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
def filter_locations
|
|
@@ -74,6 +80,29 @@ module TRMNLP
|
|
|
74
80
|
end
|
|
75
81
|
end
|
|
76
82
|
|
|
83
|
+
def split_option_locations
|
|
84
|
+
check.split_options.flat_map do |field, option|
|
|
85
|
+
source.yaml_location('src/settings.yml', 'custom_fields', field, 'options', option)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def gray_text_locations
|
|
90
|
+
classes = finding[:message][/\A'([^']+)'/, 1]
|
|
91
|
+
check.offending_elements.select { |_, element| Checks::GrayTextHasOneBitClass.class_list(element) == classes }
|
|
92
|
+
.map { |path, element| source.element_location(path, element) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def style_block_locations
|
|
96
|
+
kind = Checks::NoStyleRestylingFramework::KINDS.find { |_, (message, _)| message == finding[:message] }&.first
|
|
97
|
+
check.occurrences.fetch(kind, []).map { |path, offset| source.offset_location(path, offset) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def settings_field_locations
|
|
101
|
+
keyname = finding[:message][/Form field '([^']+)'/, 1]
|
|
102
|
+
index = source.custom_field_definitions.index { it['keyname'].to_s == keyname }
|
|
103
|
+
index ? source.yaml_location('src/settings.yml', 'custom_fields', index) : []
|
|
104
|
+
end
|
|
105
|
+
|
|
77
106
|
def form_field_locations
|
|
78
107
|
warning = finding[:message].split(' — ', 2).last
|
|
79
108
|
source.custom_field_definitions.each_with_index.flat_map do |field, index|
|
data/lib/trmnlp/lint/source.rb
CHANGED
|
@@ -87,16 +87,17 @@ module TRMNLP
|
|
|
87
87
|
|
|
88
88
|
def locations(pattern, files: markup_files)
|
|
89
89
|
files.flat_map do |path, contents|
|
|
90
|
-
contents.to_enum(:scan, pattern).map
|
|
91
|
-
offset = Regexp.last_match.begin(0)
|
|
92
|
-
prefix = contents[0...offset]
|
|
93
|
-
line = prefix.count("\n") + 1
|
|
94
|
-
{ path:, line:, column: offset - (prefix.rindex("\n") || -1),
|
|
95
|
-
snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
|
|
96
|
-
end
|
|
90
|
+
contents.to_enum(:scan, pattern).map { offset_location(path, Regexp.last_match.begin(0), contents:) }
|
|
97
91
|
end.uniq
|
|
98
92
|
end
|
|
99
93
|
|
|
94
|
+
def offset_location(path, offset, contents: markup_files.fetch(path))
|
|
95
|
+
prefix = contents[0...offset]
|
|
96
|
+
line = prefix.count("\n") + 1
|
|
97
|
+
{ path:, line:, column: offset - (prefix.rindex("\n") || -1),
|
|
98
|
+
snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
|
|
99
|
+
end
|
|
100
|
+
|
|
100
101
|
def yaml_location(file, *keys)
|
|
101
102
|
path = file == '.trmnlp.yml' ? paths.trmnlp_config : paths.plugin_config
|
|
102
103
|
contents = raw_read(path)
|
data/lib/trmnlp/lint.rb
CHANGED
|
@@ -10,15 +10,19 @@ require_relative 'lint/checks/layouts_have_content'
|
|
|
10
10
|
require_relative 'lint/checks/no_async_functions'
|
|
11
11
|
require_relative 'lint/checks/waits_for_dom_load'
|
|
12
12
|
require_relative 'lint/checks/limited_inline_styles'
|
|
13
|
+
require_relative 'lint/checks/no_style_restyling_framework'
|
|
13
14
|
require_relative 'lint/checks/no_size_classes'
|
|
14
15
|
require_relative 'lint/checks/arbitrary_values_in_range'
|
|
15
16
|
require_relative 'lint/checks/no_opacity'
|
|
17
|
+
require_relative 'lint/checks/gray_text_has_one_bit_class'
|
|
16
18
|
require_relative 'lint/checks/highcharts_animations_disabled'
|
|
17
19
|
require_relative 'lint/checks/highcharts_elements_unique'
|
|
18
20
|
require_relative 'lint/checks/image_links_reachable'
|
|
19
21
|
require_relative 'lint/checks/custom_fields_used'
|
|
20
22
|
require_relative 'lint/checks/form_fields_valid'
|
|
21
23
|
require_relative 'lint/checks/yes_no_selects_are_boolean'
|
|
24
|
+
require_relative 'lint/checks/select_options_split_label_and_value'
|
|
25
|
+
require_relative 'lint/checks/no_refresh_rate_field'
|
|
22
26
|
require_relative 'lint/checks/coordinates_use_lat_lon'
|
|
23
27
|
require_relative 'lint/checks/no_custom_filters'
|
|
24
28
|
require_relative 'lint/checks/no_unknown_filters'
|
|
@@ -45,15 +49,19 @@ module TRMNLP
|
|
|
45
49
|
Checks::NoAsyncFunctions,
|
|
46
50
|
Checks::WaitsForDomLoad,
|
|
47
51
|
Checks::LimitedInlineStyles,
|
|
52
|
+
Checks::NoStyleRestylingFramework,
|
|
48
53
|
Checks::NoSizeClasses,
|
|
49
54
|
Checks::ArbitraryValuesInRange,
|
|
50
55
|
Checks::NoOpacity,
|
|
56
|
+
Checks::GrayTextHasOneBitClass,
|
|
51
57
|
Checks::HighchartsAnimationsDisabled,
|
|
52
58
|
Checks::HighchartsElementsUnique,
|
|
53
59
|
Checks::ImageLinksReachable,
|
|
54
60
|
Checks::CustomFieldsUsed,
|
|
55
61
|
Checks::FormFieldsValid,
|
|
56
62
|
Checks::YesNoSelectsAreBoolean,
|
|
63
|
+
Checks::SelectOptionsSplitLabelAndValue,
|
|
64
|
+
Checks::NoRefreshRateField,
|
|
57
65
|
Checks::CoordinatesUseLatLon,
|
|
58
66
|
Checks::NoCustomFilters,
|
|
59
67
|
Checks::NoUnknownFilters,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mini_magick'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Testing
|
|
7
|
+
# How many pixels of a PNG stand out from its background, the gray level most pixels have; area: a box
|
|
8
|
+
# counts only inside it.
|
|
9
|
+
class InkCount
|
|
10
|
+
# Half the gray scale: text and lines pass; a faint tint or frame does not, on white or on black.
|
|
11
|
+
MIN_DIFFERENCE = 128
|
|
12
|
+
|
|
13
|
+
def initialize(path, area: nil)
|
|
14
|
+
@path = path
|
|
15
|
+
@area = area
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pixels
|
|
19
|
+
levels = histogram
|
|
20
|
+
return 0 if levels.empty?
|
|
21
|
+
|
|
22
|
+
background = levels.max_by(&:last).first
|
|
23
|
+
levels.sum { |level, count| (level - background).abs > MIN_DIFFERENCE ? count : 0 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# [gray level 0-255, pixel count] for each level the picture uses.
|
|
29
|
+
def histogram
|
|
30
|
+
output = MiniMagick.convert do |convert|
|
|
31
|
+
convert << @path
|
|
32
|
+
convert << '-crop' << crop_geometry << '+repage' if @area
|
|
33
|
+
convert << '-colorspace' << 'gray' << '-depth' << '8' << '-format' << '%c' << 'histogram:info:-'
|
|
34
|
+
end
|
|
35
|
+
output.lines.filter_map do |line|
|
|
36
|
+
(match = line.match(/\A\s*(\d+):\s*\(\s*(\d+)/)) && [match[2].to_i, match[1].to_i]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def crop_geometry = "#{@area.width.round}x#{@area.height.round}+#{@area.left.round}+#{@area.top.round}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const shown = (el) => el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
|
|
2
|
+
const intersect = (a, b) => ({ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top),
|
|
3
|
+
right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) });
|
|
4
|
+
const area = (r) => Math.max(0, r.right - r.left) * Math.max(0, r.bottom - r.top);
|
|
5
|
+
const covers = (a, b, share) => area(intersect(a, b)) > Math.min(area(a), area(b)) * share;
|
|
6
|
+
const visiblePart = (rect, el) => {
|
|
7
|
+
let part = rect;
|
|
8
|
+
for (let parent = el; parent; parent = parent.parentElement) {
|
|
9
|
+
if (getComputedStyle(parent).overflow !== 'visible') part = intersect(part, parent.getBoundingClientRect());
|
|
10
|
+
}
|
|
11
|
+
return part;
|
|
12
|
+
};
|
|
13
|
+
const textMeasuringContext = document.createElement('canvas').getContext('2d');
|
|
14
|
+
// A text range's box is as tall as its line, so the font's own box sits in the middle of it.
|
|
15
|
+
const baselineOf = (rect, metrics) => rect.top + metrics.fontBoundingBoxAscent +
|
|
16
|
+
(rect.height - metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
|
|
17
|
+
const inkOf = (rect, text, style, oneLine) => {
|
|
18
|
+
textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
19
|
+
const metrics = textMeasuringContext.measureText(text);
|
|
20
|
+
const baseline = baselineOf(rect, metrics);
|
|
21
|
+
const left = oneLine ? rect.left - metrics.actualBoundingBoxLeft : rect.left;
|
|
22
|
+
const right = oneLine ? rect.left + metrics.actualBoundingBoxRight : rect.right;
|
|
23
|
+
return { left, right, top: baseline - metrics.actualBoundingBoxAscent,
|
|
24
|
+
bottom: baseline + metrics.actualBoundingBoxDescent };
|
|
25
|
+
};
|
|
26
|
+
const texts = [];
|
|
27
|
+
const range = document.createRange();
|
|
28
|
+
const walker = document.createTreeWalker(document.querySelector('.view') || document.body, NodeFilter.SHOW_TEXT);
|
|
29
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
30
|
+
const owner = node.parentElement;
|
|
31
|
+
if (!node.data.trim() || owner instanceof SVGElement || owner.closest('script, style, svg') || !shown(owner)) continue;
|
|
32
|
+
range.selectNodeContents(node);
|
|
33
|
+
const style = getComputedStyle(owner);
|
|
34
|
+
const lines = [...range.getClientRects()];
|
|
35
|
+
lines.map((rect) => visiblePart(inkOf(rect, node.data.trim(), style, lines.length === 1), owner))
|
|
36
|
+
.filter((rect) => area(rect) > 4)
|
|
37
|
+
.forEach((rect) => texts.push({ node, owner, rect, text: node.data.trim().slice(0, 40) }));
|
|
38
|
+
}
|
|
39
|
+
const SHARED_INK_PIXELS = 4;
|
|
40
|
+
const glyphCache = new Map();
|
|
41
|
+
const glyphsOf = (node) => {
|
|
42
|
+
if (glyphCache.has(node)) return glyphCache.get(node);
|
|
43
|
+
const style = getComputedStyle(node.parentElement);
|
|
44
|
+
const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
45
|
+
const glyphs = [];
|
|
46
|
+
for (let start = 0; start < node.data.length;) {
|
|
47
|
+
const end = start + String.fromCodePoint(node.data.codePointAt(start)).length;
|
|
48
|
+
const glyph = node.data.slice(start, end);
|
|
49
|
+
if (glyph.trim()) {
|
|
50
|
+
range.setStart(node, start);
|
|
51
|
+
range.setEnd(node, end);
|
|
52
|
+
[...range.getClientRects()].forEach((rect) => {
|
|
53
|
+
textMeasuringContext.font = font;
|
|
54
|
+
const metrics = textMeasuringContext.measureText(glyph);
|
|
55
|
+
const box = visiblePart(inkOf(rect, glyph, style, false), node.parentElement);
|
|
56
|
+
if (area(box) > 1) glyphs.push({ glyph, font, box, x: rect.left, baseline: baselineOf(rect, metrics) });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
start = end;
|
|
60
|
+
}
|
|
61
|
+
glyphCache.set(node, glyphs);
|
|
62
|
+
return glyphs;
|
|
63
|
+
};
|
|
64
|
+
// Draws each glyph where the page put it and counts the pixels both inked, so a glyph tucked into the
|
|
65
|
+
// empty corner of another glyph's box does not count.
|
|
66
|
+
const inkPixels = (glyph, region) => {
|
|
67
|
+
const canvas = document.createElement('canvas');
|
|
68
|
+
canvas.width = Math.ceil(region.right - region.left);
|
|
69
|
+
canvas.height = Math.ceil(region.bottom - region.top);
|
|
70
|
+
const context = canvas.getContext('2d');
|
|
71
|
+
context.font = glyph.font;
|
|
72
|
+
context.fillText(glyph.glyph, glyph.x - region.left, glyph.baseline - region.top);
|
|
73
|
+
const alpha = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
74
|
+
const inked = new Set();
|
|
75
|
+
for (let i = 3; i < alpha.length; i += 4) if (alpha[i] > 127) inked.add(i);
|
|
76
|
+
return inked;
|
|
77
|
+
};
|
|
78
|
+
const inkOverlaps = (one, other) => {
|
|
79
|
+
const shared = intersect(one.box, other.box);
|
|
80
|
+
if (area(shared) < 1) return false;
|
|
81
|
+
const otherInk = inkPixels(other, shared);
|
|
82
|
+
return [...inkPixels(one, shared)].filter((pixel) => otherInk.has(pixel)).length >= SHARED_INK_PIXELS;
|
|
83
|
+
};
|
|
84
|
+
const inkTouches = (a, b) => glyphsOf(a.node).some((glyph) => glyphsOf(b.node).some((other) => inkOverlaps(glyph, other)));
|
|
85
|
+
const related = (a, b) => a === b || a.contains(b) || b.contains(a);
|
|
86
|
+
const found = [];
|
|
87
|
+
texts.forEach((a, i) => texts.slice(i + 1).forEach((b) => {
|
|
88
|
+
if (!related(a.owner, b.owner) && covers(a.rect, b.rect, 0) && inkTouches(a, b)) found.push(`"${a.text}" and "${b.text}"`);
|
|
89
|
+
}));
|
|
90
|
+
const titleBar = document.querySelector('.view .title_bar');
|
|
91
|
+
if (titleBar) {
|
|
92
|
+
const bar = titleBar.getBoundingClientRect();
|
|
93
|
+
texts.filter((t) => !titleBar.contains(t.owner) && covers(t.rect, bar, 1 / 3))
|
|
94
|
+
.forEach((t) => found.push(`"${t.text}" and the title bar`));
|
|
95
|
+
}
|
|
96
|
+
return [...new Set(found)];
|
|
@@ -18,6 +18,9 @@ module TRMNLP
|
|
|
18
18
|
}.freeze
|
|
19
19
|
# A field with more options than this is drawn with its first and last.
|
|
20
20
|
PUBLISHABLE_RECIPE_MAX_OPTIONS = 20
|
|
21
|
+
# Share of a layout's ink its dark mode render must keep. Of 17 recipes measured on 10-13, those that draw
|
|
22
|
+
# well in dark mode kept 85% or more; those whose text turned black on black kept 0% to 15%.
|
|
23
|
+
DARK_MODE_MIN_INK_KEPT = 0.5
|
|
21
24
|
|
|
22
25
|
# Each select field's keyname and the values its options save, from the plugin's settings.yml.
|
|
23
26
|
def self.select_field_values
|
|
@@ -32,14 +35,18 @@ module TRMNLP
|
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
# rubocop:disable-next Metrics/BlockLength -- one shared group, one check per example
|
|
35
|
-
RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS
|
|
38
|
+
RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS,
|
|
39
|
+
dark_mode_screen: { device: 'v2' }|
|
|
36
40
|
let(:recipe_inputs) do
|
|
37
41
|
{ mocks: respond_to?(:mocks) ? mocks : {}, custom_fields: respond_to?(:custom_fields) ? custom_fields : {},
|
|
38
42
|
variables: respond_to?(:variables) ? variables : {}, now: respond_to?(:now) ? now : nil,
|
|
39
43
|
data: respond_to?(:data) ? data : nil }
|
|
40
44
|
end
|
|
41
45
|
|
|
42
|
-
def draw_cleanly
|
|
46
|
+
def draw_cleanly
|
|
47
|
+
have_no_problems.and(have_no_leaked_text)
|
|
48
|
+
.and(have_no_text_clamped_with_room_to_spare).and(have_no_overlapping_text)
|
|
49
|
+
end
|
|
43
50
|
|
|
44
51
|
TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.product(screens).each do |view, screen|
|
|
45
52
|
device = screen[:device].is_a?(Hash) ? screen[:device][:model] : screen[:device]
|
|
@@ -51,6 +58,13 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
|
|
|
51
58
|
end
|
|
52
59
|
end
|
|
53
60
|
|
|
61
|
+
TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.each do |view|
|
|
62
|
+
it "keeps the #{view} view's content in dark mode" do
|
|
63
|
+
light = trmnl.render(view:, **dark_mode_screen, **recipe_inputs)
|
|
64
|
+
expect(trmnl.render(view:, **dark_mode_screen, **recipe_inputs, dark_mode: true)).to keep_the_content_of(light)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
TRMNLP::Testing::PUBLISHABLE_RECIPE_API_FAILURES.each do |failure, answer|
|
|
55
69
|
it "draws the full view when the API #{failure}" do
|
|
56
70
|
screen = trmnl.render(**screens.first, **recipe_inputs, mocks: { '*' => answer })
|
|
@@ -67,6 +81,10 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
|
|
|
67
81
|
end
|
|
68
82
|
end
|
|
69
83
|
|
|
84
|
+
it 'has a mock for every request it makes' do
|
|
85
|
+
expect(trmnl.transform(device: screens.first[:device], **recipe_inputs)).to have_a_mock_for_every_request
|
|
86
|
+
end
|
|
87
|
+
|
|
70
88
|
it 'runs its transform without error' do
|
|
71
89
|
expect(trmnl.transform(device: screens.first[:device], **recipe_inputs).error).to be_nil
|
|
72
90
|
end
|
data/lib/trmnlp/testing/rspec.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative '../browser_pool'
|
|
|
10
10
|
require_relative '../firefox_driver'
|
|
11
11
|
require_relative 'browser'
|
|
12
12
|
require_relative 'certificate_authority'
|
|
13
|
+
require_relative 'ink_count'
|
|
13
14
|
require_relative 'page_server'
|
|
14
15
|
require_relative 'plugin'
|
|
15
16
|
require_relative 'report'
|
|
@@ -75,6 +76,49 @@ RSpec::Matchers.define :have_no_transform_error do
|
|
|
75
76
|
failure_message { |screen| "expected the transform to run without error, but: #{screen.result.error}" }
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
# A request no mock answers gets status 599, which draws the plugin's error screen as if its API failed.
|
|
80
|
+
RSpec::Matchers.define :have_a_mock_for_every_request do
|
|
81
|
+
def unmocked(run) = run.requests.reject { it[:mocked] }.map { "#{it[:method]} #{it[:url].split('?', 2).first}" }.uniq
|
|
82
|
+
|
|
83
|
+
match { |run| unmocked(run).empty? }
|
|
84
|
+
failure_message do |run|
|
|
85
|
+
"expected a mock for every request, but none answers: #{unmocked(run).join(', ')}. " \
|
|
86
|
+
'Add them to tests/mocks.json; without one the screen shows the error state.'
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Dark mode recolors the Framework's text and lines; anything drawn in a fixed black stays black on the black screen.
|
|
91
|
+
RSpec::Matchers.define :keep_the_content_of do |light|
|
|
92
|
+
match do |dark|
|
|
93
|
+
@light, @dark = [light, dark].map { TRMNLP::Testing::InkCount.new(it.png_path, area: content_box(it)).pixels }
|
|
94
|
+
@light.zero? || @dark >= @light * TRMNLP::Testing::DARK_MODE_MIN_INK_KEPT
|
|
95
|
+
end
|
|
96
|
+
# The layout only: a mashup slot's frame is too faint to count on white but stands out on black.
|
|
97
|
+
def content_box(screen) = screen.has_css?('.layout', wait: false) ? screen.box('.layout') : nil
|
|
98
|
+
|
|
99
|
+
failure_message do |dark|
|
|
100
|
+
"expected #{dark.inspect} in dark mode to keep its content, but it keeps #{(100 * @dark / @light).round}% of " \
|
|
101
|
+
"it (#{@dark} of #{@light} pixels that stand out): text, lines or icons drawn in a fixed black (#000, black) " \
|
|
102
|
+
'stay black on the black screen. Use currentColor or Framework classes.'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
RSpec::Matchers.define :have_no_text_clamped_with_room_to_spare do
|
|
107
|
+
match { |screen| screen.clamped_with_room.empty? }
|
|
108
|
+
failure_message do |screen|
|
|
109
|
+
"expected no text cut short with room to spare on #{screen.inspect}, but it cuts: " \
|
|
110
|
+
"#{screen.clamped_with_room.join('; ')}."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
RSpec::Matchers.define :have_no_overlapping_text do
|
|
115
|
+
match { |screen| screen.overlapping_text.empty? }
|
|
116
|
+
failure_message do |screen|
|
|
117
|
+
"expected no text drawn over other text on #{screen.inspect}, but it draws #{screen.overlapping_text.join('; ')} " \
|
|
118
|
+
'over each other.'
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
78
122
|
RSpec::Matchers.define :have_no_problems do
|
|
79
123
|
match { |screen| screen.problems.empty? }
|
|
80
124
|
failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
|
|
@@ -87,6 +87,34 @@ module TRMNLP
|
|
|
87
87
|
}).map(describe);
|
|
88
88
|
JS
|
|
89
89
|
|
|
90
|
+
# Each element the Framework clamped although its full text fits on one line of the width its parent gives it.
|
|
91
|
+
CLAMPED_WITH_ROOM_SCRIPT = <<~JS
|
|
92
|
+
const textMeasuringContext = document.createElement('canvas').getContext('2d');
|
|
93
|
+
const px = (value) => parseFloat(value) || 0;
|
|
94
|
+
const sharesItsRow = (style) => style.display.includes('grid') ||
|
|
95
|
+
(style.display.includes('flex') && !style.flexDirection.startsWith('column'));
|
|
96
|
+
const fullTextWidth = (el, text) => {
|
|
97
|
+
const style = getComputedStyle(el);
|
|
98
|
+
textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
99
|
+
const shown = style.textTransform === 'uppercase' ? text.toUpperCase() : text;
|
|
100
|
+
return textMeasuringContext.measureText(shown).width + px(style.letterSpacing) * shown.length +
|
|
101
|
+
px(style.paddingLeft) + px(style.paddingRight) + px(style.marginLeft) + px(style.marginRight);
|
|
102
|
+
};
|
|
103
|
+
return [...document.querySelectorAll('.view [data-clamp-original]')].filter((el) => {
|
|
104
|
+
const original = el.getAttribute('data-clamp-original').trim();
|
|
105
|
+
const parent = el.parentElement;
|
|
106
|
+
if (!original || el.innerText.trim() === original || !el.getClientRects().length || !parent) return false;
|
|
107
|
+
const parentStyle = getComputedStyle(parent);
|
|
108
|
+
if (sharesItsRow(parentStyle)) return false;
|
|
109
|
+
const room = parent.clientWidth - px(parentStyle.paddingLeft) - px(parentStyle.paddingRight);
|
|
110
|
+
return fullTextWidth(el, original) <= room - 2;
|
|
111
|
+
}).map((el) => `"${el.innerText.trim()}" (all of "${el.getAttribute('data-clamp-original').trim()}" fits)`);
|
|
112
|
+
JS
|
|
113
|
+
|
|
114
|
+
# Pairs of text whose ink is drawn over each other, and text drawn under the title bar. Each line's ink is cut
|
|
115
|
+
# to what its clipping parents show; two count when their glyphs share inked pixels.
|
|
116
|
+
OVERLAPPING_TEXT_SCRIPT = File.read(File.join(__dir__, 'overlapping_text.js')).freeze
|
|
117
|
+
|
|
90
118
|
HIDDEN_MARK = 'data-trmnlp-hidden'
|
|
91
119
|
# The DOM with every element Firefox does not draw marked, so Capybara's visible text is what is on screen.
|
|
92
120
|
DRAWN_DOM_SCRIPT = <<~JS.freeze
|
|
@@ -126,6 +154,9 @@ module TRMNLP
|
|
|
126
154
|
|
|
127
155
|
def overflowing(except: nil) = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT, except) }
|
|
128
156
|
|
|
157
|
+
def clamped_with_room = @browser.on(self) { it.execute_script(CLAMPED_WITH_ROOM_SCRIPT) }
|
|
158
|
+
def overlapping_text = @browser.on(self) { it.execute_script(OVERLAPPING_TEXT_SCRIPT) }
|
|
159
|
+
|
|
129
160
|
# Script errors, unhandled rejections, console.error lines and files that failed to load.
|
|
130
161
|
def problems = evaluate('window.__trmnlpProblems || []')
|
|
131
162
|
|
data/lib/trmnlp/version.rb
CHANGED