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.
@@ -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
- css_class ? source.locations(Regexp.new(Regexp.escape(css_class))) : []
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|
@@ -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 do
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 = have_no_problems.and(have_no_leaked_text)
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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.27.0'
4
+ VERSION = '0.29.0'
5
5
  end