view_spec 0.0.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +51 -0
  4. data/app/views/view_spec/_group.html.erb +20 -0
  5. data/app/views/view_spec/_scenario.html.erb +17 -0
  6. data/app/views/view_spec/_spec.html.erb +10 -0
  7. data/lib/view_spec/config.rb +29 -0
  8. data/lib/view_spec/dsl/context.rb +54 -0
  9. data/lib/view_spec/dsl/controller.rb +17 -0
  10. data/lib/view_spec/dsl/groups.rb +16 -0
  11. data/lib/view_spec/dsl/layout.rb +16 -0
  12. data/lib/view_spec/dsl/params.rb +36 -0
  13. data/lib/view_spec/dsl/scenarios.rb +22 -0
  14. data/lib/view_spec/dsl/title.rb +12 -0
  15. data/lib/view_spec/dsl.rb +18 -0
  16. data/lib/view_spec/engine.rb +23 -0
  17. data/lib/view_spec/error.rb +4 -0
  18. data/lib/view_spec/group.rb +24 -0
  19. data/lib/view_spec/helpers/spec_helper.rb +9 -0
  20. data/lib/view_spec/param.rb +34 -0
  21. data/lib/view_spec/param_set.rb +32 -0
  22. data/lib/view_spec/registry.rb +54 -0
  23. data/lib/view_spec/reloader.rb +15 -0
  24. data/lib/view_spec/renderable.rb +36 -0
  25. data/lib/view_spec/scenario.rb +58 -0
  26. data/lib/view_spec/source_file.rb +55 -0
  27. data/lib/view_spec/spec.rb +37 -0
  28. data/lib/view_spec/subject.rb +42 -0
  29. data/lib/view_spec/type.rb +39 -0
  30. data/lib/view_spec/types/boolean.rb +6 -0
  31. data/lib/view_spec/types/date.rb +6 -0
  32. data/lib/view_spec/types/date_time.rb +6 -0
  33. data/lib/view_spec/types/decimal.rb +6 -0
  34. data/lib/view_spec/types/float.rb +6 -0
  35. data/lib/view_spec/types/integer.rb +6 -0
  36. data/lib/view_spec/types/string.rb +6 -0
  37. data/lib/view_spec/types/symbol.rb +9 -0
  38. data/lib/view_spec/types/time.rb +6 -0
  39. data/lib/view_spec/utils.rb +9 -0
  40. data/lib/view_spec/version.rb +3 -0
  41. data/lib/view_spec.rb +38 -0
  42. metadata +125 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db2b2ddc540389235a0c75db6bf93c85e65b854574623ea09b782e24d330d6ad
