Merging from main master to my repo master. #4

Closed
Karavel wants to merge 53 commits from fixin.me/fixin.me:master into master
50 changed files with 710 additions and 311 deletions

View File

@ -1,7 +1,7 @@
source "https://rubygems.org"
ruby file: ".ruby-version"
gem "rails", "~> 7.1.2"
gem "rails", "~> 7.2.2"
gem "sprockets-rails"
gem "mysql2", "~> 0.5"
gem "puma", "~> 6.0"

View File

@ -1,124 +1,122 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3)
actionpack (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activesupport (= 7.1.3)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.3)
actionview (= 7.1.3)
activesupport (= 7.1.3)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3)
actionpack (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
useragent (~> 0.16)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3)
activesupport (= 7.1.3)
actionview (7.2.2)
activesupport (= 7.2.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.1.3)
activesupport (= 7.1.3)
activejob (7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.3.6)
activemodel (7.1.3)
activesupport (= 7.1.3)
activerecord (7.1.3)
activemodel (= 7.1.3)
activesupport (= 7.1.3)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
timeout (>= 0.4.0)
activestorage (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activesupport (= 7.1.3)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
marcel (~> 1.0)
activesupport (7.1.3)
activesupport (7.2.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.6)
benchmark (0.4.0)
bigdecimal (3.1.8)
bindex (0.8.1)
builder (3.2.4)
builder (3.3.0)
byebug (11.1.3)
capybara (3.39.2)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
date (3.3.4)
devise (4.9.3)
date (3.4.1)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
drb (2.2.0)
ruby2_keywords
erubi (1.12.0)
ffi (1.16.3)
drb (2.2.1)
erubi (1.13.0)
ffi (1.17.0-x86_64-linux-gnu)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.1)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
importmap-rails (2.0.1)
importmap-rails (2.0.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.2)
irb (1.11.1)
rdoc
io-console (0.8.0)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
loofah (2.22.0)
logger (1.6.2)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@ -126,79 +124,77 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.2)
marcel (1.0.4)
matrix (0.4.2)
mini_mime (1.1.5)
minitest (5.21.2)
mutex_m (0.2.0)
mysql2 (0.5.5)
net-imap (0.4.9.1)
minitest (5.25.4)
mysql2 (0.5.6)
net-imap (0.5.1)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.4.0.1)
net-smtp (0.5.0)
net-protocol
nio4r (2.7.0)
nokogiri (1.16.0-x86_64-linux)
nio4r (2.7.4)
nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
orm_adapter (0.5.0)
psych (5.1.2)
psych (5.2.1)
date
stringio
public_suffix (5.0.4)
puma (6.4.2)
public_suffix (6.0.1)
puma (6.5.0)
nio4r (~> 2.0)
racc (1.7.3)
rack (3.0.8)
racc (1.8.1)
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.2.1)
rack (>= 3)
webrick (~> 1.8)
rails (7.1.3)
actioncable (= 7.1.3)
actionmailbox (= 7.1.3)
actionmailer (= 7.1.3)
actionpack (= 7.1.3)
actiontext (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activemodel (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
rails (7.2.2)
actioncable (= 7.2.2)
actionmailbox (= 7.2.2)
actionmailer (= 7.2.2)
actionpack (= 7.2.2)
actiontext (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activemodel (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
bundler (>= 1.15.0)
railties (= 7.1.3)
railties (= 7.2.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
irb
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rake (13.1.0)
rdoc (6.6.2)
rake (13.2.1)
rdoc (6.8.1)
psych (>= 4.0.0)
regexp_parser (2.9.0)
reline (0.4.2)
regexp_parser (2.9.3)
reline (0.5.12)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.6)
ruby2_keywords (0.0.5)
rexml (3.3.9)
rubyzip (2.3.2)
sassc (2.4.0)
ffi (~> 1.9)
@ -208,27 +204,30 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.16.0)
securerandom (0.4.0)
selenium-webdriver (4.27.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stringio (3.1.0)
thor (1.3.0)
tilt (2.3.0)
timeout (0.4.1)
turbo-rails (2.0.0.pre.beta.3)
stringio (3.1.2)
thor (1.3.2)
tilt (2.4.0)
timeout (0.4.2)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@ -236,14 +235,13 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webrick (1.8.1)
websocket (1.2.10)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.12)
zeitwerk (2.7.1)
PLATFORMS
x86_64-linux
@ -255,7 +253,7 @@ DEPENDENCIES
importmap-rails
mysql2 (~> 0.5)
puma (~> 6.0)
rails (~> 7.1.2)
rails (~> 7.2.2)
sassc-rails
selenium-webdriver
sprockets-rails
@ -263,5 +261,8 @@ DEPENDENCIES
tzinfo-data
web-console
RUBY VERSION
ruby 3.3.0p0
BUNDLED WITH
2.5.3

View File

@ -9,8 +9,11 @@ Software requirements
* Server side:
* Ruby version: developed on Ruby 3.x
* database with recursive Common Table Expressions (CTE) support, e.g.
MySQL >= 8.0, MariaDB >= 10.2.2
* database with:
* recursive Common Table Expressions (CTE) support, e.g.
MySQL >= 8.0, MariaDB >= 10.2.2
* decimal type with precision of at least 30 (not sure if SQLite3
supports this)
* for testing: browser as specified in _Client side_ requirements
* Client side:
* browser supporting below requirements (e.g. Firefox >= 121):
@ -118,3 +121,15 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test test/system/users_test.rb --seed 1234
### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/
### Rake tasks
Exporting default settings defined in application to seed file (e.g. to send as
PR or share between installations):
bundle exec rails db:seed:export

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M3,16.74L7.76,12L3,7.26L7.26,3L12,7.76L16.74,3L21,7.26L16.24,12L21,16.74L16.74,21L12,16.24L7.26,21L3,16.74M12,13.41L16.74,18.16L18.16,16.74L13.41,12L18.16,7.26L16.74,5.84L12,10.59L7.26,5.84L5.84,7.26L10.59,12L5.84,16.74L7.26,18.16L12,13.41Z" /></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12 14L19 7H15V1H9V7H5L12 14M12 11.17L9.83 9H11V3H13V9H14.17L12 11.17M5 16V18H19V16H5M5 22V20H19V22H5Z" /></svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M13,5V11H14.17L12,13.17L9.83,11H11V5H13M15,3H9V9H5L12,16L19,9H15V3M19,18H5V20H19V18Z" /></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z" /></svg>

After

Width:  |  Height:  |  Size: 173 B

View File

@ -98,16 +98,21 @@ input[type=submit] {
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select {
select,
textarea {
padding: 0.2em 0.4em;
}
.button,
button,
input,
select {
select,
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
}
textarea {
margin: 0
}
.button > svg,
.tab > svg,
button > svg {
@ -151,7 +156,8 @@ input[type=checkbox]:checked {
-webkit-appearance: checkbox;
}
input:hover,
select:hover {
select:hover,
textarea:hover {
border-color: #009ade;
outline: solid 1px #009ade;
}
@ -160,11 +166,13 @@ select:hover {
}
input:focus-visible,
select:focus-within,
select:focus-visible {
select:focus-visible,
textarea:focus-visible {
accent-color: #006c9b;
background-color: var(--color-focus-gray);
}
input[type=text]:read-only {
input[type=text]:read-only,
textarea:read-only {
border: none;
padding-left: 0;
padding-right: 0;
@ -336,7 +344,7 @@ table.items th,
table.items td {
padding-inline: 1em 0;
}
table.items td:has(input) {
table.items td:has(input, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items th:last-child {
@ -367,7 +375,7 @@ table.items td.link a::after {
table.items td.subunit {
padding-inline-start: 1.8em;
}
table.items td.subunit:has(input) {
table.items td.subunit:has(input, textarea) {
padding-inline-start: calc(1.4em - 1px);
}
table.items td.actions {
@ -390,6 +398,9 @@ table.items tr.dropzone::after {
table.items td.handle {
cursor: move;
}
table.items tr.form td {
vertical-align: top;
}
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */
@ -410,7 +421,8 @@ table.items td.link a:hover:focus-visible {
color: #006c9b;
}
table.items td:not(:first-child) {
table.items td:not(:first-child),
.grayed {
color: var(--color-table-gray);
fill: var(--color-table-gray);
}
@ -439,7 +451,8 @@ table.items input[type=submit] {
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input:not(:hover),
table.items select:not(:hover) {
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray);
}
table.items .button:not(:hover),

View File

@ -0,0 +1,54 @@
class Default::UnitsController < ApplicationController
navigation_tab :units
before_action :find_unit, only: :export
before_action :find_unit_default, only: [:import, :destroy]
before_action only: :import do
raise AccessForbidden unless current_user.at_least(:active)
end
before_action except: [:index, :import] do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
@units = current_user.units.defaults_diff.includes(:base).ordered
end
def import
@unit.port!(current_user)
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
#def import_all
# From defaults_diff return not only portability, but reason for not being
# portable: missing_base and nesting_too_deep. Add portable and
# missing_base, if possible in one query
#end
def export
@unit.port!(nil)
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
def destroy
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
private
def find_unit
@unit = Unit.find_by!(id: params[:id], user: current_user)
end
def find_unit_default
@unit = Unit.find_by!(id: params[:id], user: nil)
end
end

View File

@ -1,10 +0,0 @@
class Units::DefaultsController < ApplicationController
navigation_tab :units
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
end
end

View File

@ -1,5 +1,5 @@
class UnitsController < ApplicationController
before_action only: [:new] do
before_action only: :new do
find_unit if params[:id].present?
end
before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
@ -9,7 +9,7 @@ class UnitsController < ApplicationController
end
def index
@units = current_user.units.includes(:subunits)
@units = current_user.units.includes(:subunits).ordered
end
def new
@ -19,7 +19,7 @@ class UnitsController < ApplicationController
def create
@unit = current_user.units.new(unit_params)
if @unit.save
flash.now[:notice] = t(".success")
flash.now[:notice] = t('.success', unit: @unit)
run_and_render :index
else
render :new
@ -31,7 +31,7 @@ class UnitsController < ApplicationController
def update
if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t(".success")
flash.now[:notice] = t('.success', unit: @unit)
run_and_render :index
else
render :edit
@ -40,25 +40,28 @@ class UnitsController < ApplicationController
def rebase
permitted = params.require(:unit).permit(:base_id)
if permitted[:base_id].blank? && @unit.multiplier != 1
permitted.merge!(multiplier: 1)
flash.now[:notice] = t(".multiplier_reset", symbol: @unit.symbol)
end
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1
run_and_render :index if @unit.update(permitted)
@unit.update!(permitted)
if @unit.multiplier_previously_changed?
flash.now[:notice] = t(".multiplier_reset", unit: @unit)
end
ensure
run_and_render :index
end
def destroy
if @unit.destroy
flash.now[:notice] = t(".success")
end
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
private
def unit_params
params.require(:unit).permit(:symbol, :name, :base_id, :multiplier)
params.require(:unit).permit(Unit::ATTRIBUTES)
end
def find_unit

View File

@ -2,12 +2,13 @@ class UsersController < ApplicationController
helper_method :allow_disguise?
before_action :find_user, only: [:show, :update, :disguise]
before_action except: :revert do
raise AccessForbidden unless current_user.at_least(:admin)
end
before_action only: :revert do
raise AccessForbidden unless current_user_disguised?
end
before_action except: :revert do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
@users = User.all

View File

@ -84,6 +84,7 @@ module ApplicationHelper
[:button_to, :link_to, :link_to_unless_current].each do |method_name|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def image_#{method_name}(name, image = nil, options = nil, html_options = {}, &block)
name = name.to_s
name = svg_tag("pictograms/\#{image}") + name if image
html_options[:class] = class_names(
@ -95,6 +96,11 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end
if __method__.start_with?('image_link_to') &&
!(html_options[:onclick] || html_options.dig(:data, :turbo_stream))
name = name + '...'
end
send :#{method_name}, name, options, html_options, &block
end
RUBY_EVAL
@ -123,27 +129,13 @@ module ApplicationHelper
"Turbo.renderStreamMessage('#{j(render partial: partial, locals: locals)}'); return false;"
end
private
def disabled_attributes(disabled)
disabled ? {disabled: true, aria: {disabled: true}, tabindex: -1} : {}
end
# Converts value to HTML formatted scientific notation
def scientifize(d)
sign, coefficient, base, exponent = d.split
return 'NaN' unless sign
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
end
if exponent != 1
result += "&times;"
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
def number_attributes(type)
step = BigDecimal(10).power(-type.scale)
max = BigDecimal(10).power(type.precision - type.scale) - step
{min: -max, max: max, step: step}
end
end

View File

@ -0,0 +1,2 @@
module Default::UnitsHelper
end

View File

@ -1,2 +0,0 @@
module Units::DefaultsHelper
end

View File

@ -1,26 +1,83 @@
class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id]
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
has_many :subunits, class_name: "Unit", dependent: :restrict_with_error, inverse_of: :base
has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error
validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user == base.user
errors.add(:base, :multilevel_nesting) if base.base.present?
end
validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: columns_hash['symbol'].limit}
validates :name, length: {maximum: columns_hash['name'].limit}
length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {other_than: 0}, if: :base
validates :multiplier, numericality: {other_than: 0, precision: true, scale: true}, if: :base
scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{
actionable_units = Arel::Table.new('actionable_units')
units = actionable_units.alias('units')
bases_units = arel_table.alias('bases_units')
other_units = arel_table.alias('other_units')
other_bases_units = arel_table.alias('other_bases_units')
sub_units = arel_table.alias('sub_units')
# TODO: move inner 'with' CTE to outer 'with recursive' - it can have multiple
# CTEs, even non recursive ones.
Unit.with_recursive(actionable_units: [
Unit.with(units: self.or(Unit.defaults)).left_joins(:base)
.where.not(
# Exclude Units that are/have default counterpart
Arel::SelectManager.new.project(1).from(other_units)
.outer_join(other_bases_units)
.on(other_units[:base_id].eq(other_bases_units[:id]))
.where(
other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol])
.and(other_units[:symbol].eq(arel_table[:symbol]))
.and(other_units[:user_id].is_distinct_from(arel_table[:user_id]))
).exists
)
.select(
arel_table[Arel.star],
# Decide if Unit can be im-/exported based on existing hierarchy:
# * same base unit symbol has to exist
# * unit with subunits can only be ported to root
arel_table[:base_id].eq(nil).or(
(
Arel::SelectManager.new.project(1).from(other_units)
.join(sub_units).on(other_units[:id].eq(sub_units[:base_id]))
.where(
other_units[:symbol].eq(arel_table[:symbol])
.and(other_units[:user_id].is_distinct_from(arel_table[:user_id]))
).exists.not
).and(
Arel::SelectManager.new.project(1).from(other_bases_units)
.where(
other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol])
.and(other_bases_units[:user_id].is_distinct_from(bases_units[:user_id]))
).exists
)
).as('portable')
),
# Fill base Units to display proper hierarchy. Duplicates will be removed
# by final group() - can't be deduplicated with UNION due to 'portable' field.
arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id]))
.project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
]).select(units: [:base_id, :symbol])
.select(
units[:id].minimum.as('id'), # can be ANY_VALUE()
units[:user_id].minimum.as('user_id'), # prefer non-default
Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting
units[:portable].minimum.as('portable')
)
.from(units).group(:base_id, :symbol)
}
scope :ordered, ->{
parent_symbol = Arel::Nodes::NamedFunction.new(
'COALESCE',
[Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]]
)
left_outer_joins(:base)
.order(parent_symbol, arel_table[:base_id].asc.nulls_first, :multiplier, :symbol)
.order(arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil), :multiplier, :symbol)
}
before_destroy do
@ -28,7 +85,23 @@ class Unit < ApplicationRecord
nil
end
def to_s
symbol
end
def movable?
subunits.empty?
end
def default?
user_id.nil?
end
# Should only by invoked on Units returned from #defaults_diff which are #portable
def port!(recipient)
recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient)
params = slice(ATTRIBUTES - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params)
end
end

View File

@ -11,7 +11,15 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in
}, default: :active
has_many :units, -> { ordered }, dependent: :destroy
has_many :units, dependent: :destroy
validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit}
validates :unconfirmed_email, length: {maximum: type_for_attribute(:unconfirmed_email).limit}
def to_s
email
end
def at_least(status)
User.statuses[self.status] >= User.statuses[status]

View File

@ -0,0 +1,23 @@
<%= tag.tr do %>
<td class="<%= class_names({subunit: unit.base, grayed: unit.default?}) %>">
<%= unit %>
</td>
<td class="actions">
<% unless unit.portable.nil? %>
<% if current_user.at_least(:active) && unit.default? %>
<%= image_button_to t('.import'), 'download-outline', import_default_unit_path(unit),
disabled_attributes(!unit.portable?) %>
<% end %>
<% if current_user.at_least(:admin) %>
<% if unit.default? %>
<%= image_button_to t('.delete'), 'delete-outline', default_unit_path(unit),
method: :delete %>
<% else %>
<%= image_button_to t('.export'), 'upload-outline', export_default_unit_path(unit),
disabled_attributes(!unit.portable?) %>
<% end %>
<% end %>
<% end %>
</td>
<% end %>

View File

@ -0,0 +1,22 @@
<div class="rightside buttongrid">
<% if current_user.at_least(:active) %>
<%# TODO: implement Import all %>
<%#= image_button_to t('.import_all'), 'download-multiple-outline',
import_all_default_units_path, data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %>
</div>
<table class="main items">
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t '.actions' %></th>
<% end %>
</tr>
</thead>
<tbody id="units">
<%= render(@units) || render_no_items %>
</tbody>
</table>

View File

@ -0,0 +1,3 @@
<%= turbo_stream.update :units do %>
<%= render(@units) || render_no_items %>
<% end %>

View File

@ -29,7 +29,7 @@
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user.email, "account-wrench-outline",
<%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) {} %>
<% if current_user_disguised? %>
<%= image_link_to t(".revert"), "incognito-off", revert_users_path %>

View File

@ -4,24 +4,26 @@
<td class="<%= class_names({subunit: @unit.base}) %>">
<%= form.text_field :symbol, form: :unit_form, required: true, autofocus: true, size: 12,
maxlength: @unit.class.columns_hash['symbol'].limit, autocomplete: "off" %>
maxlength: @unit.class.type_for_attribute(:symbol).limit, autocomplete: "off" %>
</td>
<td>
<%= form.text_field :name, form: :unit_form, size: 30,
maxlength: @unit.class.columns_hash['name'].limit, autocomplete: "off" %>
<%= form.text_area :description, form: :unit_form, cols: 30, rows: 1, escape: false,
maxlength: @unit.class.type_for_attribute(:description).limit, autocomplete: "off" %>
</td>
<td>
<% unless @unit.base.nil? %>
<%= form.hidden_field :base_id, form: :unit_form %>
<%= form.number_field :multiplier, form: :unit_form, required: true, step: "any",
size: 10, autocomplete: "off" %>
<%= form.number_field :multiplier, form: :unit_form, required: true,
size: 10, autocomplete: "off",
**number_attributes(@unit.class.type_for_attribute(:multiplier)) %>
<% end %>
</td>
<td class="actions">
<%= form.submit form: :unit_form %>
<%= image_link_to t(:cancel), "close-circle-outline", units_path, class: 'dangerous',
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {link_id: link_id}) %>
</td>
<td></td>
<% end %>
<% end %>

View File

@ -5,21 +5,21 @@
data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} do %>
<td class="<%= class_names('link', {subunit: unit.base}) %>">
<%= link_to unit.symbol, edit_unit_path(unit), id: dom_id(unit, :edit),
<%= link_to unit, edit_unit_path(unit), id: dom_id(unit, :edit),
onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= unit.name %></td>
<td class="number"><%= scientifize(unit.multiplier) %></td>
<td><%= unit.description %></td>
<td class="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %>
<td class="actions">
<% if unit.base.nil? %>
<%= image_link_to t(".add_subunit"), "plus-outline", new_unit_path(unit),
<%= image_link_to t('.add_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :add), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
<%= image_button_to t(".delete_unit"), "delete-outline", unit_path(unit),
<%= image_button_to t('.delete_unit'), 'delete-outline', unit_path(unit),
method: :delete %>
</td>
<% if unit.movable? %>

View File

@ -1,2 +0,0 @@
<h1>Units::Defaults#index</h1>
<p>Find me in app/views/units/defaults/index.html.erb</p>

View File

@ -2,9 +2,8 @@
<% if current_user.at_least(:active) %>
<%= image_link_to t('.add_unit'), 'plus-outline', new_unit_path, id: :add_unit,
onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_link_to t('.import_units'), 'import', new_unit_path, class: 'tools',
data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %>
</div>
<%= tag.div id: :unit_form %>
@ -13,7 +12,7 @@
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:name).capitalize %></th>
<th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>

View File

@ -11,7 +11,7 @@
<tbody>
<% @users.each do |user| %>
<tr>
<td class="link"><%= link_to user.email, user_path(user) %></td>
<td class="link"><%= link_to user, user_path(user) %></td>
<td>
<% if user == current_user %>
<%= user.status %>

View File

@ -1,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>Hello <%= @resource %>!</p>
<p>We're contacting you to notify you that your password has been changed.</p>

View File

@ -1,4 +1,4 @@
<p>Hello <%= @resource.email %>!</p>
<p>Hello <%= @resource %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>

View File

@ -1,4 +1,4 @@
<p>Hello <%= @resource.email %>!</p>
<p>Hello <%= @resource %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>

View File

@ -1,6 +1,6 @@
<% content_for :navigation, flush: true do %>
<%= image_link_to t(:back), 'arrow-left-bold-outline',
request.referer.present? ? :back : root_path %>
<%= link_to svg_tag("pictograms/arrow-left-bold-outline") + t(:back),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %>
<div class="rightside buttongrid">

View File

@ -23,6 +23,10 @@ module FixinMe
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
# Autoload lib/, required e.g. for core library extensions.
# https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#config-autoload-lib-ignore.
config.autoload_lib(ignore: %w(assets tasks))
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
@ -32,7 +36,7 @@ module FixinMe
# config.eager_load_paths << Rails.root.join("extras")
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ArgumentError'] = :bad_request
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
# SETUP: Below settings need to be updated on a per-installation basis.
#
@ -47,5 +51,8 @@ module FixinMe
# Email address of admin account
config.admin = 'admin@localhost'
# Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost'
end
end

View File

@ -0,0 +1,9 @@
require 'core_ext/big_decimal_scientific_notation'
ActiveSupport.on_load :active_record do
ActiveModel::Validations::NumericalityValidator.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
end
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
end

View File

@ -24,7 +24,8 @@ Devise.setup do |config|
# Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class
# with default "from" parameter.
config.mailer_sender = 'fixinme@noreply.me'
# This is set in 'config/application.rb'.
#config.mailer_sender = 'fixinme@noreply.me'
# Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer'

View File

@ -1,3 +0,0 @@
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
end

View File

@ -1,4 +1,8 @@
en:
errors:
messages:
precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits
activerecord:
attributes:
unit:
@ -33,6 +37,9 @@ en:
forbidden: >
You have not been granted access to this action (403 Forbidden).
This should not happen, please notify site administrator.
not_found: >
The record that you requested operation on does not exist (404 Not Found).
This should not happen, please notify site administrator.
unprocessable_entity: >
The request is semantically incorrect and was rejected (422 Unprocessable Entity).
This should not happen, please notify site administrator.
@ -54,22 +61,39 @@ en:
delete_unit: Delete
index:
add_unit: Add unit
import_units: Import...
no_items: There are no configured units. You can try to import some defaults.
import_units: Import
no_items: There are no configured units. You can Add some or Import from defaults.
top_level_drop: Drop here to reposition into top-level unit
new:
none: none
create:
success: Created new unit
success: Created new unit "%{unit}"
update:
success: Updated unit
success: Updated unit "%{unit}"
rebase:
multiplier_reset: Multiplier of "%{symbol}" has been reset to 1, due to repositioning
multiplier_reset: Multiplier of "%{unit}" has been reset to 1, due to repositioning
destroy:
success: Deleted unit
success: Deleted unit "%{unit}"
default:
units:
unit:
delete: Delete
export: Export
import: Import
index:
actions: Actions on defaults
back: Back to units
import_all: Import all
no_items: There are no differences between default and user units.
import:
success: Imported unit "%{unit}"
export:
success: Exported unit "%{unit}"
destroy:
success: Deleted unit "%{unit}"
users:
index:
disguise: View as...
disguise: View as
passwords:
edit:
new_password: New password

View File

@ -3,22 +3,19 @@ Rails.application.routes.draw do
controllers: {registrations: :registrations}
resources :units, except: [:show], path_names: {new: '(/:id)/new'} do
member do
post :rebase
end
member { post :rebase }
end
namespace :units do
get 'defaults/index'
namespace :default do
resources :units, only: [:index, :destroy] do
member { post :import, :export }
#collection { post :import_all }
end
end
resources :users, only: [:index, :show, :update] do
member do
get :disguise
end
collection do
get :revert
end
member { get :disguise }
collection { get :revert }
end
devise_scope :user do

View File

@ -2,12 +2,12 @@ class CreateUnits < ActiveRecord::Migration[7.0]
def change
create_table :units do |t|
t.references :user, foreign_key: true
t.string :symbol
t.string :name
t.decimal :multiplier, precision: 30, scale: 15, default: 1.0
t.string :symbol, null: false, limit: 15
t.text :description
t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base
t.timestamps
t.timestamps null: false
end
add_index :units, [:user_id, :symbol], unique: true
end

View File

@ -10,12 +10,12 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2023_06_02_185352) do
ActiveRecord::Schema[7.2].define(version: 2023_06_02_185352) do
create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "symbol"
t.string "name"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0"
t.string "symbol", limit: 15, null: false
t.text "description"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false
t.bigint "base_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

View File

@ -20,19 +20,4 @@ end
# Formulas will be deleted as dependent on Quantities
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
Unit.transaction do
Unit.defaults.delete_all
unit_1 = Unit.create symbol: "1", name: "dimensionless, one"
Unit.create symbol: "%", base: unit_1, multiplier: 1e-2, name: "percent"
Unit.create symbol: "", base: unit_1, multiplier: 1e-3, name: "promille"
Unit.create symbol: "", base: unit_1, multiplier: 1e-4, name: "basis point"
Unit.create symbol: "ppm", base: unit_1, multiplier: 1e-6, name: "parts per million"
unit_g = Unit.create symbol: "g", name: "gram"
Unit.create symbol: "ug", base: unit_g, multiplier: 1e-6, name: "microgram"
Unit.create symbol: "mg", base: unit_g, multiplier: 1e-3, name: "milligram"
Unit.create symbol: "kg", base: unit_g, multiplier: 1e3, name: "kilogram"
Unit.create symbol: "kcal", name: "kilocalorie"
end
require 'seeds/units.rb'

View File

@ -0,0 +1,9 @@
Unit.transaction do
Unit.defaults.delete_all
<% Unit.defaults.ordered.each do |unit| %>
<%= "\n" if unit.base.nil? %>
unit_<%= unit.symbol %> =
Unit.create symbol: "<%= unit.symbol %>",<% unless unit.base.nil? %> base: unit_<%= unit.base.symbol %>, multiplier: <%= unit.multiplier.to_scientific %>,<% end %>
description: "<%= unit.description %>"
<% end %>
end

36
db/seeds/units.rb Normal file
View File

@ -0,0 +1,36 @@
Unit.transaction do
Unit.defaults.delete_all
unit_1 =
Unit.create symbol: "1",
description: "dimensionless, one"
unit_ppm =
Unit.create symbol: "ppm", base: unit_1, multiplier: 1e-6,
description: "parts per million"
unit_ =
Unit.create symbol: "", base: unit_1, multiplier: 1e-4,
description: "basis point"
unit_ =
Unit.create symbol: "", base: unit_1, multiplier: 1e-3,
description: "promille"
unit_% =
Unit.create symbol: "%", base: unit_1, multiplier: 1e-2,
description: "percent"
unit_g =
Unit.create symbol: "g",
description: "gram"
unit_ug =
Unit.create symbol: "ug", base: unit_g, multiplier: 1e-6,
description: "microgram"
unit_mg =
Unit.create symbol: "mg", base: unit_g, multiplier: 1e-3,
description: "milligram"
unit_kg =
Unit.create symbol: "kg", base: unit_g, multiplier: 1e3,
description: "kilogram"
unit_kcal =
Unit.create symbol: "kcal",
description: "kilocalorie"
end

View File

@ -1,4 +1,4 @@
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
private
def unique

View File

@ -0,0 +1,16 @@
module CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
def validate_each(record, attr_name, value, ...)
super(record, attr_name, value, ...)
if options[:precision] || options[:scale]
attr_type = record.class.type_for_attribute(attr_name)
value = BigDecimal(value) unless value.is_a? BigDecimal
if options[:precision] && (value.precision > attr_type.precision)
record.errors.add(attr_name, :precision_exceeded, **filtered_options(attr_type.precision))
end
if options[:scale] && (value.scale > attr_type.scale)
record.errors.add(attr_name, :scale_exceeded, **filtered_options(attr_type.scale))
end
end
end
end

View File

@ -0,0 +1,36 @@
module CoreExt
module BigDecimalScientificNotation
def to_scientific
return 'NaN' unless finite?
sign, coefficient, base, exponent = split
(sign == -1 ? '-' : '') +
(coefficient.length > 1 ? coefficient.insert(1, '.') : coefficient) +
(exponent != 1 ? "e#{exponent-1}" : '')
end
# Converts value to HTML formatted scientific notation
def to_html
sign, coefficient, base, exponent = split
return 'NaN' unless sign
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
end
if exponent != 1
result += "&times;"
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
end
end
end
BigDecimal.prepend CoreExt::BigDecimalScientificNotation

12
lib/tasks/db.rake Normal file
View File

@ -0,0 +1,12 @@
namespace :db do
namespace :seed do
desc "Dump default settings as seed data to db/seeds/*.rb"
task export: :environment do
seeds_path = Pathname.new(Rails.application.paths["db"].first) / 'seeds'
(seeds_path / 'templates').glob('*.erb').each do |template_path|
template = ERB.new(template_path.read, trim_mode: '<>')
(seeds_path / "#{template_path.basename('.*').to_s}.rb").write(template.result)
end
end
end
end

View File

@ -1,10 +1,13 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
extend ActionView::Helpers::TranslationHelper
include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning
Selenium::WebDriver.logger.ignore(:selenium_manager)
Selenium::WebDriver.logger
.ignore(:selenium_manager, :clear_session_storage, :clear_local_storage)
Capybara.configure do |config|
config.save_path = "#{Rails.root}/tmp/screenshots/"
end
@ -30,4 +33,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
#def assert_stale(element)
# assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name }
#end
# HTML does not allow [disabled] attribute on <a> tag, so it's not possible to
# easily find them using e.g. :link selector
#Capybara.add_selector(:disabled_link) do
# label "<a> tag with [disabled] attribute"
#end
test "click disabled link" do
# Link should be unclickable
# assert_raises(Selenium::WebDriver::Error::ElementClickInterceptedError) do
# # Use custom selector for disabled links
# find('a[disabled]').click
# end
end
end

View File

@ -1,6 +1,6 @@
require "test_helper"
class Units::DefaultsControllerTest < ActionDispatch::IntegrationTest
class Default::UnitsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get units_defaults_index_url
assert_response :success

View File

@ -1,40 +1,40 @@
g:
user: admin
symbol: g
name: gram
description: gram
kg:
user: admin
symbol: kg
name: kilogram
description: kilogram
multiplier: 1000
base: g
1:
user: admin
symbol: 1
name: one
description: one
s:
user: admin
symbol: s
name: second
description: second
percent:
user: admin
symbol: '%'
name: percent
description: percent
multiplier: 0.01
base: 1
µg:
user: admin
symbol: µg
name: microgram
description: microgram
multiplier: 0.000001
base: g
mg:
user: admin
symbol: mg
name: milligram
description: milligram
multiplier: 0.001
base: g
g_alice:
user: alice
symbol: g
name: gram
description: gram

View File

@ -1,8 +1,16 @@
require "application_system_test_case"
class UnitsTest < ApplicationSystemTestCase
LINK_LABELS = {
add_unit: t('units.index.add_unit'),
add_subunit: t('units.unit.add_subunit'),
edit: nil
}
setup do
@user = sign_in
LINK_LABELS[:edit] = Regexp.union(@user.units.map(&:symbol))
visit units_path
end
@ -12,7 +20,8 @@ class UnitsTest < ApplicationSystemTestCase
assert_selector 'tr', count: @user.units.count
end
Unit.destroy_all
# Cannot #destroy_all due to {dependent: :restrict*} on Unit.subunits association
@user.units.delete_all
visit units_path
within 'tbody' do
assert_selector 'tr', count: 1
@ -20,16 +29,24 @@ class UnitsTest < ApplicationSystemTestCase
end
end
test "add unit" do
click_on t('units.index.add_unit')
test "new" do
type, label = LINK_LABELS.assoc([:add_unit, :add_subunit].sample)
add_link = all(:link, exact_text: label).sample
add_link.click
assert_equal 'disabled', add_link[:disabled]
within 'tbody > tr:has(input[type=text], textarea)' do
assert_selector ':focus'
maxlength = all(:fillable_field).to_h { |f| [f[:name], f[:maxlength].to_i || 1000] }
maxlength = all(:fillable_field).to_h { |f| [f[:name], f[:maxlength].to_i || 2**16] }
fill_in 'unit[symbol]',
with: SecureRandom.random_symbol(rand([1..15, 15..maxlength['unit[symbol]']].sample))
fill_in 'unit[name]',
with: [nil, SecureRandom.alphanumeric(rand(1..maxlength['unit[name]']))].sample
with: random_string(rand([1..3, 4..maxlength['unit[symbol]']].sample))
fill_in 'unit[description]', with: random_string(rand(0..maxlength['unit[description]']))
within :field, 'unit[multiplier]' do |field|
fill_in with: random_number(field[:max], field[:step])
end if add_link[:text] != t('units.index.add_unit')
assert_difference ->{ Unit.count }, 1 do
click_on t('helpers.submit.create')
end
@ -39,39 +56,48 @@ class UnitsTest < ApplicationSystemTestCase
assert_no_selector :fillable_field
assert_selector 'tr', count: @user.units.count
end
assert_selector '.flash.notice', text: /^#{t('units.create.success')}/
assert_no_selector :element, :a, 'disabled': 'disabled',
exact_text: Regexp.union(LINK_LABELS.fetch_values(:add_unit, :add_subunit))
assert_selector '.flash.notice', text: t('units.create.success', unit: Unit.all.last.symbol)
end
test "add and edit disallow opening multiple forms" do
# Once new/edit form is open, other actions on the same page either replace
# the form or leave it untouched
# TODO: add non-empty form closing warning
# TODO: check proper form/button redisplay and flash messages on add/edit
test "new and edit form on validation error" do
end
# TODO: add non-empty form closing warning
test "new and edit disallow opening multiple forms" do
# Once new/edit form is open, attempt to open another one will close it
links = {}
link_labels = {1 => [t('units.index.add_unit'), t('units.unit.add_subunit')],
0 => units.map(&:symbol)}
link_labels.each_pair do |row_change, labels|
all(:link_or_button, exact_text: Regexp.union(labels)).map { |l| links[l] = row_change }
targets = {}
LINK_LABELS.each_pair do |type, labels|
links[type] = all(:link_or_button, exact_text: labels).to_a
targets[type] = links[type].sample
end
link, rows = links.assoc(links.keys.sample).tap { |l, _| links.delete(l) }
# Define tr count change depending on link clicked
row_change = {add_unit: 1, add_subunit: 1, edit: 0}
type, link = targets.assoc(targets.keys.sample).tap { |t, _| targets.delete(t) }
rows = row_change[type]
assert_difference ->{ all('tbody tr').count }, rows do
link.click
end
find 'tbody tr:has(input[type=text]:focus)'
# Link should be now unavailable or unclickable
begin
assert_raises(Selenium::WebDriver::Error::ElementClickInterceptedError) do
link.click
end if link.visible?
rescue Selenium::WebDriver::Error::StaleElementReferenceError
link = nil
within('tbody tr:has(input[type=text])') { assert_selector ':focus' }
if type == :edit
assert !link.visible?
[:add_subunit, :edit].each do |t|
assert_difference(->{ links[t].length }, -1) { links[t].select!(&:visible?) }
end
else
assert link[:disabled]
end
link = links.keys.select(&:visible?).sample
assert_difference ->{ all('tbody tr').count }, links[link] - rows do
targets.merge([:add_subunit, :edit].map { |t| [t, links[t].sample] }.to_h)
type, link = targets.assoc(targets.keys.sample)
assert_difference ->{ all('tbody tr').count }, row_change[type] - rows do
link.click
end
assert_selector 'tbody tr:has(input[type=text]:focus)', count: 1
within('tbody tr:has(input[type=text])') { assert_selector ':focus' }
end
# NOTE: extend with any add/edit link

View File

@ -9,15 +9,41 @@ class ActiveSupport::TestCase
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
include AbstractController::Translation
include ActionMailer::TestHelper
include ActionView::Helpers::TranslationHelper
# NOTE: use public #alphanumeric(chars: ...) from Ruby 3.3 onwards
SecureRandom.class_eval do
def self.random_symbol(n = 10)
# Unicode characters: 32-126, 160-383
choose([*' '..'~', 160.chr(Encoding::UTF_8), *'¡'..'ſ'], n)
# List of categorized Unicode characters:
# * http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
# File format: http://www.unicode.org/L2/L1999/UnicodeData.html
# Select from graphic ranges: L, M, N, P, S, Zs
UNICODE_CHARS = {
1 => [*"\u0020".."\u007E"],
2 => [*"\u00A0".."\u00AC",
*"\u00AE".."\u05FF",
*"\u0606".."\u061B",
*"\u061D".."\u06DC",
*"\u06DE".."\u070E",
*"\u0710".."\u07FF"]
}
UNICODE_CHARS.default = UNICODE_CHARS[1] + UNICODE_CHARS[2]
def random_string(bytes = 10)
result = ''
while bytes > 0
result += UNICODE_CHARS[bytes].sample.tap { |c| bytes -= c.bytesize }
end
result
end
# Assumes: max >= step and step = 1e[-]N, both as strings
def random_number(max, step)
max.delete!('.')
precision = max.length
start = rand(precision) + 1
d = (rand(max.to_i) + 1) % 10**start
length = rand([0, 1..4, 4..precision].sample)
d = d.truncate(-start + length)
d = 10**(start - length) if d.zero?
BigDecimal(step) * d
end
def randomize_user_password!(user)
@ -25,11 +51,11 @@ class ActiveSupport::TestCase
end
def random_password
SecureRandom.alphanumeric rand(Rails.configuration.devise.password_length)
Random.alphanumeric rand(Rails.configuration.devise.password_length)
end
def random_email
"%s@%s.%s" % (1..3).map { SecureRandom.alphanumeric(rand(1..20)) }
"%s@%s.%s" % (1..3).map { Random.alphanumeric(rand(1..20)) }
end
def with_last_email