4
+ data.tar.gz: f7c9400e0487de9c32c2cd0fa345c4c29e6bdceae3143bdb310c0bd2568c0390
5
+ SHA512:
6
+ metadata.gz: e8fe0a339f4f6e8b22c212f5f21d70c66bdedf8e1ffb1c56b03ce236025185a2b28a0cce1510fec59ceeab038550689016d998dce823af51d30b61d83c68f3f1
7
+ data.tar.gz: 6d720f348d61915a323f0217d30af8fa09cb633918a13d4480e65db52c1433dc37b552b5e3abc407eff994e464f5212e90a8d990389d34fb185fe0acea3ab315
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021-present Mark Perkins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # ViewSpec
2
+
3
+ ViewSpec lets you create example-led specifications for components, partials and views using an expressive Ruby DSL.
4
+
5
+ Specs can then be used in your tests, to render previews for development and visual testing or to generate documentation.
6
+
7
+ ```rb
8
+ # button_vspec.rb
9
+
10
+ ViewSpec.spec ButtonComponent do
11
+ title "Button (ViewComponent)"
12
+
13
+ scenario "default" do
14
+ preview do
15
+ render ButtonComponent.new(text: "Button text")
16
+ end
17
+ end
18
+
19
+ scenario "editable params" do
20
+ param :text, :string, default: "Button text"
21
+ param :type, :symbol, default: :button
22
+
23
+ preview do
24
+ render ButtonComponent.new(**params)
25
+ end
26
+ end
27
+
28
+ scenario "buttons galore" do
29
+ preview <<~ERB
30
+ <%= render ButtonComponent.new(text: "Button 1") %>
31
+ <%= render ButtonComponent.new(text: "Button 2") %>
32
+ <%= render ButtonComponent.new(text: "Button 3") %>
33
+ <%= render ButtonComponent.new(text: "Button 4") %>
34
+ ERB
35
+ end
36
+
37
+ group "themes" do
38
+ scenario "primary" do
39
+ preview { render ButtonComponent.new(text: "Primary", theme: :primary) }
40
+ end
41
+
42
+ scenario "secondary" do
43
+ preview { render ButtonComponent.new(text: "Secondary", theme: :secondary) }
44
+ end
45
+
46
+ scenario "danger" do
47
+ preview { render ButtonComponent.new(text: "Danger", theme: :danger) }
48
+ end
49
+ end
50
+ end
51
+ ```
@@ -0,0 +1,20 @@
1
+ <%# locals: (group:) %>
2
+
3
+ <section style="padding: 20px; border: 1px solid #ccc; margin-bottom: 20px;">
4
+ <header>
5
+ <h2><%= group.title %></h2>
6
+ <p>Short identifier: <%= group.short_identifier %></p>
7
+ </header>
8
+ <div style="padding-left: 0px;">
9
+ <h4>Preview:</h4>
10
+ <div style="padding: 20px; border: 2px dotted #bbb;">
11
+ <% group.scenarios.each do |scenario| %>
12
+ <%= scenario.render_preview(params: request.query_parameters) %>
13
+ <% end %>
14
+ </div>
15
+ <h4>Output:</h4>
16
+ <code>
17
+ <pre style="padding: 10px; background-color: #eee; display: block;"><% group.scenarios.each do |scenario| -%><%= scenario.render_output(params: request.query_parameters) + "\n" %><% end %></pre>
18
+ </code>
19
+ </div>
20
+ </div>
@@ -0,0 +1,17 @@
1
+ <%# locals: (scenario:) %>
2
+
3
+ <section style="padding: 20px; border: 1px solid #ccc; margin-bottom: 20px;">
4
+ <header>
5
+ <h2><%= scenario.title %></h2>
6
+ <p>Short identifier: <%= scenario.short_identifier %></p>
7
+ </header>
8
+
9
+ <h4>Preview:</h4>
10
+ <div style="padding: 20px; border: 2px dotted #bbb;">
11
+ <%= scenario.render_preview(params: request.query_parameters) %>
12
+ </div>
13
+ <h4>Output:</h4>
14
+ <code>
15
+ <pre style="padding: 10px; background-color: #eee; display: block;"><%= scenario.render_output(params: request.query_parameters) %></pre>
16
+ </code>
17
+ </section>
@@ -0,0 +1,10 @@
1
+ <%# locals: (spec:) %>
2
+
3
+ <div>
4
+ <h1>Demo: <%= spec.title %></h1>
5
+ <p>Source path: <%= spec.source_file.app_path %></p>
6
+
7
+ <% spec.entries.each do |entry| %>
8
+ <%= render entry %>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,29 @@
1
+ module ViewSpec
2
+ class Config
3
+ include Singleton
4
+
5
+ delegate_missing_to :config
6
+
7
+ def preview_controller
8
+ config.preview_controller.is_a?(String) ? config.preview_controller.constantize : config.preview_controller
9
+ end
10
+
11
+ class << self
12
+ def defaults
13
+ ActiveSupport::OrderedOptions.new.merge!({
14
+ spec_paths: ["#{Rails.root}/view_specs"],
15
+ spec_files: [],
16
+ spec_file_suffix: "_vspec",
17
+ preview_controller: "ApplicationController",
18
+ preview_layout: "application"
19
+ })
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def config
26
+ @config ||= self.class.defaults
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,54 @@
1
+ module ViewSpec
2
+ module DSL
3
+ class Context
4
+ delegate :type, to: :class
5
+
6
+ def initialize(subject, lookup_attrs = {}, &block)
7
+ @subject = Subject.new(subject)
8
+ set_lookup_attrs(lookup_attrs.to_h)
9
+ @block = block
10
+
11
+ evaluate!
12
+ end
13
+
14
+ def entries
15
+ @entries ||= []
16
+ end
17
+
18
+ class << self
19
+ def type
20
+ @type ||= name.demodulize.underscore.to_sym
21
+ end
22
+
23
+ def lookup_attr(name)
24
+ lookup_attrs << name.to_sym
25
+ end
26
+
27
+ def lookup_attrs
28
+ @lookup_attrs ||= []
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def lookup_attrs
35
+ self.class.lookup_attrs.map do |key|
36
+ [key, public_send(key)]
37
+ end.to_h
38
+ end
39
+
40
+ def set_lookup_attrs(values_hash)
41
+ values_hash.each do |key, value|
42
+ public_send(key, value) if respond_to?(key)
43
+ end
44
+ end
45
+
46
+ def evaluate!
47
+ return if @evaluated
48
+
49
+ instance_exec(&@block) if @block
50
+ @evaluated = true
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,17 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Controller
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ lookup_attr :controller
8
+ end
9
+
10
+ def controller(value = nil)
11
+ @controller = value unless value.nil?
12
+ controller = @controller || @outer_scope&.controller || ViewSpec.config.preview_controller
13
+ controller.is_a?(String) ? controller.camelize.constantize : controller
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Groups
4
+ extend ActiveSupport::Concern
5
+
6
+ def groups
7
+ entries.filter { _1.is_a?(Group) }
8
+ end
9
+
10
+ def group(name, &block)
11
+ entries << Group.new(name, lookup_attrs, &block)
12
+ entries.last
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Layout
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ lookup_attr :layout
8
+ end
9
+
10
+ def layout(value = nil)
11
+ @layout = value unless value.nil?
12
+ @layout ||= ViewSpec.config.preview_layout
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Params
4
+ extend ActiveSupport::Concern
5
+
6
+ def param(name, cast_type = nil, **options)
7
+ param_definitions << {name:, cast_type:, options:}
8
+ end
9
+
10
+ def params
11
+ raise ":params cannot be accessed outside of the preview context"
12
+ end
13
+
14
+ def resolve_params(values_hash = {})
15
+ values_hash = values_hash.to_h.with_indifferent_access
16
+ param_set = ParamSet.new
17
+
18
+ param_definitions.each do |props|
19
+ value = values_hash[props[:name]]
20
+ param_set.add(props[:name], props[:cast_type], value:, **props[:options])
21
+ end
22
+
23
+ other_params = values_hash.reject { param_set.include?(_1) }
24
+ param_set.add(other_params)
25
+
26
+ param_set
27
+ end
28
+
29
+ private
30
+
31
+ def param_definitions
32
+ @param_definitions ||= []
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Scenarios
4
+ extend ActiveSupport::Concern
5
+
6
+ def scenarios(include_grouped: false)
7
+ entries.map do |entry|
8
+ if entry.type == :scenario
9
+ entry
10
+ elsif include_grouped && entry.type == :group
11
+ entry.scenarios
12
+ end
13
+ end.compact.flatten
14
+ end
15
+
16
+ def scenario(name, &block)
17
+ entries << Scenario.new(name, lookup_attrs, &block)
18
+ entries.last
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ module ViewSpec
2
+ module DSL
3
+ module Title
4
+ extend ActiveSupport::Concern
5
+
6
+ def title(value = nil)
7
+ @title = value unless value.nil?
8
+ @title ||= @subject&.to_title
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ module ViewSpec
2
+ module DSL
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def spec(subject, &block)
7
+ source_path = caller_locations(1..1).first.absolute_path
8
+ registry.add(source_path, subject, &block)
9
+ end
10
+
11
+ def registry
12
+ @registry ||= Registry.new
13
+ end
14
+
15
+ alias_method :specs, :registry
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ require "rails"
2
+
3
+ module ViewSpec
4
+ class Engine < Rails::Engine
5
+ isolate_namespace ViewSpec
6
+
7
+ config.view_spec = ViewSpec.config
8
+
9
+ config.after_initialize do |app|
10
+ if !app.config.cache_classes
11
+ opts = config.view_spec
12
+ reloader = Reloader.new(opts.spec_files, opts.spec_paths) do
13
+ ViewSpec.load!
14
+ end
15
+
16
+ Rails.application.reloaders << reloader
17
+ reloader.execute
18
+ else
19
+ ViewSpec.load!
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ module ViewSpec
2
+ class Error < StandardError
3
+ end
4
+ end
@@ -0,0 +1,24 @@
1
+ module ViewSpec
2
+ class Group < DSL::Context
3
+ include DSL::Title
4
+ include DSL::Layout
5
+ include DSL::Scenarios
6
+ include DSL::Controller
7
+
8
+ def short_identifier
9
+ @subject.to_short_identifier
10
+ end
11
+
12
+ def spec
13
+ @outer_scope
14
+ end
15
+
16
+ def to_param
17
+ short_identifier
18
+ end
19
+
20
+ def to_partial_path
21
+ "view_spec/group"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ module ViewSpec
2
+ module SpecHelper
3
+ def view_specs
4
+ ViewSpec.specs
5
+ end
6
+
7
+ module_function :view_specs
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ require "active_model"
2
+ module ViewSpec
3
+ class Param
4
+ include ::ActionView::Helpers::SanitizeHelper
5
+
6
+ attr_reader :name, :options
7
+
8
+ def initialize(name, cast_type = nil, value: nil, default: nil, **type_options)
9
+ @name = name.to_sym
10
+ @cast_type = cast_type
11
+ @value = value
12
+ @default = default
13
+ @type_options = type_options
14
+ end
15
+
16
+ def value
17
+ type.cast(raw_value)
18
+ end
19
+
20
+ def type
21
+ @type ||= Type.for(@cast_type, **@type_options)
22
+ end
23
+
24
+ def to_pair
25
+ [name, value]
26
+ end
27
+
28
+ private
29
+
30
+ def raw_value
31
+ sanitize(@value) || @default
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ module ViewSpec
2
+ class ParamSet
3
+ include Enumerable
4
+
5
+ delegate :each, to: :@params
6
+
7
+ def initialize
8
+ @params = []
9
+ end
10
+
11
+ def add(name, cast_type = nil, value: nil, **options)
12
+ if name.is_a?(Hash)
13
+ @params += name.map { Param.new(_1, value: _2) }
14
+ else
15
+ @params << Param.new(name, cast_type, value:, **options)
16
+ end
17
+ @params.uniq!(&:name)
18
+ end
19
+
20
+ def names
21
+ @params.map(&:name)
22
+ end
23
+
24
+ def include?(name)
25
+ names.include?(name)
26
+ end
27
+
28
+ def to_h
29
+ @params.map { _1.to_pair }.to_h
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ module ViewSpec
2
+ class Registry
3
+ include Enumerable
4
+
5
+ delegate :each, to: :@specs
6
+
7
+ def initialize
8
+ @specs = []
9
+ @source_files = {}
10
+ end
11
+
12
+ def add(source_path, subject, &block)
13
+ delete(subject)
14
+
15
+ source_file = source_file(source_path)
16
+ @specs << Spec.new(source_file, subject, &block)
17
+ @specs.last
18
+ end
19
+
20
+ def source_file(path)
21
+ @source_files[path.to_s] ||= SourceFile.new(path)
22
+ end
23
+
24
+ def find(subject)
25
+ find_where({subject:})
26
+ end
27
+
28
+ def has?(...)
29
+ !!find(...)
30
+ end
31
+
32
+ def delete(subject)
33
+ index = @specs.index { _1.subject == subject }
34
+ @specs.delete_at(index) unless index.nil?
35
+ end
36
+
37
+ def clear!
38
+ @specs.clear
39
+ @source_files.clear
40
+ end
41
+
42
+ def find_where(conditions)
43
+ where(conditions).first
44
+ end
45
+
46
+ def where(conditions)
47
+ @specs.filter do |spec|
48
+ !!conditions.each do |key, value|
49
+ break false unless spec.respond_to?(key) && (spec.public_send(key) == value)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ module ViewSpec
2
+ class Reloader
3
+ delegate :execute, to: :@file_watcher
4
+
5
+ def initialize(files = [], paths = [], &callback)
6
+ paths_hash = paths.map { [_1, ["rb"]] }.to_h
7
+
8
+ @file_watcher = ActiveSupport::FileUpdateChecker.new(files, paths_hash, &callback)
9
+ end
10
+
11
+ def updated?
12
+ @file_watcher.execute_if_updated
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ module ViewSpec
2
+ class Renderable
3
+ class CurrentTemplate
4
+ attr_accessor :short_identifier
5
+ end
6
+
7
+ def initialize(identifier, tpl = nil, &block)
8
+ @identifier = identifier
9
+ @block = tpl ? -> { render inline: tpl } : block
10
+ end
11
+
12
+ def render_in(view_context)
13
+ render_context(view_context).instance_exec(&@block)&.strip&.html_safe
14
+ end
15
+
16
+ private
17
+
18
+ def render_context(view_context)
19
+ view_context = view_context.clone
20
+
21
+ unless view_context.instance_variable_get(:@current_template)
22
+ current_template = CurrentTemplate.new
23
+ current_template.short_identifier = @identifier
24
+ view_context.instance_variable_set(:@current_template, current_template)
25
+ end
26
+
27
+ view_context.assigns.each do |key, value|
28
+ view_context.define_singleton_method(key) do
29
+ instance_variable_get(:"@#{key}") || {}
30
+ end
31
+ end
32
+
33
+ view_context
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ module ViewSpec
2
+ class Scenario < DSL::Context
3
+ include DSL::Title
4
+ include DSL::Params
5
+ include DSL::Layout
6
+ include DSL::Controller
7
+
8
+ class NoPreviewError < StandardError; end
9
+
10
+ def preview(tpl = nil, &block)
11
+ @preview = tpl || block
12
+ end
13
+
14
+ def render_preview(params: {}, assigns: {}, layout: false, raw: false)
15
+ assigns[:params] = resolve_params(params).to_h
16
+ html = render(renderable_preview, layout:, assigns:)
17
+
18
+ raw ? CGI.unescapeHTML(html) : html
19
+ end
20
+
21
+ def render_output(*args, **kwargs)
22
+ render_preview(*args, **kwargs, raw: true)
23
+ end
24
+
25
+ def name
26
+ @subject.to_s
27
+ end
28
+
29
+ def short_identifier
30
+ @subject.to_short_identifier
31
+ end
32
+
33
+ def to_param
34
+ short_identifier
35
+ end
36
+
37
+ def to_partial_path
38
+ "view_spec/scenario"
39
+ end
40
+
41
+ private
42
+
43
+ def renderable_preview
44
+ if @preview.is_a?(String)
45
+ Renderable.new(short_identifier, @preview)
46
+ elsif @preview.is_a?(Proc)
47
+ Renderable.new(short_identifier, &@preview)
48
+ else
49
+ raise NoPreviewError, "no preview defined for scenario `#{short_identifier}`"
50
+ end
51
+ end
52
+
53
+ def render(*args, layout: false, assigns: {}, **kwargs, &block)
54
+ layout = self.layout if layout == true
55
+ controller.render(*args, layout:, assigns:, **kwargs, &block)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ module ViewSpec
2
+ class SourceFile
3
+ attr_reader :absolute_path
4
+
5
+ delegate_missing_to :absolute_path
6
+ delegate :to_s, to: :absolute_path
7
+
8
+ def initialize(absolute_path)
9
+ @absolute_path = Pathname(absolute_path)
10
+ end
11
+
12
+ def normalized_name
13
+ basename(".rb").to_s.delete_suffix(ViewSpec.config.spec_file_suffix)
14
+ end
15
+
16
+ def relative_path
17
+ spec_file? ? absolute_path.relative_path_from(base_path) : app_path
18
+ end
19
+
20
+ def lookup_path
21
+ relative_dirname = relative_path.dirname.to_s.delete_prefix(".")
22
+ [relative_dirname.presence, normalized_name].compact.join("/")
23
+ end
24
+
25
+ def app_path
26
+ absolute_path.relative_path_from(Rails.root)
27
+ end
28
+
29
+ def spec_file?
30
+ absolute_path.to_s.end_with?("#{ViewSpec.config.spec_file_suffix}.rb")
31
+ end
32
+
33
+ alias_method :to_path, :to_s
34
+ alias_method :to_pathname, :absolute_path
35
+
36
+ private
37
+
38
+ def base_path
39
+ base_paths.find { descendant_of?(_1) }
40
+ end
41
+
42
+ def base_paths
43
+ Utils.normalize_paths(ViewSpec.config.spec_paths)
44
+ end
45
+
46
+ def descendant_of?(directory_path)
47
+ directory_segments = File.expand_path(directory_path).split("/")
48
+ path_segments.slice(0, directory_segments.size) == directory_segments
49
+ end
50
+
51
+ def path_segments
52
+ absolute_path.to_s.split("/")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ module ViewSpec
2
+ class Spec < DSL::Context
3
+ include DSL::Title
4
+ include DSL::Layout
5
+ include DSL::Groups
6
+ include DSL::Scenarios
7
+ include DSL::Controller
8
+
9
+ attr_reader :subject, :source_file
10
+
11
+ def initialize(source_file, subject, &block)
12
+ @source_file = source_file
13
+
14
+ super(subject, nil, &block)
15
+ end
16
+
17
+ def short_identifier
18
+ @subject.to_short_identifier
19
+ end
20
+
21
+ def lookup_path
22
+ if @source_file.spec_file?
23
+ @source_file.lookup_path
24
+ else
25
+ short_identifier
26
+ end
27
+ end
28
+
29
+ def to_param
30
+ lookup_path
31
+ end
32
+
33
+ def to_partial_path
34
+ "view_spec/spec"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ module ViewSpec
2
+ class Subject
3
+ include Comparable
4
+
5
+ delegate :to_s, to: :value
6
+
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ end
12
+
13
+ def to_title
14
+ @title ||= to_short_identifier.titleize
15
+ end
16
+
17
+ def to_identifier
18
+ @identifier ||= to_parameterized_path
19
+ end
20
+
21
+ def to_short_identifier
22
+ @short_identifier ||= to_identifier.split("/").last
23
+ end
24
+
25
+ def <=>(other)
26
+ if other.is_a?(self.class)
27
+ to_identifier.casecmp other.to_identifier
28
+ else
29
+ value <=> other
30
+ end
31
+ end
32
+
33
+ def to_parameterized_path
34
+ to_path.parameterize
35
+ end
36
+
37
+ def to_path
38
+ path = @value.to_s.underscore.gsub(/\s+/, "_").delete_suffix(".rb")
39
+ path.gsub(/(_component|\/component)$/, "")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ module ViewSpec
2
+ class Type
3
+ TYPES = %i[boolean date date_time decimal float integer string symbol time]
4
+
5
+ delegate :cast, to: :value_caster
6
+
7
+ def initialize(**options)
8
+ @options = options
9
+ end
10
+
11
+ def type
12
+ self.class.name.demodulize.to_sym
13
+ end
14
+
15
+ class << self
16
+ def for(type, ...)
17
+ return new(...) unless type.in?(TYPES)
18
+
19
+ "ViewSpec::Types::#{type.to_s.camelize}".constantize.new(...)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def value_caster
26
+ @value_caster ||= begin
27
+ "::ActiveModel::Type::#{self.class.name.demodulize}".constantize.new
28
+ rescue NameError
29
+ NoopValueCaster.new
30
+ end
31
+ end
32
+ end
33
+
34
+ class NoopValueCaster
35
+ def cast(value)
36
+ value
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Boolean < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Date < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class DateTime < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Decimal < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Float < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Integer < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class String < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Symbol < Type
4
+ def cast(value)
5
+ value.to_sym
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module ViewSpec
2
+ module Types
3
+ class Time < Type
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module ViewSpec
2
+ module Utils
3
+ def normalize_paths(paths)
4
+ paths.map { File.absolute_path(_1, Rails.root) }
5
+ end
6
+
7
+ module_function :normalize_paths
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module ViewSpec
2
+ VERSION = "0.0.0"
3
+ end
data/lib/view_spec.rb ADDED
@@ -0,0 +1,38 @@
1
+ require "zeitwerk"
2
+ require "active_support"
3
+ require "view_spec/version"
4
+
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.tag = "view_spec"
7
+ loader.inflector.inflect "dsl" => "DSL"
8
+ loader.push_dir("#{__dir__}/view_spec", namespace: ViewSpec)
9
+ loader.collapse("#{__dir__}/view_spec/helpers")
10
+ loader.enable_reloading if ENV["RAILS_ENV"] == "development"
11
+ loader.setup
12
+
13
+ module ViewSpec
14
+ include DSL
15
+
16
+ class << self
17
+ def config
18
+ @config ||= Config.instance
19
+ end
20
+
21
+ def configure(&block)
22
+ yield config
23
+ end
24
+
25
+ def load!
26
+ registry.clear!
27
+
28
+ Utils.normalize_paths(config.spec_paths).each do |specs_path|
29
+ spec_files = Dir.glob("#{specs_path}/**/*#{config.spec_file_suffix}.rb")
30
+ spec_files.each { load _1 }
31
+ end
32
+
33
+ Utils.normalize_paths(config.spec_files).each { load _1 }
34
+ end
35
+ end
36
+ end
37
+
38
+ require "view_spec/engine"
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: view_spec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Mark Perkins
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: zeitwerk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activemodel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ description:
56
+ email:
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE.txt
62
+ - README.md
63
+ - app/views/view_spec/_group.html.erb
64
+ - app/views/view_spec/_scenario.html.erb
65
+ - app/views/view_spec/_spec.html.erb
66
+ - lib/view_spec.rb
67
+ - lib/view_spec/config.rb
68
+ - lib/view_spec/dsl.rb
69
+ - lib/view_spec/dsl/context.rb
70
+ - lib/view_spec/dsl/controller.rb
71
+ - lib/view_spec/dsl/groups.rb
72
+ - lib/view_spec/dsl/layout.rb
73
+ - lib/view_spec/dsl/params.rb
74
+ - lib/view_spec/dsl/scenarios.rb
75
+ - lib/view_spec/dsl/title.rb
76
+ - lib/view_spec/engine.rb
77
+ - lib/view_spec/error.rb
78
+ - lib/view_spec/group.rb
79
+ - lib/view_spec/helpers/spec_helper.rb
80
+ - lib/view_spec/param.rb
81
+ - lib/view_spec/param_set.rb
82
+ - lib/view_spec/registry.rb
83
+ - lib/view_spec/reloader.rb
84
+ - lib/view_spec/renderable.rb
85
+ - lib/view_spec/scenario.rb
86
+ - lib/view_spec/source_file.rb
87
+ - lib/view_spec/spec.rb
88
+ - lib/view_spec/subject.rb
89
+ - lib/view_spec/type.rb
90
+ - lib/view_spec/types/boolean.rb
91
+ - lib/view_spec/types/date.rb
92
+ - lib/view_spec/types/date_time.rb
93
+ - lib/view_spec/types/decimal.rb
94
+ - lib/view_spec/types/float.rb
95
+ - lib/view_spec/types/integer.rb
96
+ - lib/view_spec/types/string.rb
97
+ - lib/view_spec/types/symbol.rb
98
+ - lib/view_spec/types/time.rb
99
+ - lib/view_spec/utils.rb
100
+ - lib/view_spec/version.rb
101
+ homepage: https://github.com/lookbook-hq/view_spec
102
+ licenses:
103
+ - MIT
104
+ metadata: {}
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 3.1.0
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.3.3
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: ViewSpec lets you create example-led specifications for components, partials
124
+ and views using an expressive Ruby DSL
125
+ test_files: []