Merge branch 'staging' of https://github.com/FarmBot/Farmbot-Web-App into lockout_prep

pull/791/head
Rick Carlino 2018-04-15 14:06:12 -05:00
commit fed516ea13
102 changed files with 1180 additions and 902 deletions

View File

@ -1 +1 @@
2.5.0
2.5.1

View File

@ -4,7 +4,7 @@ node_js:
before_script:
- sudo apt-get install curl -y
- mv node_modules/.bin/which node_modules/.bin/which.backup
- rvm install 2.5.0 && rvm use 2.5.0
- rvm install 2.5.1 && rvm use 2.5.1
- mv node_modules/.bin/which.backup node_modules/.bin/which
- cp config/database.travis.yml config/database.yml
- mkdir -p public/app

View File

@ -1,5 +1,5 @@
source "https://rubygems.org"
ruby "2.5.0"
ruby "2.5.1"
gem "rails"
gem "thin"
@ -27,8 +27,9 @@ gem "request_store"
gem "secure_headers"
gem "valid_url"
gem "font-awesome-rails"
gem "discard", "~> 1.0"
gem "discard"
gem "scenic"
gem "bullet"
group :development, :test do
gem "lol_dba"

View File

@ -6,7 +6,7 @@ GIT
GIT
remote: https://github.com/fog/fog-google
revision: ee58e2a4d9502f2a4dc102ca6a4b664656551e3f
revision: c1be700a0c9557e8cf58b0eaa5d95bb078ca7fe5
specs:
fog-google (1.3.3)
fog-core
@ -17,25 +17,25 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (5.1.5)
actionpack (= 5.1.5)
actioncable (5.1.6)
actionpack (= 5.1.6)
nio4r (~> 2.0)
websocket-driver (~> 0.6.1)
actionmailer (5.1.5)
actionpack (= 5.1.5)
actionview (= 5.1.5)
activejob (= 5.1.5)
actionmailer (5.1.6)
actionpack (= 5.1.6)
actionview (= 5.1.6)
activejob (= 5.1.6)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.1.5)
actionview (= 5.1.5)
activesupport (= 5.1.5)
actionpack (5.1.6)
actionview (= 5.1.6)
activesupport (= 5.1.6)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.1.5)
activesupport (= 5.1.5)
actionview (5.1.6)
activesupport (= 5.1.6)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@ -45,18 +45,18 @@ GEM
activemodel (>= 4.1, < 6)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (5.1.5)
activesupport (= 5.1.5)
activejob (5.1.6)
activesupport (= 5.1.6)
globalid (>= 0.3.6)
activemodel (5.1.5)
activesupport (= 5.1.5)
activerecord (5.1.5)
activemodel (= 5.1.5)
activesupport (= 5.1.5)
activemodel (5.1.6)
activesupport (= 5.1.6)
activerecord (5.1.6)
activemodel (= 5.1.6)
activesupport (= 5.1.6)
arel (~> 8.0)
activesupport (5.1.5)
activesupport (5.1.6)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.2)
@ -65,15 +65,18 @@ GEM
arel (8.0.0)
bcrypt (3.1.11)
builder (3.2.3)
bullet (5.7.5)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11.0)
bunny (2.9.2)
amq-protocol (~> 2.3.0)
capybara (2.18.0)
capybara (3.0.1)
addressable
mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (>= 2.0, < 4.0)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
xpath (~> 3.0)
case_transform (0.2)
activesupport
childprocess (0.9.0)
@ -86,7 +89,7 @@ GEM
url
coderay (1.1.2)
concurrent-ruby (1.0.5)
crass (1.0.3)
crass (1.0.4)
daemons (1.2.6)
database_cleaner (1.6.2)
declarative (0.0.10)
@ -108,7 +111,7 @@ GEM
docile (1.3.0)
erubi (1.7.1)
eventmachine (1.2.5)
excon (0.61.0)
excon (0.62.0)
factory_bot (4.8.2)
activesupport (>= 3.0.0)
factory_bot_rails (4.8.2)
@ -132,8 +135,8 @@ GEM
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-rails (4.7.0.3)
railties (>= 3.2, < 5.2)
font-awesome-rails (4.7.0.4)
railties (>= 3.2, < 6.0)
foreman (0.84.0)
thor (~> 0.19.1)
formatador (0.2.5)
@ -156,7 +159,7 @@ GEM
signet (~> 0.7)
hashdiff (0.3.7)
httpclient (2.8.3)
i18n (0.9.5)
i18n (1.0.0)
concurrent-ruby (~> 1.0)
json (2.1.0)
jsonapi-renderer (0.2.0)
@ -212,22 +215,22 @@ GEM
pry (>= 0.10.4)
public_suffix (3.0.2)
rack (2.0.4)
rack-attack (5.1.0)
rack-attack (5.2.0)
rack
rack-cors (1.0.2)
rack-test (0.8.3)
rack-test (1.0.0)
rack (>= 1.0, < 3)
rails (5.1.5)
actioncable (= 5.1.5)
actionmailer (= 5.1.5)
actionpack (= 5.1.5)
actionview (= 5.1.5)
activejob (= 5.1.5)
activemodel (= 5.1.5)
activerecord (= 5.1.5)
activesupport (= 5.1.5)
rails (5.1.6)
actioncable (= 5.1.6)
actionmailer (= 5.1.6)
actionpack (= 5.1.6)
actionview (= 5.1.6)
activejob (= 5.1.6)
activemodel (= 5.1.6)
activerecord (= 5.1.6)
activesupport (= 5.1.6)
bundler (>= 1.3.0)
railties (= 5.1.5)
railties (= 5.1.6)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
@ -237,16 +240,16 @@ GEM
activesupport (>= 3.2)
choice (~> 0.2.0)
ruby-graphviz (~> 1.2)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
rails_12factor (0.0.3)
rails_serve_static_assets
rails_stdout_logging
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.1.5)
actionpack (= 5.1.5)
activesupport (= 5.1.5)
railties (5.1.6)
actionpack (= 5.1.6)
activesupport (= 5.1.6)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
@ -304,7 +307,7 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
skylight (1.6.0)
skylight (1.6.1)
activesupport (>= 3.0.0)
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
@ -324,6 +327,7 @@ GEM
tzinfo (1.2.5)
thread_safe (~> 0.1)
uber (0.1.0)
uniform_notifier (1.11.0)
url (0.3.2)
useragent (0.16.10)
valid_url (0.0.4)
@ -344,6 +348,7 @@ PLATFORMS
DEPENDENCIES
active_model_serializers
bullet
bunny
capybara
codecov
@ -351,7 +356,7 @@ DEPENDENCIES
delayed_job
delayed_job_active_record
devise
discard (~> 1.0)
discard
factory_bot_rails
faker
figaro
@ -389,7 +394,7 @@ DEPENDENCIES
webpack-rails
RUBY VERSION
ruby 2.5.0p0
ruby 2.5.1p57
BUNDLED WITH
1.16.1

View File

@ -162,7 +162,7 @@ private
render json: {error: "Upgrade to latest FarmBot OS"}, status: 426
end
EXPECTED_VER = Gem::Version::new('5.0.0')
EXPECTED_VER = Gem::Version::new GlobalConfig.dump["MINIMUM_FBOS_VERSION"]
# Try to extract FarmBot OS version from user agent.
# If none found, return lowest allowable version + 1 "tiny" bump to prevent

View File

@ -16,16 +16,8 @@ module Api
render json: current_device.logs.where(*args_).limit(limit)
end
# This is one of the "oddball" endpoints for the FarmBot API.
# It is unique because it allows batch creation of logs.
# When creating in batches, it is a "best effort" approach.
# If some logs fail to save, they will fail silently.
# As a matter of policy, not all log types are stored in the DB.
def create
case raw_json
when Array then handle_many_logs
when Hash then handle_single_log
end
mutate Logs::Create.run(raw_json, device: current_device)
end
def index
@ -46,22 +38,5 @@ private
.not(id: current_device.limited_log_list.pluck(:id))
.delete_all
end
def handle_many_logs
mutate Logs::BatchCreate.run(device: current_device, logs: raw_json)
end
def handle_single_log
outcome = Logs::Create.run(raw_json, device: current_device)
if outcome.success?
outcome.result.save!
maybe_deliver(outcome.result)
end
mutate outcome
end
def maybe_deliver(log_or_logs)
LogDispatch.delay.deliver(current_device, log_or_logs)
end
end
end

View File

@ -33,9 +33,7 @@ module Api
end
def sequences
@sequences ||= Sequence
.includes(:primary_nodes, :edge_nodes)
.where(device: current_device)
@sequences ||= Sequence.with_usage_reports.where(device: current_device)
end
def sequence

View File

@ -44,7 +44,9 @@ module Api
# Every time a token is created, sweep the old TokenIssuances out of the
# database.
def clean_out_old_tokens
TokenIssuance.where("exp < ?", Time.now.to_i).destroy_all
TokenIssuance
.where("exp < ?", Time.now.to_i)
.destroy_all
end
def if_properly_formatted

View File

@ -1,5 +1,10 @@
module Api
class ToolsController < Api::AbstractController
INDEX_QUERY = 'SELECT "tools".*, points.id as tool_slot_id FROM "tools" ' \
'INNER JOIN "points" ON "points"."tool_id" = "tools"."id" ' \
'AND "points"."pointer_type" IN (\'ToolSlot\') WHERE "tools"'\
'."device_id" = %s;'
def index
render json: tools
end
@ -29,11 +34,11 @@ private
end
def tools
Tool.includes(:tool_slot).where(device: current_device)
Tool.find_by_sql(INDEX_QUERY % current_device.id)
end
def tool
@tool ||= tools.find(params[:id])
@tool ||= Tool.where(device: current_device).find(params[:id])
end
end
end

View File

@ -1,7 +0,0 @@
class BatchLogDispatchJob < ApplicationJob
queue_as :default
def perform(device, logs)
LogDispatch.deliver(device, Log.create!(logs))
end
end

View File

@ -86,11 +86,11 @@ module CeleryScript
def misc_fields
return {
id: sequence.id,
name: sequence.name,
color: sequence.color,
created_at: sequence.created_at,
updated_at: sequence.updated_at,
args: Sequence::DEFAULT_ARGS
args: Sequence::DEFAULT_ARGS,
color: sequence.color,
name: sequence.name
}
end
@ -110,11 +110,8 @@ module CeleryScript
def execute
canonical_form = misc_fields.merge!(recurse_into_node(entry_node))
canonical_form[:in_use] = \
EdgeNode.where(kind: "sequence_id", value: sequence.id).exists? ||
RegimenItem.where(sequence_id: sequence.id).exists? ||
FarmEvent.where(executable: sequence).exists?
s = canonical_form.with_indifferent_access
s[:in_use] = sequence.in_use?
# HISTORICAL NOTE:
# When I prototyped the variables declaration stuff, a few (failed)
# iterations snuck into the DB. Gradually migrating is easier than

View File

@ -8,12 +8,20 @@ class GlobalConfig < ApplicationRecord
validates_presence_of :key
LONG_REVISION = ENV["BUILT_AT"] || ENV["HEROKU_SLUG_COMMIT"] || "NONE"
DEFAULTS = { NODE_ENV: Rails.env || "development",
TOS_URL: ENV.fetch("TOS_URL", ""),
PRIV_URL: ENV.fetch("PRIV_URL", ""),
LONG_REVISION: LONG_REVISION,
SHORT_REVISION: LONG_REVISION.first(8),
FBOS_END_OF_LIFE_VERSION: "0.0.0" }
# Bootstrap initial defaults:
{
"NODE_ENV" => Rails.env || "development",
"TOS_URL" => ENV.fetch("TOS_URL", ""),
"PRIV_URL" => ENV.fetch("PRIV_URL", ""),
"LONG_REVISION" => LONG_REVISION,
"SHORT_REVISION" => LONG_REVISION.first(8),
"FBOS_END_OF_LIFE_VERSION" => "0.0.0",
"MINIMUM_FBOS_VERSION" => "6.0.0"
}.map do |(key, value)|
self.find_or_create_by(key: key) do |conf|
conf.assign_attributes(key: key, value: value)
end
end
# Memoized version of every GlobalConfig, with key/values layed out in a hash.
# Database values prempt values set in ::DEFAULTS
@ -22,11 +30,8 @@ class GlobalConfig < ApplicationRecord
end
def self.reload_
config_hash = GlobalConfig
.all
.map(&:reload)
.map{ |x| {x.key => x.value} }
.reduce({}, :merge)
@dump = DEFAULTS.merge(config_hash)
@dump = {}
GlobalConfig.all.map { |x| @dump[x.key] = x.value }
@dump
end
end

View File

@ -10,7 +10,7 @@ class Log < ApplicationRecord
# pagination, but could later on.
PAGE_SIZE = 25
DISCARD = ["fun", "debug"]
DISCARD = ["fun", "debug"]
# self.meta[:type] is used by the bot and the frontend as a sort of
TYPES = CeleryScriptSettingsBag::ALLOWED_MESSAGE_TYPES
# The means by which the message will be sent. Ex: frontend toast notification

View File

@ -8,25 +8,20 @@ class LogDispatch < ApplicationRecord
self.max_per_hour = 20
# If this method grows, create a mutation.
def self.deliver(device, log_or_logs)
list = Array.wrap(log_or_logs)
send_routine_emails(list, device)
send_fatal_emails(list, device)
def self.deliver(device, log)
send_routine_emails(log, device)
send_fatal_emails(log, device)
end
def self.send_routine_emails(log_array, device)
log_array
.select { |log | (log["channels"] || []).include?("email") }
.map { |log | { device: device, log: log }}
.tap { |logs| self.create!(logs) }
def self.send_routine_emails(log, device)
return unless (log["channels"] || []).include?("email")
self.create!(device: device, log: log)
LogDeliveryMailer.log_digest(device).deliver_later
end
def self.send_fatal_emails(log_array, device)
log_array
.select { |log| (log["channels"] || []).include?("fatal_email") }
.map { |log| FatalErrorMailer.fatal_error(device, log).deliver_later }
def self.send_fatal_emails(log, device)
return unless (log["channels"] || []).include?("fatal_email")
FatalErrorMailer.fatal_error(device, log).deliver_later
end
def broadcast?

View File

@ -16,10 +16,11 @@ class Sequence < ApplicationRecord
include CeleryScriptSettingsBag
belongs_to :device
has_many :farm_events, as: :executable
has_many :regimen_items
has_many :primary_nodes, dependent: :destroy
has_many :edge_nodes, dependent: :destroy
has_one :sequence_usage_report
has_many :farm_events, as: :executable
has_many :regimen_items
has_many :primary_nodes, dependent: :destroy
has_many :edge_nodes, dependent: :destroy
# allowable label colors for the frontend.
[ :name, :kind ].each { |n| validates n, presence: true }
@ -62,6 +63,20 @@ class Sequence < ApplicationRecord
false unless destroyed?
end
# Determines if the current sequence is used by any farmevents, regimens or
# sequences.
def in_use?
[sequence_usage_report.edge_node_count,
sequence_usage_report.farm_event_count,
sequence_usage_report.regimen_items_count].max != 0
end
# Eagerly load edge_node, primary_node and usage_report. This is a big deal
# for performance when serializing sequences.
def self.with_usage_reports
self.includes(:sequence_usage_report, :edge_nodes, :primary_nodes)
end
# THIS IS AN OVERRIDE - Special serialization required for auto sync.
# When a sequence is created, we save it to the database to create a primary
# key, then we iterate over `EdgeNode` and `PrimaryNode`s, assigning that

View File

@ -0,0 +1,7 @@
# THIS IS A SQL VIEW. IT IS NOT A REAL TABLE.
# Tracks how many resources are using the sequence (if any)
class SequenceUsageReport < ApplicationRecord
def readonly?
true
end
end

View File

@ -5,7 +5,7 @@ class Tool < ApplicationRecord
belongs_to :device
has_one :tool_slot
validates :device, presence: true
validates :name, uniqueness: { scope: :device }
validates :name, uniqueness: { scope: :device }
IN_USE = "Tool in use by the following sequences: %s"
end

View File

@ -1,42 +0,0 @@
module Logs
class BatchCreate < Mutations::Command
required do
model :device, class: Device
array :logs do
hash do
string :message
optional do
array :channels,
class: String,
in: CeleryScriptSettingsBag::ALLOWED_CHANNEL_NAMES
hash :meta do
string :type, in: Log::TYPES
optional do
integer :x
integer :y
integer :z
end
end
end
end
end
end
def execute
BatchLogDispatchJob.perform_later(device, clean_logs)
return clean_logs
end
private
def clean_logs
@clean_logs ||= logs
.last(device.max_log_count)
.map { |i| Logs::Create.run(i, device: device) }
.select { |i| i.success? } # <= Ignore rejects
.map { |i| i.result }
.reject { |i| Log::DISCARD.include?(i.type) } # Discard jokes
.map { |i| i.as_json.except("id") }
end
end
end

View File

@ -51,6 +51,7 @@ module Logs
end
def validate
add_error :log, :private, BAD_WORDS if has_bad_words
@log = Log.new
@log.device = device
@log.message = message
@ -63,15 +64,19 @@ module Logs
@log.minor_version = transitional_field(:minor_version)
@log.type = transitional_field(:type)
@log.validate!
add_error :log, :private, BAD_WORDS if has_bad_words
end
def execute
@log.save! && maybe_deliver
@log
end
private
def maybe_deliver
LogDispatch.delay.deliver(device, @log)
end
def has_bad_words
!!inputs[:message].upcase.match(BLACKLIST)
end

View File

@ -13,7 +13,6 @@ module Points
end
def validate
puts "Convert this to use InUseTool after STI refactor."
nope! if still_in_use?
end
@ -34,21 +33,11 @@ module Points
end
def nope!
add_error :in_use, :in_use, (IN_USE % [names])
end
def current_tool_id
point.tool_id
add_error :in_use, :in_use, (IN_USE % [deps.join(", ")])
end
def deps
@deps ||= Sequence
.where(id: EdgeNode.where(kind: "tool_id", value: current_tool_id)
.pluck(:sequence_id))
end
def names
@names ||= deps.pluck(:name).join(", ")
@deps ||= InUseTool.where(tool_id: point.tool_id).pluck(:sequence_name)
end
end
end

View File

@ -2,7 +2,8 @@ class ToolSerializer < ActiveModel::Serializer
attributes :id, :name, :status
def status
active = ToolSlot.where(tool_id: object.id).any?
active ? "active" : "inactive"
# The attribute `tool_slot_id` is added via a special SQL query.
# SEE: ToolsController::INDEX_QUERY
object[:tool_slot_id] ? "active" : "inactive"
end
end

View File

@ -8,6 +8,10 @@ require "rails/all"
Bundler.require(:default, Rails.env)
module FarmBot
class Application < Rails::Application
config.after_initialize do
Bullet.enable = true
Bullet.console = true
end
config.active_job.queue_adapter = :delayed_job
config.action_dispatch.perform_deep_munge = false
I18n.enforce_available_locales = false

View File

@ -11,7 +11,11 @@ FarmBot::Application.configure do
config.log_formatter = ::Logger::Formatter.new
config.log_level = :info
config.public_file_server.enabled = false
config.after_initialize do
Bullet.enable = true
Bullet.console = true
Bullet.rollbar = true if ENV["ROLLBAR_ACCESS_TOKEN"]
end
# HACK AHEAD! Here's why:
# 1. FarmBot Inc. Uses Sendgrid for email.
# 2. FarmBot is an open source project that must be vendor neutral.

View File

@ -1,4 +1,9 @@
FarmBot::Application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.console = true
Bullet.raise = true
end
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that

View File

@ -1,7 +1,6 @@
class Rack::Attack
### Throttle Spammy Clients ###
# Throttle all requests by IP 100 req/min
throttle('req/ip', limit: 500, period: 5.minutes) do |req|
throttle('req/ip', limit: 100, period: 1.minutes) do |req|
req.ip
end

View File

@ -0,0 +1,13 @@
class AddMissingIndexes < ActiveRecord::Migration[5.1]
def change
add_index :edge_nodes, [:kind, :value]
add_index :farm_events, :end_time
add_index :global_configs, :key
add_index :logs, :created_at
add_index :logs, :type
add_index :peripherals, :mode
add_index :token_issuances, [:jti, :device_id]
add_index :token_issuances, :exp
add_index :users, :confirmation_token
end
end

View File

@ -0,0 +1,6 @@
class AddMoreMissingIndexes < ActiveRecord::Migration[5.1]
def change
add_index :logs, :verbosity
change_column :logs, :verbosity, :integer, default: 1
end
end

View File

@ -0,0 +1,8 @@
class AddXySwapToWebAppConfigs < ActiveRecord::Migration[5.1]
def change
add_column :web_app_configs,
:xy_swap,
:boolean,
default: false
end
end

View File

@ -0,0 +1,5 @@
class CreateSequenceUsageReports < ActiveRecord::Migration[5.1]
def change
create_view :sequence_usage_reports
end
end

View File

@ -0,0 +1,5 @@
class RetroactivelySetDefaultVerbosity < ActiveRecord::Migration[5.1]
def change
Log.where(verbosity: nil).update_all(verbosity: 1)
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180411175813) do
ActiveRecord::Schema.define(version: 20180413145332) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -58,6 +58,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.bigint "primary_node_id", null: false
t.string "kind", limit: 50
t.string "value", limit: 300
t.index ["kind", "value"], name: "index_edge_nodes_on_kind_and_value"
t.index ["primary_node_id"], name: "index_edge_nodes_on_primary_node_id"
t.index ["sequence_id"], name: "index_edge_nodes_on_sequence_id"
end
@ -71,6 +72,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.string "executable_type", limit: 280
t.integer "executable_id"
t.index ["device_id"], name: "index_farm_events_on_device_id"
t.index ["end_time"], name: "index_farm_events_on_end_time"
t.index ["executable_type", "executable_id"], name: "index_farm_events_on_executable_type_and_executable_id"
end
@ -204,6 +206,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.text "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["key"], name: "index_global_configs_on_key"
end
create_table "images", id: :serial, force: :cascade do |t|
@ -259,11 +262,14 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.string "type", limit: 10, default: "info"
t.integer "major_version"
t.integer "minor_version"
t.integer "verbosity"
t.integer "verbosity", default: 1
t.integer "x"
t.integer "y"
t.integer "z"
t.index ["created_at"], name: "index_logs_on_created_at"
t.index ["device_id"], name: "index_logs_on_device_id"
t.index ["type"], name: "index_logs_on_type"
t.index ["verbosity"], name: "index_logs_on_verbosity"
end
create_table "peripherals", id: :serial, force: :cascade do |t|
@ -274,6 +280,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.datetime "updated_at", null: false
t.integer "mode", default: 0
t.index ["device_id"], name: "index_peripherals_on_device_id"
t.index ["mode"], name: "index_peripherals_on_mode"
end
create_table "pin_bindings", force: :cascade do |t|
@ -388,6 +395,8 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["device_id"], name: "index_token_issuances_on_device_id"
t.index ["exp"], name: "index_token_issuances_on_exp"
t.index ["jti", "device_id"], name: "index_token_issuances_on_jti_and_device_id"
end
create_table "tools", id: :serial, force: :cascade do |t|
@ -416,6 +425,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email"
t.index ["agreed_to_terms_at"], name: "index_users_on_agreed_to_terms_at"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token"
t.index ["device_id"], name: "index_users_on_device_id"
t.index ["email"], name: "index_users_on_email", unique: true
end
@ -458,6 +468,7 @@ ActiveRecord::Schema.define(version: 20180411175813) do
t.string "photo_filter_begin"
t.string "photo_filter_end"
t.boolean "discard_unsaved", default: false
t.boolean "xy_swap", default: false
t.index ["device_id"], name: "index_web_app_configs_on_device_id"
end
@ -513,4 +524,18 @@ ActiveRecord::Schema.define(version: 20180411175813) do
WHERE ((edge_nodes.kind)::text = 'tool_id'::text);
SQL
create_view "sequence_usage_reports", sql_definition: <<-SQL
SELECT sequences.id AS sequence_id,
( SELECT count(*) AS count
FROM edge_nodes
WHERE (((edge_nodes.kind)::text = 'sequence_id'::text) AND ((edge_nodes.value)::integer = sequences.id))) AS edge_node_count,
( SELECT count(*) AS count
FROM farm_events
WHERE ((farm_events.executable_id = sequences.id) AND ((farm_events.executable_type)::text = 'Sequence'::text))) AS farm_event_count,
( SELECT count(*) AS count
FROM regimen_items
WHERE (regimen_items.sequence_id = sequences.id)) AS regimen_items_count
FROM sequences;
SQL
end

View File

@ -0,0 +1,15 @@
SELECT
sequences.id as sequence_id,
(SELECT COUNT(*)
FROM edge_nodes
WHERE edge_nodes.kind = 'sequence_id'
AND (edge_nodes.value)::int = sequences.id) AS edge_node_count,
(SELECT COUNT(*)
FROM farm_events
WHERE farm_events.executable_id = sequences.id
AND farm_events.executable_type = 'Sequence') AS farm_event_count,
(SELECT COUNT(*)
FROM regimen_items
WHERE regimen_items.sequence_id = sequences.id) AS regimen_items_count
FROM
sequences;

View File

@ -35,7 +35,6 @@ class LogService
if save?(log)
device = Device.find(device_id)
db_log = Logs::Create.run!(log, device: device)
db_log.save!
maybe_clear_logs(device)
LogDispatch.deliver(device, db_log)
end

View File

@ -8,6 +8,7 @@ describe Api::GlobalConfigController do
value: "INITIAL_" + SecureRandom.hex)
it 'shows configs' do
GlobalConfig.reload_
get :show
expect(json[:PING]).to eq(GlobalConfig.find_by(key: "PING").value)
end

View File

@ -1,121 +0,0 @@
[{
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "warn"
},
"message": "FIXME failed to install first party farmwares: %RuntimeError{message: \"Could not sync Elixir.Farmbot.Farmware.Installer.Repository.Farmbot is already synced up!\"}",
"created_at": 1495820256,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME is installing first party Farmwares.",
"created_at": 1495820255,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME ntp: :ok",
"created_at": 1495820255,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "warn"
},
"message": "No serial handler yet, waiting...",
"created_at": 23,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME trying to set time (try 0)",
"created_at": 22,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME is getting time from NTP.",
"created_at": 22,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME starting ntp client.",
"created_at": 22,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "success"
},
"message": "FIXME connection test complete",
"created_at": 22,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "busy"
},
"message": "FIXME doing connection test...",
"created_at": 21,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "warn"
},
"message": "No serial handler yet, waiting...",
"created_at": 18,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME is waiting for linux and network and what not.",
"created_at": 16,
"channels": []
}, {
"meta": {
"z": 0,
"y": 0,
"x": 0,
"type": "info"
},
"message": "FIXME Configurator init!",
"created_at": 15,
"channels": []
}]

View File

@ -1,5 +1,4 @@
require 'spec_helper'
JSON_EXAMPLE = File.read("spec/controllers/api/logs/connor_fixture.json")
describe Api::LogsController do
include Devise::Test::ControllerHelpers
@ -81,76 +80,13 @@ describe Api::LogsController do
expect(Log.count).to eq(0)
end
it 'creates many logs (with an Array)' do
sign_in user
before_count = Log.count
run_jobs_now do
post :create,
body: [
{ meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["toast"],
message: "one" },
{ meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["toast"],
message: "two" },
{ meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["toast"],
message: "three" },
].to_json,
params: {format: :json}
end
expect(response.status).to eq(200)
expect(before_count + 3).to eq(Log.count)
end
it 'does not bother saving `fun` or `debug` logs' do
sign_in user
Log.destroy_all
LogDispatch.destroy_all
before_count = Log.count
dispatch_before = LogDispatch.count
run_jobs_now do
post :create,
body: [
{ meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["toast"],
message: "one" },
{ meta: { x: 1, y: 2, z: 3, type: "fun" }, # Ignored
channels: [],
message: "two" },
{ meta: { x: 1, y: 2, z: 3, type: "debug" }, # Ignored
channels: [],
message: "two" },
{ meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["email"],
message: "three" },
].to_json,
params: {format: :json}
expect(response.status).to eq(200)
expect(Log.count).to eq(before_count + 2)
expect(LogDispatch.count).to eq(dispatch_before + 1)
end
end
it 'Runs compaction when the logs pile up' do
payl = []
100.times do
payl.push({ meta: { x: 1,
y: 2,
z: 3,
type: "info"
},
channels: ["toast"],
message: "one" })
end
LogDispatch.destroy_all
Log.destroy_all
100.times { Log.create!(device: user.device) }
sign_in user
user.device.update_attributes!(max_log_count: 15)
LogDispatch.destroy_all
Log.destroy_all
before_count = Log.count
run_jobs_now do
post :create, body: payl.to_json, params: {format: :json}
end
get :index, params: {format: :json}
expect(response.status).to eq(200)
expect(json.length).to eq(user.device.max_log_count)
end
@ -198,23 +134,10 @@ describe Api::LogsController do
expect(last_email.to).to include(user.email)
end
end
it "handles bug that Connor reported" do
sign_in user
empty_mail_bag
Log.destroy_all
LogDispatch.destroy_all
run_jobs_now do
post :create,
body: JSON_EXAMPLE,
params: {format: :json}
expect(last_email).to eq(nil)
end
end
end
describe "#search" do
examples = [
EXAMPLES = [
[1, "success"],
[1, "busy"],
[1, "warn"],
@ -244,7 +167,7 @@ describe Api::LogsController do
sign_in user
Log.destroy_all
conf = user.device.web_app_config
examples.map do |(verbosity, type)|
EXAMPLES.map do |(verbosity, type)|
FactoryBot.create(:log, device: user.device,
verbosity: verbosity,
type: type)
@ -258,14 +181,18 @@ describe Api::LogsController do
debug_log: 3)
get :search
expect(response.status).to eq(200)
expect(json.length).to eq(examples.length)
expect(json.length).to eq(EXAMPLES.length)
end
it 'sends emails'
it 'sends fatal_emails'
it 'filters NO logs based on log filtering settings in `WebAppConfig` ' do
sign_in user
Log.destroy_all
conf = user.device.web_app_config
examples.map do |(verbosity, type)|
EXAMPLES.map do |(verbosity, type)|
FactoryBot.create(:log, device: user.device,
verbosity: verbosity,
type: type)

View File

@ -136,7 +136,7 @@ describe Api::PointsController do
it "marks device as seen when they download points" do
old_last_saw_api = user.device.last_saw_api
ua = "FarmbotOS/5.0.2 (host) host ()"
ua = "FarmbotOS/6.0.2 (host) host ()"
allow(request).to receive(:user_agent).and_return(ua)
request.env["HTTP_USER_AGENT"] = ua
request.headers["Authorization"] = "bearer #{auth_token}"

View File

@ -0,0 +1,7 @@
require "spec_helper"
describe(SequenceUsageReport) do
it "is readonly" do
expect(SequenceUsageReport.new.readonly?).to be(true)
end
end

View File

@ -15,9 +15,9 @@ sudo docker run hello-world # Should run!
command curl -sSL https://rvm.io/mpapis.asc | gpg --import -
curl -sSL https://get.rvm.io | bash
source /usr/local/rvm/scripts/rvm
rvm install "ruby-2.5.0"
rvm install "ruby-2.5.1"
cd .
rvm --default use 2.5.0
rvm --default use 2.5.1
# LOG OUT AND LOG BACK IN NOW.
# Image Magick

View File

@ -210,7 +210,8 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
enable_browser_speak: false,
photo_filter_begin: "2018-01-11T20:20:38.362Z",
photo_filter_end: "2018-01-22T15:32:41.970Z",
discard_unsaved: false
discard_unsaved: false,
xy_swap: false,
});
}

View File

@ -0,0 +1,9 @@
import { MapTransformProps } from "../farm_designer/map/interfaces";
export const fakeMapTransformProps = (): MapTransformProps => {
return {
quadrant: 2,
gridSize: { x: 3000, y: 1500 },
xySwap: false,
};
};

View File

@ -17,21 +17,22 @@ import { mount } from "enzyme";
import { bot } from "../__test_support__/fake_state/bot";
import { fakeUser } from "../__test_support__/fake_state/resources";
describe("<App />: Controls Pop-Up", () => {
function fakeProps(): AppProps {
return {
timeOffset: 0, // Default to UTC
dispatch: jest.fn(),
loaded: [],
logs: [],
user: fakeUser(),
bot: bot,
consistent: true,
axisInversion: { x: false, y: false, z: false },
firmwareConfig: undefined,
};
}
const fakeProps = (): AppProps => {
return {
timeOffset: 0, // Default to UTC
dispatch: jest.fn(),
loaded: [],
logs: [],
user: fakeUser(),
bot: bot,
consistent: true,
axisInversion: { x: false, y: false, z: false },
firmwareConfig: undefined,
xySwap: false,
};
};
describe("<App />: Controls Pop-Up", () => {
function controlsPopUp(page: string, exists: boolean) {
it(`doesn't render controls pop-up on ${page} page`, () => {
mockPath = "/app/" + page;
@ -58,21 +59,6 @@ describe("<App />: Controls Pop-Up", () => {
});
describe("<App />: Loading", () => {
function fakeProps(): AppProps {
const p: AppProps = {
dispatch: jest.fn(),
loaded: [],
logs: [],
user: fakeUser(),
bot: bot,
consistent: true,
timeOffset: 0,
axisInversion: { x: false, y: false, z: false },
firmwareConfig: undefined,
};
return p;
}
it("MUST_LOADs not loaded", () => {
const wrapper = mount(<App {...fakeProps()} />);
expect(wrapper.text()).toContain("Loading...");
@ -94,20 +80,6 @@ describe("<App />: Loading", () => {
});
describe("<App />: NavBar", () => {
function fakeProps(): AppProps {
return {
dispatch: jest.fn(),
loaded: [],
logs: [],
user: fakeUser(),
bot: bot,
consistent: true,
timeOffset: 0,
axisInversion: { x: false, y: false, z: false },
firmwareConfig: undefined,
};
}
it("displays links", () => {
const wrapper = mount(<App {...fakeProps()} />);
expect(wrapper.text())

View File

@ -10,17 +10,28 @@ import * as React from "react";
import { ControlsPopup } from "../controls_popup";
import { mount } from "enzyme";
import { bot } from "../__test_support__/fake_state/bot";
import { ControlsPopupProps } from "../controls/interfaces";
describe("<ControlsPopup />", () => {
beforeEach(function () {
jest.clearAllMocks();
});
const wrapper = mount(<ControlsPopup
dispatch={jest.fn()}
axisInversion={{ x: true, y: false, z: false }}
botPosition={{ x: undefined, y: undefined, z: undefined }}
mcuParams={bot.hardware.mcu_params} />);
const fakeProps = (): ControlsPopupProps => {
return {
dispatch: jest.fn(),
axisInversion: { x: false, y: false, z: false },
botPosition: { x: undefined, y: undefined, z: undefined },
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
arduinoBusy: false,
stepSize: 100,
};
};
const p = fakeProps();
p.axisInversion.x = true;
const wrapper = mount(<ControlsPopup {...p} />);
it("Has a false initial state", () => {
expect(wrapper.state("isOpen")).toBeFalsy();
@ -53,4 +64,17 @@ describe("<ControlsPopup />", () => {
[0, 1, 2, 3].map((i) => wrapper.find("button").at(i).simulate("click"));
expect(mockDevice.moveRelative).not.toHaveBeenCalled();
});
it("swaps axes", () => {
const swappedProps = fakeProps();
swappedProps.xySwap = true;
const swapped = mount(<ControlsPopup {...swappedProps} />);
swapped.setState({ isOpen: true });
expect(swapped.state("isOpen")).toBeTruthy();
const button = swapped.find("button").at(1);
expect(button.props().title).toBe("move x axis (100)");
button.simulate("click");
expect(mockDevice.moveRelative)
.toHaveBeenCalledWith({ speed: 100, x: 100, y: 0, z: 0 });
});
});

View File

@ -23,6 +23,7 @@ import { Session } from "./session";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import { FirmwareConfig } from "./config_storage/firmware_configs";
import { getWebAppConfigValue } from "./config_storage/actions";
/** Remove 300ms delay on touch devices - https://github.com/ftlabs/fastclick */
const fastClick = require("fastclick");
@ -40,6 +41,7 @@ export interface AppProps {
consistent: boolean;
timeOffset: number;
axisInversion: Record<Xyz, boolean>;
xySwap: boolean;
firmwareConfig: FirmwareConfig | undefined;
}
@ -62,6 +64,7 @@ function mapStateToProps(props: Everything): AppProps {
y: !!Session.deprecatedGetBool(BooleanSetting.y_axis_inverted),
z: !!Session.deprecatedGetBool(BooleanSetting.z_axis_inverted),
},
xySwap: !!getWebAppConfigValue(() => props)(BooleanSetting.xy_swap),
firmwareConfig: validFwConfig(getFirmwareConfig(props.resources.index))
};
}
@ -118,7 +121,10 @@ export class App extends React.Component<AppProps, {}> {
dispatch={this.props.dispatch}
axisInversion={this.props.axisInversion}
botPosition={validBotLocationData(location_data).position}
mcuParams={this.props.firmwareConfig || mcu_params} />}
firmwareSettings={this.props.firmwareConfig || mcu_params}
xySwap={this.props.xySwap}
arduinoBusy={!!this.props.bot.hardware.informational_settings.busy}
stepSize={this.props.bot.stepSize} />}
</div>;
}
}

View File

@ -44,6 +44,7 @@ export interface WebAppConfig {
photo_filter_begin: string;
photo_filter_end: string;
discard_unsaved: boolean;
xy_swap: boolean;
}
export type NumberConfigKey = "id"
@ -85,4 +86,5 @@ export type BooleanConfigKey = "confirm_step_deletion"
|"show_first_party_farmware"
|"enable_browser_speak"
|"show_images"
|"discard_unsaved";
|"discard_unsaved"
|"xy_swap";

View File

@ -38,6 +38,7 @@ describe("<Controls />", () => {
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
shouldDisplay: () => true,
xySwap: false,
};
}

View File

@ -1,6 +1,7 @@
const mockDevice = {
home: jest.fn(() => { return Promise.resolve(); }),
takePhoto: jest.fn(() => { return Promise.resolve(); }),
moveRelative: jest.fn(() => { return Promise.resolve(); }),
};
jest.mock("../../device", () => ({
@ -18,40 +19,64 @@ import { bot } from "../../__test_support__/fake_state/bot";
describe("<JogButtons/>", function () {
beforeEach(function () {
jest.clearAllMocks();
jogButtonProps.disabled = false;
});
const jogButtonProps: JogMovementControlsProps = {
bot: bot,
x_axis_inverted: false,
y_axis_inverted: false,
z_axis_inverted: false,
disabled: false,
firmwareSettings: bot.hardware.mcu_params
const jogButtonProps = (): JogMovementControlsProps => {
return {
stepSize: 100,
botPosition: { x: undefined, y: undefined, z: undefined },
axisInversion: { x: false, y: false, z: false },
arduinoBusy: false,
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
};
};
it("calls home command", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps} />);
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
jogButtons.find("button").at(3).simulate("click");
expect(mockDevice.home).toHaveBeenCalledTimes(1);
});
it("is disabled", () => {
jogButtonProps.disabled = true;
const jogButtons = mount(<JogButtons {...jogButtonProps} />);
const p = jogButtonProps();
p.arduinoBusy = true;
const jogButtons = mount(<JogButtons {...p} />);
jogButtons.find("button").at(3).simulate("click");
expect(mockDevice.home).not.toHaveBeenCalled();
});
it("call has correct args", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps} />);
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
jogButtons.find("button").at(3).simulate("click");
expect(mockDevice.home)
.toHaveBeenCalledWith({ axis: "all", speed: 100 });
});
it("takes photo", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps} />);
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
jogButtons.find("button").at(0).simulate("click");
expect(mockDevice.takePhoto).toHaveBeenCalled();
});
it("has unswapped xy jog buttons", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
const button = jogButtons.find("button").at(6);
expect(button.props().title).toBe("move x axis (100)");
button.simulate("click");
expect(mockDevice.moveRelative)
.toHaveBeenCalledWith({ speed: 100, x: 100, y: 0, z: 0 });
});
it("has swapped xy jog buttons", () => {
const p = jogButtonProps();
(p.stepSize as number | undefined) = undefined;
p.xySwap = true;
const jogButtons = mount(<JogButtons {...p} />);
const button = jogButtons.find("button").at(6);
expect(button.props().title).toBe("move y axis (100)");
button.simulate("click");
expect(mockDevice.moveRelative)
.toHaveBeenCalledWith({ speed: 100, x: 0, y: 100, z: 0 });
});
});

View File

@ -7,12 +7,19 @@ jest.mock("../../session", () => {
};
});
jest.mock("../../config_storage/actions", () => {
return {
toggleWebAppBool: jest.fn()
};
});
import * as React from "react";
import { mount } from "enzyme";
import { Move } from "../move";
import { bot } from "../../__test_support__/fake_state/bot";
import { MoveProps } from "../interfaces";
import { Session } from "../../session";
import { toggleWebAppBool } from "../../config_storage/actions";
describe("<Move />", () => {
beforeEach(function () {
@ -24,7 +31,7 @@ describe("<Move />", () => {
dispatch: jest.fn(),
bot: bot,
user: undefined,
disabled: false,
arduinoBusy: false,
raw_encoders: false,
scaled_encoders: false,
x_axis_inverted: false,
@ -32,6 +39,7 @@ describe("<Move />", () => {
z_axis_inverted: false,
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
};
}
@ -62,8 +70,7 @@ describe("<Move />", () => {
});
it("toggle: invert jog button", () => {
const p = fakeProps();
const wrapper = mount(<Move {...p} />);
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle("x")();
@ -71,11 +78,18 @@ describe("<Move />", () => {
});
it("toggle: encoder data display", () => {
const p = fakeProps();
const wrapper = mount(<Move {...p} />);
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle_encoder_data("raw_encoders")();
expect(Session.invertBool).toHaveBeenCalledWith("raw_encoders");
});
it("toggle: xy swap", () => {
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle_xy_swap();
expect(toggleWebAppBool).toHaveBeenCalledWith("xy_swap");
});
});

View File

@ -24,7 +24,7 @@ export class Controls extends React.Component<Props, {}> {
bot: this.props.bot,
user: this.props.user,
dispatch: this.props.dispatch,
disabled: arduinoBusy,
arduinoBusy,
raw_encoders: !!Session.deprecatedGetBool(BooleanSetting.raw_encoders),
scaled_encoders: !!Session.deprecatedGetBool(BooleanSetting.scaled_encoders),
x_axis_inverted: !!Session.deprecatedGetBool(BooleanSetting.x_axis_inverted),
@ -32,6 +32,7 @@ export class Controls extends React.Component<Props, {}> {
z_axis_inverted: !!Session.deprecatedGetBool(BooleanSetting.z_axis_inverted),
botToMqttStatus: this.props.botToMqttStatus,
firmwareSettings: this.props.firmwareSettings,
xySwap: this.props.xySwap,
};
const showWebcamWidget = !Session.deprecatedGetBool(BooleanSetting.hide_webcam_widget);
return <Page className="controls">

View File

@ -1,11 +1,11 @@
import { validBotLocationData } from "../util";
import { JogMovementControlsProps } from "./interfaces";
import { DirectionAxesProps } from "./interfaces";
import { McuParams } from "farmbot";
const _ = (nr_steps: number | undefined, steps_mm: number | undefined) => {
return (nr_steps || 0) / (steps_mm || 1);
};
function calculateAxialLengths(props: JogMovementControlsProps) {
function calculateAxialLengths(props: { firmwareSettings: McuParams }) {
const fwParams = props.firmwareSettings;
return {
@ -15,35 +15,33 @@ function calculateAxialLengths(props: JogMovementControlsProps) {
};
}
export function buildDirectionProps(props: JogMovementControlsProps) {
const { firmwareSettings } = props;
const { location_data } = props.bot.hardware;
const botLocationData = validBotLocationData(location_data);
export function buildDirectionProps(props: DirectionAxesProps) {
const { firmwareSettings, botPosition } = props;
const lengths = calculateAxialLengths(props);
return {
x: {
isInverted: props.x_axis_inverted,
isInverted: props.axisInversion.x,
stopAtHome: !!firmwareSettings.movement_stop_at_home_x,
stopAtMax: !!firmwareSettings.movement_stop_at_max_x,
axisLength: lengths.x,
negativeOnly: !!firmwareSettings.movement_home_up_x,
position: botLocationData.position.x
position: botPosition.x
},
y: {
isInverted: props.y_axis_inverted,
isInverted: props.axisInversion.y,
stopAtHome: !!firmwareSettings.movement_stop_at_home_y,
stopAtMax: !!firmwareSettings.movement_stop_at_max_y,
axisLength: lengths.y,
negativeOnly: !!firmwareSettings.movement_home_up_y,
position: botLocationData.position.y
position: botPosition.y
},
z: {
isInverted: props.z_axis_inverted,
isInverted: props.axisInversion.z,
stopAtHome: !!firmwareSettings.movement_stop_at_home_z,
stopAtMax: !!firmwareSettings.movement_stop_at_max_z,
axisLength: lengths.z,
negativeOnly: !!firmwareSettings.movement_home_up_z,
position: botLocationData.position.z
position: botPosition.z
},
};
}

View File

@ -18,13 +18,14 @@ export interface Props {
botToMqttStatus: NetworkState;
firmwareSettings: McuParams;
shouldDisplay: ShouldDisplay;
xySwap: boolean;
}
export interface MoveProps {
dispatch: Function;
bot: BotState;
user: TaggedUser | undefined;
disabled: boolean | undefined;
arduinoBusy: boolean;
raw_encoders: boolean;
scaled_encoders: boolean;
x_axis_inverted: boolean;
@ -32,6 +33,7 @@ export interface MoveProps {
z_axis_inverted: boolean;
botToMqttStatus: NetworkState;
firmwareSettings: McuParams;
xySwap: boolean;
}
export interface DirectionButtonProps {
@ -93,13 +95,20 @@ export interface StepSizeSelectorProps {
selector: (num: number) => void;
}
export interface JogMovementControlsProps {
x_axis_inverted: boolean;
y_axis_inverted: boolean;
z_axis_inverted: boolean;
bot: BotState;
export interface DirectionAxesProps {
axisInversion: Record<Xyz, boolean>;
botPosition: BotPosition;
firmwareSettings: McuParams;
disabled: boolean | undefined;
}
export interface JogMovementControlsProps extends DirectionAxesProps {
stepSize: number;
arduinoBusy: boolean;
xySwap: boolean;
}
export interface ControlsPopupProps extends JogMovementControlsProps {
dispatch: Function;
}
export interface ToggleButtonProps {

View File

@ -5,9 +5,15 @@ import { JogMovementControlsProps } from "./interfaces";
import { getDevice } from "../device";
import { buildDirectionProps } from "./direction_axes_props";
const DEFAULT_STEP_SIZE = 100;
export function JogButtons(props: JogMovementControlsProps) {
const { stepSize, xySwap, arduinoBusy } = props;
const directionAxesProps = buildDirectionProps(props);
return <table className="jog-table" style={{ border: 0 }}>
const rightLeft = xySwap ? "y" : "x";
const upDown = xySwap ? "x" : "y";
return <table className="jog-table">
<tbody>
<tr>
<td>
@ -19,11 +25,11 @@ export function JogButtons(props: JogMovementControlsProps) {
<td />
<td>
<DirectionButton
axis="y"
axis={upDown}
direction="up"
directionAxisProps={directionAxesProps.y}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
<td />
<td />
@ -32,8 +38,8 @@ export function JogButtons(props: JogMovementControlsProps) {
axis="z"
direction="up"
directionAxisProps={directionAxesProps.z}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
</tr>
<tr>
@ -41,32 +47,32 @@ export function JogButtons(props: JogMovementControlsProps) {
<button
className="i fa fa-home arrow-button fb-button"
onClick={() => homeAll(100)}
disabled={props.disabled || false} />
disabled={arduinoBusy || false} />
</td>
<td />
<td>
<DirectionButton
axis="x"
axis={rightLeft}
direction="left"
directionAxisProps={directionAxesProps.x}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
<td>
<DirectionButton
axis="y"
axis={upDown}
direction="down"
directionAxisProps={directionAxesProps.y}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
<td>
<DirectionButton
axis="x"
axis={rightLeft}
direction="right"
directionAxisProps={directionAxesProps.x}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
<td />
<td>
@ -74,8 +80,8 @@ export function JogButtons(props: JogMovementControlsProps) {
axis="z"
direction="down"
directionAxisProps={directionAxesProps.z}
steps={props.bot.stepSize || 1000}
disabled={props.disabled} />
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
</td>
</tr>
<tr>

View File

@ -15,6 +15,8 @@ import { AxisDisplayGroup } from "./axis_display_group";
import { Session } from "../session";
import { INVERSION_MAPPING, ENCODER_MAPPING } from "../devices/reducer";
import { minFwVersionCheck, validBotLocationData } from "../util";
import { toggleWebAppBool } from "../config_storage/actions";
import { BooleanSetting } from "../session_keys";
export class Move extends React.Component<MoveProps, {}> {
@ -25,11 +27,14 @@ export class Move extends React.Component<MoveProps, {}> {
toggle_encoder_data =
(name: EncoderDisplay) => () => Session.invertBool(ENCODER_MAPPING[name]);
toggle_xy_swap = () =>
this.props.dispatch(toggleWebAppBool(BooleanSetting.xy_swap));
render() {
const { location_data, informational_settings } = this.props.bot.hardware;
const { firmware_version } = informational_settings;
const { x_axis_inverted, y_axis_inverted, z_axis_inverted,
raw_encoders, scaled_encoders } = this.props;
raw_encoders, scaled_encoders, xySwap } = this.props;
const btnColor = (flag: boolean) => { return flag ? "green" : "red"; };
const xBtnColor = btnColor(x_axis_inverted);
@ -37,6 +42,7 @@ export class Move extends React.Component<MoveProps, {}> {
const zBtnColor = btnColor(z_axis_inverted);
const rawBtnColor = btnColor(raw_encoders);
const scaledBtnColor = btnColor(scaled_encoders);
const xySwapBtnColor = btnColor(xySwap);
const locationData = validBotLocationData(location_data);
const motor_coordinates = locationData.position;
@ -54,10 +60,10 @@ export class Move extends React.Component<MoveProps, {}> {
helpText={ToolTips.MOVE}>
<Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<div>
<label>
<div className="move-settings-menu">
<p>
{t("Invert Jog Buttons")}
</label>
</p>
<fieldset>
<label>
{t("X Axis")}
@ -82,9 +88,10 @@ export class Move extends React.Component<MoveProps, {}> {
className={"fb-button fb-toggle-button " + zBtnColor}
onClick={this.toggle("z")} />
</fieldset>
<label>
<p>
{t("Display Encoder Data")}
</label>
</p>
<fieldset>
<label>
{t("Scaled encoder position")}
@ -101,6 +108,18 @@ export class Move extends React.Component<MoveProps, {}> {
className={"fb-button fb-toggle-button " + rawBtnColor}
onClick={this.toggle_encoder_data("raw_encoders")} />
</fieldset>
<p>
{t("Swap jog buttons")}
</p>
<fieldset>
<label>
{t("x and y axis")}
</label>
<button
className={"fb-button fb-toggle-button " + xySwapBtnColor}
onClick={this.toggle_xy_swap} />
</fieldset>
</div>
</Popover>
<EStopButton
@ -120,12 +139,16 @@ export class Move extends React.Component<MoveProps, {}> {
selector={num => this.props.dispatch(changeStepSize(num))}
selected={this.props.bot.stepSize} />
<JogButtons
bot={this.props.bot}
x_axis_inverted={x_axis_inverted}
y_axis_inverted={y_axis_inverted}
z_axis_inverted={z_axis_inverted}
disabled={this.props.disabled}
firmwareSettings={this.props.firmwareSettings} />
stepSize={this.props.bot.stepSize}
botPosition={locationData.position}
axisInversion={{
x: x_axis_inverted,
y: y_axis_inverted,
z: z_axis_inverted
}}
arduinoBusy={this.props.arduinoBusy}
firmwareSettings={this.props.firmwareSettings}
xySwap={this.props.xySwap} />
<Row>
<Col xs={3}>
<label>{t("X AXIS")}</label>
@ -151,7 +174,7 @@ export class Move extends React.Component<MoveProps, {}> {
<AxisInputBoxGroup
position={motor_coordinates}
onCommit={input => moveAbs(input)}
disabled={this.props.disabled} />
disabled={this.props.arduinoBusy} />
</MustBeOnline>
</WidgetBody>
</Widget>;

View File

@ -12,6 +12,8 @@ import * as _ from "lodash";
import {
validFwConfig, shouldDisplay, determineInstalledOsVersion
} from "../util";
import { BooleanSetting } from "../session_keys";
import { getWebAppConfigValue } from "../config_storage/actions";
export function mapStateToProps(props: Everything): Props {
const peripherals = _.uniq(selectAllPeripherals(props.resources.index));
@ -23,6 +25,7 @@ export function mapStateToProps(props: Everything): Props {
const { mcu_params } = props.bot.hardware;
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const getWebAppConfigVal = getWebAppConfigValue(() => props);
return {
feeds: selectAllWebcamFeeds(resources.index),
@ -34,5 +37,6 @@ export function mapStateToProps(props: Everything): Props {
botToMqttStatus,
firmwareSettings: fwConfig || mcu_params,
shouldDisplay: shouldDisplay(installedOsVersion, props.bot.minOsFeatureData),
xySwap: !!getWebAppConfigVal(BooleanSetting.xy_swap),
};
}

View File

@ -1,55 +1,27 @@
import * as React from "react";
import { DirectionButton } from "./controls/direction_button";
import { Xyz, BotPosition } from "./devices/interfaces";
import { McuParams } from "farmbot";
import { getDevice } from "./device";
import { buildDirectionProps } from "./controls/direction_axes_props";
import { ControlsPopupProps } from "./controls/interfaces";
interface State {
isOpen: boolean;
stepSize: number;
}
interface Props {
dispatch: Function;
axisInversion: Record<Xyz, boolean>;
botPosition: BotPosition;
mcuParams: McuParams;
}
export class ControlsPopup extends React.Component<Props, Partial<State>> {
state: State = {
isOpen: false,
stepSize: 100
};
export class ControlsPopup
extends React.Component<ControlsPopupProps, Partial<State>> {
state: State = { isOpen: false };
private toggle = (property: keyof State) => () =>
this.setState({ [property]: !this.state[property] });
public render() {
const isOpen = this.state.isOpen ? "open" : "";
const { mcuParams } = this.props;
const directionAxesProps = {
x: {
isInverted: this.props.axisInversion.x,
stopAtHome: !!mcuParams.movement_stop_at_home_x,
stopAtMax: !!mcuParams.movement_stop_at_max_x,
axisLength: (mcuParams.movement_axis_nr_steps_x || 0)
/ (mcuParams.movement_step_per_mm_x || 1),
negativeOnly: !!mcuParams.movement_home_up_x,
position: this.props.botPosition.x
},
y: {
isInverted: this.props.axisInversion.y,
stopAtHome: !!mcuParams.movement_stop_at_home_y,
stopAtMax: !!mcuParams.movement_stop_at_max_y,
axisLength: (mcuParams.movement_axis_nr_steps_y || 0)
/ (mcuParams.movement_step_per_mm_y || 1),
negativeOnly: !!mcuParams.movement_home_up_y,
position: this.props.botPosition.y
}
};
const { stepSize, xySwap, arduinoBusy } = this.props;
const directionAxesProps = buildDirectionProps(this.props);
const rightLeft = xySwap ? "y" : "x";
const upDown = xySwap ? "x" : "y";
return <div
className={"controls-popup " + isOpen}>
<i className="fa fa-crosshairs"
@ -57,29 +29,29 @@ export class ControlsPopup extends React.Component<Props, Partial<State>> {
<div className="controls-popup-menu-outer">
<div className="controls-popup-menu-inner">
<DirectionButton
axis={"x"}
axis={rightLeft}
direction="right"
directionAxisProps={directionAxesProps.x}
steps={this.state.stepSize}
disabled={!isOpen} />
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize}
disabled={!isOpen || arduinoBusy} />
<DirectionButton
axis={"y"}
axis={upDown}
direction="up"
directionAxisProps={directionAxesProps.y}
steps={this.state.stepSize}
disabled={!isOpen} />
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize}
disabled={!isOpen || arduinoBusy} />
<DirectionButton
axis={"y"}
axis={upDown}
direction="down"
directionAxisProps={directionAxesProps.y}
steps={this.state.stepSize}
disabled={!isOpen} />
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize}
disabled={!isOpen || arduinoBusy} />
<DirectionButton
axis={"x"}
axis={rightLeft}
direction="left"
directionAxisProps={directionAxesProps.x}
steps={this.state.stepSize}
disabled={!isOpen} />
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize}
disabled={!isOpen || arduinoBusy} />
<button
className="i fa fa-camera arrow-button fb-button brown"
onClick={() => getDevice().takePhoto().catch(() => { })} />

View File

@ -319,7 +319,7 @@ fieldset {
}
}
.webcam-stream-unavailable text {
.webcam-stream-unavailable p {
width: 100%;
position: absolute;
top: 50%;
@ -484,6 +484,18 @@ ul {
font-size: 1.2rem;
}
.move-settings-menu {
label {
margin-top: 7px;
}
p {
margin-top: 0.7rem;
font-size: 1.4rem;
color: $medium_gray;
font-weight: 400;
}
}
.controls-popup,
.controls-popup-menu-outer {
position: fixed;

View File

@ -51,6 +51,7 @@
margin: auto;
margin-top: 15px;
width: auto;
border: 0;
}
.arrow-button {

View File

@ -3,6 +3,7 @@ import { BotExtents } from "../bot_extents";
import { shallow } from "enzyme";
import { bot } from "../../../__test_support__/fake_state/bot";
import { BotExtentsProps } from "../interfaces";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<VirtualFarmBot/>", () => {
function fakeProps(): BotExtentsProps {
@ -10,9 +11,7 @@ describe("<VirtualFarmBot/>", () => {
mcuParams.movement_stop_at_home_x = 1;
mcuParams.movement_stop_at_home_y = 1;
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
stopAtHome: { x: true, y: true },
botSize: {
x: { value: 3000, isDefault: true },
@ -23,7 +22,7 @@ describe("<VirtualFarmBot/>", () => {
it("renders home lines", () => {
const p = fakeProps();
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).props()).toEqual({ "x1": 2, "x2": 2, "y1": 2, "y2": 1500 });
expect(homeLines.at(1).props()).toEqual({ "x1": 2, "x2": 3000, "y1": 2, "y2": 2 });
@ -38,7 +37,7 @@ describe("<VirtualFarmBot/>", () => {
x: { value: 100, isDefault: false },
y: { value: 100, isDefault: false }
};
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).props()).toEqual({ "x1": 2, "x2": 2, "y1": 2, "y2": 100 });
expect(homeLines.at(1).props()).toEqual({ "x1": 2, "x2": 100, "y1": 2, "y2": 2 });
@ -54,7 +53,7 @@ describe("<VirtualFarmBot/>", () => {
x: { value: 3000, isDefault: true },
y: { value: 100, isDefault: false }
};
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).props()).toEqual({ "x1": 2, "x2": 3000, "y1": 2, "y2": 2 });
expect(homeLines.at(1).html()).toBeFalsy();
@ -71,7 +70,7 @@ describe("<VirtualFarmBot/>", () => {
x: { value: 100, isDefault: false },
y: { value: 100, isDefault: false }
};
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).html()).toBeFalsy();
expect(homeLines.at(1).html()).toBeFalsy();
@ -87,7 +86,7 @@ describe("<VirtualFarmBot/>", () => {
x: { value: 100, isDefault: false },
y: { value: 100, isDefault: false }
};
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).props()).toEqual({ "x1": 2998, "x2": 2998, "y1": 2, "y2": 100 });
expect(homeLines.at(1).props()).toEqual({ "x1": 2998, "x2": 2900, "y1": 2, "y2": 2 });
@ -96,11 +95,38 @@ describe("<VirtualFarmBot/>", () => {
expect(maxLines.at(1).props()).toEqual({ "x1": 2998, "x2": 2900, "y1": 100, "y2": 100 });
});
it("renders max line in correct location", () => {
const p = fakeProps();
p.stopAtHome.x = false;
p.stopAtHome.y = false;
p.botSize = {
x: { value: 100, isDefault: false },
y: { value: 100, isDefault: true }
};
const wrapper = shallow(<BotExtents {...p} />);
const maxLines = wrapper.find("#max-lines").find("line");
expect(maxLines.at(0).props()).toEqual({ "x1": 100, "x2": 100, "y1": 2, "y2": 100 });
});
it("renders max line in correct location with swapped axes", () => {
const p = fakeProps();
p.stopAtHome.x = false;
p.stopAtHome.y = false;
p.mapTransformProps.xySwap = true;
p.botSize = {
x: { value: 100, isDefault: false },
y: { value: 100, isDefault: true }
};
const wrapper = shallow(<BotExtents {...p} />);
const maxLines = wrapper.find("#max-lines").find("line");
expect(maxLines.at(0).props()).toEqual({ "x1": 2, "x2": 100, "y1": 100, "y2": 100 });
});
it("renders no lines", () => {
const p = fakeProps();
p.stopAtHome.x = false;
p.stopAtHome.y = false;
const wrapper = shallow(<BotExtents {...p } />);
const wrapper = shallow(<BotExtents {...p} />);
const homeLines = wrapper.find("#home-lines").find("line");
expect(homeLines.at(0).html()).toBeFalsy();
expect(homeLines.at(1).html()).toBeFalsy();

View File

@ -4,13 +4,12 @@ import { shallow } from "enzyme";
import { DragHelpersProps } from "../interfaces";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { Color } from "../../../ui/index";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<DragHelpers/>", () => {
function fakeProps(): DragHelpersProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plant: fakePlant(),
dragging: false,
zoomLvl: 1.8,
@ -20,7 +19,7 @@ describe("<DragHelpers/>", () => {
}
it("doesn't render drag helpers", () => {
const wrapper = shallow(<DragHelpers {...fakeProps() } />);
const wrapper = shallow(<DragHelpers {...fakeProps()} />);
expect(wrapper.find("text").length).toEqual(0);
expect(wrapper.find("rect").length).toBeLessThanOrEqual(1);
expect(wrapper.find("use").length).toEqual(0);
@ -29,7 +28,7 @@ describe("<DragHelpers/>", () => {
it("renders drag helpers", () => {
const p = fakeProps();
p.dragging = true;
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
expect(wrapper.find("#coordinates-tooltip").length).toEqual(1);
expect(wrapper.find("#long-crosshair").length).toEqual(1);
expect(wrapper.find("#short-crosshair").length).toEqual(1);
@ -42,7 +41,7 @@ describe("<DragHelpers/>", () => {
p.dragging = true;
p.plant.body.x = 104;
p.plant.body.y = 199;
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
expect(wrapper.find("text").length).toEqual(1);
expect(wrapper.find("text").text()).toEqual("100, 200");
expect(wrapper.find("text").props().fontSize).toEqual("1.25rem");
@ -53,7 +52,7 @@ describe("<DragHelpers/>", () => {
const p = fakeProps();
p.dragging = true;
p.zoomLvl = 0.9;
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
expect(wrapper.find("text").length).toEqual(1);
expect(wrapper.find("text").text()).toEqual("100, 200");
expect(wrapper.find("text").props().fontSize).toEqual("3rem");
@ -64,7 +63,7 @@ describe("<DragHelpers/>", () => {
const p = fakeProps();
p.dragging = true;
p.plant.body.id = 5;
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
const crosshair = wrapper.find("#short-crosshair");
expect(crosshair.length).toEqual(1);
const segment = crosshair.find("#crosshair-segment-5");
@ -83,7 +82,7 @@ describe("<DragHelpers/>", () => {
const p = fakeProps();
p.dragging = true;
p.zoomLvl = 0.9;
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
const crosshair = wrapper.find("#short-crosshair");
expect(crosshair.length).toEqual(1);
expect(crosshair.find("rect").first().props())
@ -98,7 +97,7 @@ describe("<DragHelpers/>", () => {
p.plant.body.x = 100;
p.plant.body.y = 100;
p.activeDragXY = { x: 100, y: 0, z: 0 };
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
const indicators = wrapper.find("#alignment-indicator");
expect(indicators.length).toEqual(1);
const segment = indicators.find("#alignment-indicator-segment-5");
@ -119,7 +118,7 @@ describe("<DragHelpers/>", () => {
p.plant.body.x = 100;
p.plant.body.y = 100;
p.activeDragXY = { x: 0, y: 100, z: 0 };
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
const indicator = wrapper.find("#alignment-indicator");
const segments = indicator.find("use");
expect(segments.length).toEqual(2);
@ -136,7 +135,7 @@ describe("<DragHelpers/>", () => {
p.plant.body.x = 100;
p.plant.body.y = 100;
p.activeDragXY = { x: 100, y: 100, z: 0 };
const wrapper = shallow(<DragHelpers {...p } />);
const wrapper = shallow(<DragHelpers {...p} />);
const indicator = wrapper.find("#alignment-indicator");
const masterSegment = indicator.find("#alignment-indicator-segment-6");
const segmentProps = masterSegment.find("rect").props();

View File

@ -1,13 +1,12 @@
import * as React from "react";
import { DrawnPoint, DrawnPointProps } from "../drawn_point";
import { mount } from "enzyme";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<DrawnPoint/>", () => {
function fakeProps(): DrawnPointProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
data: {
cx: 10,
cy: 20,
@ -18,7 +17,7 @@ describe("<DrawnPoint/>", () => {
}
it("renders point", () => {
const wrapper = mount(<DrawnPoint {...fakeProps() } />);
const wrapper = mount(<DrawnPoint {...fakeProps()} />);
expect(wrapper.find("g").props().stroke).toEqual("red");
expect(wrapper.find("circle").first().props()).toEqual({
id: "point-radius", strokeDasharray: "4 5",

View File

@ -7,6 +7,12 @@ jest.mock("farmbot-toastr", () => ({
error: mockError
}));
jest.mock("../../actions", () => ({
closePlantInfo: jest.fn(),
movePlant: jest.fn(),
unselectPlant: jest.fn(() => jest.fn()),
}));
jest.mock("../../../api/crud", () => ({
initSave: jest.fn(),
edit: () => "edit resource",
@ -26,6 +32,7 @@ import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { Actions } from "../../../constants";
import { initSave } from "../../../api/crud";
import { setEggStatus, EggKeys } from "../easter_eggs/status";
import { movePlant, unselectPlant } from "../../actions";
function fakeProps(): GardenMapProps {
return {
@ -172,23 +179,79 @@ describe("<GardenPlant/>", () => {
it("drags: editing", () => {
mockPath = "/app/designer/plants/1/edit";
Object.defineProperty(window, "getComputedStyle", {
value: () => { return { zoom: 0.5 }; }, configurable: true
});
const p = fakeProps();
const wrapper = shallow(<GardenMap {...p} />);
expect(wrapper.state()).toEqual({});
wrapper.find("#drop-area-svg").simulate("mouseMove");
expect(p.dispatch).not.toHaveBeenCalled();
expect(wrapper.state()).toEqual({});
wrapper.setState({ isDragging: true });
wrapper.setState({ isDragging: true, pageX: 200, pageY: 300 });
wrapper.find("#drop-area-svg").simulate("mouseMove", {
pageX: 1, pageY: 2
pageX: 400, pageY: 500
});
expect(wrapper.state()).toEqual({
activeDragXY: { x: 100, y: 200, z: 0 },
activeDragXY: { x: 500, y: 600, z: 0 },
isDragging: true,
pageX: 1,
pageY: 2
pageX: 400,
pageY: 500
});
expect(p.dispatch).toHaveBeenCalledWith("edit resource");
expect(movePlant).toHaveBeenCalledWith(expect.objectContaining({
deltaX: 400, deltaY: 400
}));
});
it("drags: editing, zoom undefined", () => {
mockPath = "/app/designer/plants/1/edit";
Object.defineProperty(window, "getComputedStyle", {
value: () => { return { zoom: undefined }; }, configurable: true
});
const p = fakeProps();
const wrapper = shallow(<GardenMap {...p} />);
expect(wrapper.state()).toEqual({});
wrapper.find("#drop-area-svg").simulate("mouseMove");
expect(p.dispatch).not.toHaveBeenCalled();
expect(wrapper.state()).toEqual({});
wrapper.setState({ isDragging: true, pageX: 300, pageY: 400 });
wrapper.find("#drop-area-svg").simulate("mouseMove", {
pageX: 400, pageY: 500
});
expect(wrapper.state()).toEqual({
activeDragXY: { x: 200, y: 300, z: 0 },
isDragging: true,
pageX: 400,
pageY: 500
});
expect(movePlant).toHaveBeenCalledWith(expect.objectContaining({
deltaX: 100, deltaY: 100
}));
});
it("drags: editing, X&Y swapped", () => {
mockPath = "/app/designer/plants/1/edit";
const p = fakeProps();
p.getConfigValue = () => true;
p.botOriginQuadrant = 1;
const wrapper = shallow(<GardenMap {...p} />);
expect(wrapper.state()).toEqual({});
wrapper.find("#drop-area-svg").simulate("mouseMove");
expect(p.dispatch).not.toHaveBeenCalled();
expect(wrapper.state()).toEqual({});
wrapper.setState({ isDragging: true, pageX: 300, pageY: 500 });
wrapper.find("#drop-area-svg").simulate("mouseMove", {
pageX: 400, pageY: 500
});
expect(wrapper.state()).toEqual({
activeDragXY: { x: -100, y: 300, z: 0 },
isDragging: true,
pageX: 500,
pageY: 400
});
expect(movePlant).toHaveBeenCalledWith(expect.objectContaining({
deltaX: -200, deltaY: 100
}));
});
it("starts drag: selecting", async () => {
@ -251,6 +314,23 @@ describe("<GardenPlant/>", () => {
});
});
it("selects location: zoom undefined", async () => {
const p = fakeProps();
const wrapper = shallow(<GardenMap {...p} />);
Object.defineProperty(window, "getComputedStyle", {
value: () => { return { zoom: undefined }; }, configurable: true
});
expect(wrapper.state()).toEqual({});
mockPath = "/app/designer/plants/move_to";
await wrapper.find("#drop-area-svg").simulate("click", {
pageX: 1000, pageY: 2000, preventDefault: jest.fn()
});
expect(p.dispatch).toHaveBeenCalledWith({
payload: { x: 580, y: 1790, z: 0 },
type: Actions.CHOOSE_LOCATION
});
});
it("starts drawing point", async () => {
const p = fakeProps();
const wrapper = shallow(<GardenMap {...p} />);
@ -292,4 +372,40 @@ describe("<GardenPlant/>", () => {
const eggs = shallow(<GardenMap {...fakeProps()} />);
expect(eggs.find("Bugs").length).toEqual(1);
});
it(".drop-area: handles drag over", () => {
mockPath = "/app/designer/plants/crop_search";
const wrapper = shallow(<GardenMap {...fakeProps()} />);
const e = {
dataTransfer: { dropEffect: undefined },
preventDefault: jest.fn()
};
wrapper.find(".drop-area").simulate("dragOver", e);
expect(e.dataTransfer.dropEffect).toEqual("move");
});
it(".drop-area: handles drag start", () => {
mockPath = "/app/designer/plants";
const wrapper = shallow(<GardenMap {...fakeProps()} />);
const e = { preventDefault: jest.fn() };
wrapper.find(".drop-area").simulate("dragStart", e);
expect(e.preventDefault).toHaveBeenCalled();
});
it(".drop-area: handles drag enter", () => {
mockPath = "/app/designer/plants/crop_search";
const wrapper = shallow(<GardenMap {...fakeProps()} />);
const e = { preventDefault: jest.fn() };
wrapper.find(".drop-area").simulate("dragEnter", e);
expect(e.preventDefault).toHaveBeenCalled();
});
it("calls unselectPlant on unmount", () => {
const p = fakeProps();
const wrapper = shallow(<GardenMap {...p} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.componentWillUnmount();
expect(unselectPlant).toHaveBeenCalled();
});
});

View File

@ -19,13 +19,12 @@ import { GardenPlantProps } from "../interfaces";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { BooleanSetting } from "../../../session_keys";
import { Actions } from "../../../constants";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<GardenPlant/>", () => {
function fakeProps(): GardenPlantProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plant: fakePlant(),
selected: false,
grayscale: false,
@ -39,7 +38,7 @@ describe("<GardenPlant/>", () => {
it("renders plant", () => {
mockStorj[BooleanSetting.disable_animations] = true;
const wrapper = shallow(<GardenPlant {...fakeProps() } />);
const wrapper = shallow(<GardenPlant {...fakeProps()} />);
expect(wrapper.find("image").length).toEqual(1);
expect(wrapper.find("image").props().opacity).toEqual(1);
expect(wrapper.find("text").length).toEqual(0);
@ -50,14 +49,14 @@ describe("<GardenPlant/>", () => {
it("renders plant animations", () => {
mockStorj[BooleanSetting.disable_animations] = false;
const wrapper = shallow(<GardenPlant {...fakeProps() } />);
const wrapper = shallow(<GardenPlant {...fakeProps()} />);
expect(wrapper.find(".soil-cloud").length).toEqual(1);
expect(wrapper.find(".animate").length).toEqual(1);
});
it("Calls the onClick callback", () => {
const p = fakeProps();
const wrapper = shallow(<GardenPlant {...p } />);
const wrapper = shallow(<GardenPlant {...p} />);
wrapper.find("image").at(0).simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT,
@ -67,7 +66,7 @@ describe("<GardenPlant/>", () => {
it("begins hover", () => {
const p = fakeProps();
const wrapper = shallow(<GardenPlant {...p } />);
const wrapper = shallow(<GardenPlant {...p} />);
wrapper.find("image").at(0).simulate("mouseEnter");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.HOVER_PLANT_LIST_ITEM,
@ -77,7 +76,7 @@ describe("<GardenPlant/>", () => {
it("ends hover", () => {
const p = fakeProps();
const wrapper = shallow(<GardenPlant {...p } />);
const wrapper = shallow(<GardenPlant {...p} />);
wrapper.find("image").at(0).simulate("mouseLeave");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.HOVER_PLANT_LIST_ITEM,
@ -87,14 +86,14 @@ describe("<GardenPlant/>", () => {
it("has color", () => {
const p = fakeProps();
const wrapper = shallow(<GardenPlant {...p } />);
const wrapper = shallow(<GardenPlant {...p} />);
expect(wrapper.find("image").props().filter).toEqual("");
});
it("has no color", () => {
const p = fakeProps();
p.grayscale = true;
const wrapper = shallow(<GardenPlant {...p } />);
const wrapper = shallow(<GardenPlant {...p} />);
expect(wrapper.find("image").props().filter).toEqual("url(#grayscale)");
});
});

View File

@ -3,19 +3,18 @@ import { GardenPoint } from "../garden_point";
import { shallow } from "enzyme";
import { GardenPointProps } from "../interfaces";
import { fakePoint } from "../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<GardenPoint/>", () => {
function fakeProps(): GardenPointProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
point: fakePoint()
};
}
it("renders point", () => {
const wrapper = shallow(<GardenPoint {...fakeProps() } />);
const wrapper = shallow(<GardenPoint {...fakeProps()} />);
expect(wrapper.find("#point-radius").props().r).toEqual(100);
expect(wrapper.find("#point-center").props().r).toEqual(2);
});

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { Grid } from "../grid";
import { shallow } from "enzyme";
import { GridProps } from "../interfaces";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<Grid/>", () => {
beforeEach(function () {
@ -10,21 +11,33 @@ describe("<Grid/>", () => {
function fakeProps(): GridProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
dispatch: jest.fn(),
onClick: jest.fn()
};
}
it("renders grid", () => {
const wrapper = shallow(<Grid {...fakeProps() } />);
expect(wrapper.find("#major-grid").props().width).toEqual(3000);
expect(wrapper.find("#minor-grid").props().width).toEqual(3000);
const expectedGridShape = { width: 3000, height: 1500 };
const wrapper = shallow(<Grid {...fakeProps()} />);
expect(wrapper.find("#major-grid").props()).toEqual(
expect.objectContaining(expectedGridShape));
expect(wrapper.find("#minor-grid").props()).toEqual(
expect.objectContaining(expectedGridShape));
expect(wrapper.find("#axis-arrows").find("line").first().props())
.toEqual({ x1: 0, x2: 25, y1: 0, y2: 0 });
expect(wrapper.find("#axis-values").find("text").length).toEqual(43);
});
it("renders grid: X&Y swapped", () => {
const expectedGridShape = { width: 1500, height: 3000 };
const p = fakeProps();
p.mapTransformProps.xySwap = true;
const wrapper = shallow(<Grid {...p} />);
expect(wrapper.find("#major-grid").props()).toEqual(
expect.objectContaining(expectedGridShape));
expect(wrapper.find("#minor-grid").props()).toEqual(
expect.objectContaining(expectedGridShape));
});
});

View File

@ -2,21 +2,31 @@ import * as React from "react";
import { MapBackground } from "../map_background";
import { shallow } from "enzyme";
import { MapBackgroundProps } from "../interfaces";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<MapBackground/>", () => {
function fakeProps(): MapBackgroundProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 }
};
}
it("renders map background", () => {
const wrapper = shallow(<MapBackground {...fakeProps() } />);
expect(wrapper.find("#bed-interior").props().width).toEqual(3180);
expect(wrapper.find("#bed-border").props().width).toEqual(3200);
const wrapper = shallow(<MapBackground {...fakeProps()} />);
expect(wrapper.find("#bed-interior").props()).toEqual(
expect.objectContaining({ width: 3180, height: 1680 }));
expect(wrapper.find("#bed-border").props()).toEqual(
expect.objectContaining({ width: 3200, height: 1700 }));
});
it("renders map background: X&Y swapped", () => {
const p = fakeProps();
p.mapTransformProps.xySwap = true;
const wrapper = shallow(<MapBackground {...p} />);
expect(wrapper.find("#bed-interior").props()).toEqual(
expect.objectContaining({ width: 1680, height: 3180 }));
expect(wrapper.find("#bed-border").props()).toEqual(
expect.objectContaining({ width: 1700, height: 3200 }));
});
});

View File

@ -4,6 +4,7 @@ import { MapImage, MapImageProps } from "../map_image";
import { SpecialStatus } from "../../../resources/tagged_resources";
import { cloneDeep } from "lodash";
import { trim } from "../../../util";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<MapImage />", () => {
const fakeProps = (): MapImageProps => {
@ -29,15 +30,12 @@ describe("<MapImage />", () => {
scale: undefined,
calibrationZ: undefined
},
mapTransformProps: {
gridSize: { x: 0, y: 0 },
quadrant: 1
},
mapTransformProps: fakeMapTransformProps(),
};
};
it("doesn't render image", () => {
const wrapper = mount(<MapImage {...fakeProps() } />);
const wrapper = mount(<MapImage {...fakeProps()} />);
expect(wrapper.html()).toEqual("<image></image>");
});
@ -49,8 +47,19 @@ describe("<MapImage />", () => {
ty: number;
}
interface ExtraTranslationData {
rot: number;
sx: number;
sy: number;
tx: number;
ty: number;
}
const renderedTest =
(num: number, inputData: MapImageProps[], expectedData: ExpectedData) => {
(num: number,
inputData: MapImageProps[],
expectedData: ExpectedData,
extra?: ExtraTranslationData) => {
it(`renders image: INPUT_SET_${num}`, () => {
const wrapper = mount(<MapImage {...inputData[num]} />);
expect(wrapper.find("image").props()).toEqual({
@ -61,6 +70,8 @@ describe("<MapImage />", () => {
height: expectedData.size.height,
transform: trim(`scale(${expectedData.sx}, ${expectedData.sy})
translate(${expectedData.tx}, ${expectedData.ty})`)
+ (extra ? trim(` rotate(${extra.rot}) scale(${extra.sx}, ${extra.sy})
translate(${extra.tx}, ${extra.ty})`) : "")
});
});
};
@ -77,10 +88,9 @@ describe("<MapImage />", () => {
scale: "0.8041",
calibrationZ: "0"
};
INPUT_SET_1.mapTransformProps = {
gridSize: { x: 5900, y: 2900 },
quadrant: 3
};
INPUT_SET_1.mapTransformProps = fakeMapTransformProps();
INPUT_SET_1.mapTransformProps.gridSize = { x: 5900, y: 2900 },
INPUT_SET_1.mapTransformProps.quadrant = 3;
INPUT_SET_1.sizeOverride = { width: 480, height: 640 };
const INPUT_SET_2 = cloneDeep(INPUT_SET_1);
@ -106,10 +116,13 @@ describe("<MapImage />", () => {
const INPUT_SET_7 = cloneDeep(INPUT_SET_6);
INPUT_SET_7.cameraCalibrationData.origin = "BOTTOM_RIGHT";
const INPUT_SET_8 = cloneDeep(INPUT_SET_7);
INPUT_SET_8.mapTransformProps.xySwap = true;
const DATA = [
INPUT_SET_1,
INPUT_SET_1, INPUT_SET_2, INPUT_SET_3, INPUT_SET_4, INPUT_SET_5,
INPUT_SET_6, INPUT_SET_7
INPUT_SET_6, INPUT_SET_7, INPUT_SET_8
];
const expectedSize = { width: 385.968, height: 514.624 };
@ -135,6 +148,9 @@ describe("<MapImage />", () => {
renderedTest(7, DATA, {
size: expectedSize, sx: 1, sy: 1, tx: 5436.016, ty: 2259.688
});
renderedTest(8, DATA, {
size: expectedSize, sx: 1, sy: 1, tx: 2388.344, ty: 5307.36
}, { rot: 90, sx: -1, sy: 1, tx: -514.624, ty: -514.624 });
it("doesn't render placeholder image", () => {
const p = INPUT_SET_1;

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { SelectionBox, SelectionBoxProps } from "../selection_box";
import { shallow } from "enzyme";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<SelectionBox/>", () => {
function fakeProps(): SelectionBoxProps {
@ -11,14 +12,12 @@ describe("<SelectionBox/>", () => {
x1: 240,
y1: 130
},
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
}
mapTransformProps: fakeMapTransformProps(),
};
}
it("renders selection box", () => {
const wrapper = shallow(<SelectionBox {...fakeProps() } />);
const wrapper = shallow(<SelectionBox {...fakeProps()} />);
const boxProps = wrapper.find("rect").props();
expect(boxProps.x).toEqual(40);
expect(boxProps.y).toEqual(30);
@ -29,14 +28,14 @@ describe("<SelectionBox/>", () => {
it("doesn't render selection box: partially undefined", () => {
const p = fakeProps();
p.selectionBox.x1 = undefined;
const wrapper = shallow(<SelectionBox {...p } />);
const wrapper = shallow(<SelectionBox {...p} />);
expect(wrapper.html()).toEqual("<g id=\"selection-box\"></g>");
});
it("renders selection box: quadrant 4", () => {
const p = fakeProps();
p.mapTransformProps.quadrant = 4;
const wrapper = shallow(<SelectionBox {...p } />);
const wrapper = shallow(<SelectionBox {...p} />);
const boxProps = wrapper.find("rect").props();
expect(boxProps.x).toEqual(2760);
expect(boxProps.y).toEqual(1370);

View File

@ -11,6 +11,7 @@ import {
import { shallow } from "enzyme";
import { SpreadOverlapHelperProps } from "../interfaces";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<SpreadOverlapHelper/>", () => {
function fakeProps(): SpreadOverlapHelperProps {
@ -19,9 +20,7 @@ describe("<SpreadOverlapHelper/>", () => {
plant.body.y = 100;
plant.body.radius = 25;
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plant,
dragging: false,
zoomLvl: 1,
@ -36,7 +35,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 900mm (inactive plant at x=100, y=100)
// Overlap: -650mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 0%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("none");
});
@ -47,7 +46,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 240mm (inactive plant at x=100, y=100)
// Overlap: 10mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 4%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(41, 141, 0, 0.04)"); // "green"
});
@ -58,7 +57,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 200mm (inactive plant at x=100, y=100)
// Overlap: 50mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 20%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(204, 255, 0, 0.2)"); // "yellow"
});
@ -69,7 +68,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 150mm (inactive plant at x=100, y=100)
// Overlap: 100mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 40%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(255, 102, 0, 0.3)"); // "orange"
});
@ -80,7 +79,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 125mm (inactive plant at x=100, y=100)
// Overlap: 125mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 50%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(255, 20, 0, 0.3)"); // "red"
});
@ -91,7 +90,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 50mm (inactive plant at x=100, y=100)
// Overlap: 200mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 80%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(255, 0, 0, 0.3)"); // "red"
});
@ -102,7 +101,7 @@ describe("<SpreadOverlapHelper/>", () => {
// Center distance: 0mm (inactive plant at x=100, y=100)
// Overlap: 250mm (default spread = radius * 10 = 250mm)
// Percentage overlap of inactive plant: 100%
const wrapper = shallow(<SpreadOverlapHelper {...p } />);
const wrapper = shallow(<SpreadOverlapHelper {...p} />);
const indicator = wrapper.find(".overlap-circle").props();
expect(indicator.fill).toEqual("rgba(255, 0, 0, 0.3)"); // "red"
});

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { TargetCoordinate, TargetCoordinateProps } from "../target_coordinate";
import { shallow } from "enzyme";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<TargetCoordinate/>", () => {
function fakeProps(): TargetCoordinateProps {
@ -10,14 +11,12 @@ describe("<TargetCoordinate/>", () => {
y: 200,
z: 0
},
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
}
mapTransformProps: fakeMapTransformProps(),
};
}
it("renders target", () => {
const wrapper = shallow(<TargetCoordinate {...fakeProps() } />);
const wrapper = shallow(<TargetCoordinate {...fakeProps()} />);
const boxProps = wrapper.find("rect").props();
expect(boxProps.x).toEqual(90);
expect(boxProps.y).toEqual(198);

View File

@ -13,31 +13,49 @@ describe("<ToolbaySlot />", () => {
x: 10,
y: 20,
pulloutDirection: 0,
quadrant: 2
quadrant: 2,
xySwap: false,
};
};
const checkSlotDirection =
(direction: number, quadrant: BotOriginQuadrant, expected: string) => {
it(`renders slot, pullout: ${direction} quad: ${quadrant}`, () => {
const p = fakeProps();
p.pulloutDirection = direction;
p.quadrant = quadrant;
const wrapper = mount(<ToolbaySlot {...p } />);
expect(wrapper.find("use").props().transform).toEqual(expected);
});
(direction: number,
quadrant: BotOriginQuadrant,
xySwap: boolean,
expected: string) => {
it(`renders slot, pullout: ${direction} quad: ${quadrant} yx: ${xySwap}`,
() => {
const p = fakeProps();
p.pulloutDirection = direction;
p.quadrant = quadrant;
p.xySwap = xySwap;
const wrapper = mount(<ToolbaySlot {...p} />);
expect(wrapper.find("use").props().transform).toEqual(expected);
});
};
checkSlotDirection(0, 2, "rotate(0, 10, 20)");
checkSlotDirection(1, 1, "rotate(180, 10, 20)");
checkSlotDirection(1, 2, "rotate(0, 10, 20)");
checkSlotDirection(1, 3, "rotate(0, 10, 20)");
checkSlotDirection(1, 4, "rotate(180, 10, 20)");
checkSlotDirection(2, 3, "rotate(180, 10, 20)");
checkSlotDirection(3, 1, "rotate(90, 10, 20)");
checkSlotDirection(3, 2, "rotate(90, 10, 20)");
checkSlotDirection(3, 3, "rotate(270, 10, 20)");
checkSlotDirection(3, 4, "rotate(270, 10, 20)");
checkSlotDirection(4, 3, "rotate(90, 10, 20)");
checkSlotDirection(0, 2, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 1, false, "rotate(180, 10, 20)");
checkSlotDirection(1, 2, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 3, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 4, false, "rotate(180, 10, 20)");
checkSlotDirection(2, 3, false, "rotate(180, 10, 20)");
checkSlotDirection(3, 1, false, "rotate(90, 10, 20)");
checkSlotDirection(3, 2, false, "rotate(90, 10, 20)");
checkSlotDirection(3, 3, false, "rotate(270, 10, 20)");
checkSlotDirection(3, 4, false, "rotate(270, 10, 20)");
checkSlotDirection(4, 3, false, "rotate(90, 10, 20)");
checkSlotDirection(0, 2, true, "rotate(180, 10, 20)");
checkSlotDirection(1, 1, true, "rotate(90, 10, 20)");
checkSlotDirection(1, 2, true, "rotate(90, 10, 20)");
checkSlotDirection(1, 3, true, "rotate(270, 10, 20)");
checkSlotDirection(1, 4, true, "rotate(270, 10, 20)");
checkSlotDirection(2, 3, true, "rotate(90, 10, 20)");
checkSlotDirection(3, 1, true, "rotate(180, 10, 20)");
checkSlotDirection(3, 2, true, "rotate(0, 10, 20)");
checkSlotDirection(3, 3, true, "rotate(0, 10, 20)");
checkSlotDirection(3, 4, true, "rotate(180, 10, 20)");
checkSlotDirection(4, 3, true, "rotate(180, 10, 20)");
});
describe("<Tool/>", () => {
@ -58,7 +76,7 @@ describe("<Tool/>", () => {
};
it("renders standard tool styling", () => {
const wrapper = mount(<Tool {...fakeProps() } />);
const wrapper = mount(<Tool {...fakeProps()} />);
const props = wrapper.find("circle").last().props();
expect(props.r).toEqual(35);
expect(props.cx).toEqual(10);
@ -69,7 +87,7 @@ describe("<Tool/>", () => {
it("tool hover", () => {
const p = fakeProps();
p.toolProps.hovered = true;
const wrapper = mount(<Tool {...p } />);
const wrapper = mount(<Tool {...p} />);
const props = wrapper.find("circle").last().props();
expect(props.fill).toEqual(Color.darkGray);
});
@ -77,7 +95,7 @@ describe("<Tool/>", () => {
it("renders special tool styling: bin", () => {
const p = fakeProps();
p.tool = "seedBin";
const wrapper = mount(<Tool {...p } />);
const wrapper = mount(<Tool {...p} />);
const elements = wrapper.find("#seed-bin").find("circle");
expect(elements.length).toEqual(2);
expect(elements.last().props().fill).toEqual("url(#SeedBinGradient)");
@ -87,7 +105,7 @@ describe("<Tool/>", () => {
const p = fakeProps();
p.tool = "seedBin";
p.toolProps.hovered = true;
const wrapper = mount(<Tool {...p } />);
const wrapper = mount(<Tool {...p} />);
p.toolProps.hovered = true;
expect(wrapper.find("#seed-bin").find("circle").length).toEqual(3);
});
@ -95,7 +113,7 @@ describe("<Tool/>", () => {
it("renders special tool styling: tray", () => {
const p = fakeProps();
p.tool = "seedTray";
const wrapper = mount(<Tool {...p } />);
const wrapper = mount(<Tool {...p} />);
const elements = wrapper.find("#seed-tray");
expect(elements.find("circle").length).toEqual(2);
expect(elements.find("rect").length).toEqual(1);
@ -106,7 +124,7 @@ describe("<Tool/>", () => {
const p = fakeProps();
p.tool = "seedTray";
p.toolProps.hovered = true;
const wrapper = mount(<Tool {...p } />);
const wrapper = mount(<Tool {...p} />);
p.toolProps.hovered = true;
expect(wrapper.find("#seed-tray").find("circle").length).toEqual(3);
});

View File

@ -7,30 +7,46 @@ describe("textAnchorPosition()", () => {
const MIDDLE_BOTTOM = { anchor: "middle", x: 0, y: -40 };
it("returns correct label position: positive x", () => {
expect(textAnchorPosition(1, 1)).toEqual(END);
expect(textAnchorPosition(1, 2)).toEqual(START);
expect(textAnchorPosition(1, 3)).toEqual(START);
expect(textAnchorPosition(1, 4)).toEqual(END);
expect(textAnchorPosition(1, 1, false)).toEqual(END);
expect(textAnchorPosition(1, 2, false)).toEqual(START);
expect(textAnchorPosition(1, 3, false)).toEqual(START);
expect(textAnchorPosition(1, 4, false)).toEqual(END);
expect(textAnchorPosition(1, 1, true)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(1, 2, true)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(1, 3, true)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(1, 4, true)).toEqual(MIDDLE_BOTTOM);
});
it("returns correct label position: negative x", () => {
expect(textAnchorPosition(2, 1)).toEqual(START);
expect(textAnchorPosition(2, 2)).toEqual(END);
expect(textAnchorPosition(2, 3)).toEqual(END);
expect(textAnchorPosition(2, 4)).toEqual(START);
expect(textAnchorPosition(2, 1, false)).toEqual(START);
expect(textAnchorPosition(2, 2, false)).toEqual(END);
expect(textAnchorPosition(2, 3, false)).toEqual(END);
expect(textAnchorPosition(2, 4, false)).toEqual(START);
expect(textAnchorPosition(2, 1, true)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(2, 2, true)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(2, 3, true)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(2, 4, true)).toEqual(MIDDLE_TOP);
});
it("returns correct label position: positive y", () => {
expect(textAnchorPosition(3, 1)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(3, 2)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(3, 3)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(3, 4)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(3, 1, false)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(3, 2, false)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(3, 3, false)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(3, 4, false)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(3, 1, true)).toEqual(END);
expect(textAnchorPosition(3, 2, true)).toEqual(START);
expect(textAnchorPosition(3, 3, true)).toEqual(START);
expect(textAnchorPosition(3, 4, true)).toEqual(END);
});
it("returns correct label position: negative y", () => {
expect(textAnchorPosition(4, 1)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(4, 2)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(4, 3)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(4, 4)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(4, 1, false)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(4, 2, false)).toEqual(MIDDLE_BOTTOM);
expect(textAnchorPosition(4, 3, false)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(4, 4, false)).toEqual(MIDDLE_TOP);
expect(textAnchorPosition(4, 1, true)).toEqual(START);
expect(textAnchorPosition(4, 2, true)).toEqual(END);
expect(textAnchorPosition(4, 3, true)).toEqual(END);
expect(textAnchorPosition(4, 4, true)).toEqual(START);
});
});

View File

@ -2,13 +2,12 @@ import * as React from "react";
import { ToolSlotPoint, TSPProps } from "../tool_slot_point";
import { mount } from "enzyme";
import { fakeToolSlot, fakeTool } from "../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("<ToolSlotPoint/>", () => {
function fakeProps(): TSPProps {
return {
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
slot: { toolSlot: fakeToolSlot(), tool: fakeTool() }
};
}
@ -42,28 +41,28 @@ describe("<ToolSlotPoint/>", () => {
it("displays 'no tool'", () => {
const p = fakeProps();
p.slot.tool = undefined;
const wrapper = mount(<ToolSlotPoint {...p } />);
const wrapper = mount(<ToolSlotPoint {...p} />);
wrapper.setState({ hovered: true });
expect(wrapper.find("text").text()).toEqual("no tool");
expect(wrapper.find("text").props().dx).toEqual(40);
});
it("doesn't display tool name", () => {
const wrapper = mount(<ToolSlotPoint {...fakeProps() } />);
const wrapper = mount(<ToolSlotPoint {...fakeProps()} />);
expect(wrapper.find("text").props().visibility).toEqual("hidden");
});
it("renders bin", () => {
const p = fakeProps();
if (p.slot.tool) { p.slot.tool.body.name = "seed bin"; }
const wrapper = mount(<ToolSlotPoint {...p } />);
const wrapper = mount(<ToolSlotPoint {...p} />);
expect(wrapper.find("#SeedBinGradient").length).toEqual(1);
});
it("renders tray", () => {
const p = fakeProps();
if (p.slot.tool) { p.slot.tool.body.name = "seed tray"; }
const wrapper = mount(<ToolSlotPoint {...p } />);
const wrapper = mount(<ToolSlotPoint {...p} />);
expect(wrapper.find("#SeedTrayPattern").length).toEqual(1);
});
});

View File

@ -9,6 +9,7 @@ import {
import { McuParams } from "farmbot";
import { AxisNumberProperty, BotSize, MapTransformProps } from "../interfaces";
import { StepsPerMmXY } from "../../../devices/interfaces";
import { fakeMapTransformProps } from "../../../__test_support__/map_transform_props";
describe("Utils", () => {
it("rounds a number", () => {
@ -18,29 +19,66 @@ describe("Utils", () => {
});
describe("translateScreenToGarden()", () => {
it("translates garden coords to screen coords: corner case", () => {
const cornerCase = translateScreenToGarden({
mapTransformProps: { quadrant: 2, gridSize: { x: 3000, y: 1500 } },
it("translates screen coords to garden coords: zoomLvl = 1", () => {
const result = translateScreenToGarden({
mapTransformProps: fakeMapTransformProps(),
page: { x: 520, y: 212 },
scroll: { left: 0, top: 0 },
scroll: { left: 10, top: 20 },
zoomLvl: 1,
gridOffset: { x: 0, y: 0 },
gridOffset: { x: 30, y: 40 },
});
expect(cornerCase.x).toEqual(200);
expect(cornerCase.y).toEqual(100);
expect(result).toEqual({ x: 180, y: 80 });
});
it("translates garden coords to screen coords: edge case", () => {
const edgeCase = translateScreenToGarden({
mapTransformProps: { quadrant: 2, gridSize: { x: 3000, y: 1500 } },
it("translates screen coords to garden coords: zoomLvl < 1", () => {
const result = translateScreenToGarden({
mapTransformProps: fakeMapTransformProps(),
page: { x: 1132, y: 382 },
scroll: { left: 0, top: 0 },
zoomLvl: 0.3,
gridOffset: { x: 0, y: 0 },
scroll: { left: 10, top: 20 },
zoomLvl: 0.33,
gridOffset: { x: 30, y: 40 },
});
expect(result).toEqual({ x: 2470, y: 840 });
});
expect(Math.round(edgeCase.x)).toEqual(2710);
expect(Math.round(edgeCase.y)).toEqual(910);
it("translates screen coords to garden coords: zoomLvl > 1", () => {
const result = translateScreenToGarden({
mapTransformProps: fakeMapTransformProps(),
page: { x: 1132, y: 382 },
scroll: { left: 10, top: 20 },
zoomLvl: 1.5,
gridOffset: { x: 30, y: 40 },
});
expect(result).toEqual({ x: 520, y: 150 });
});
it("translates screen coords to garden coords: other case", () => {
const fakeMTP = fakeMapTransformProps();
fakeMTP.quadrant = 3;
fakeMTP.gridSize = { x: 300, y: 150 };
const result = translateScreenToGarden({
mapTransformProps: fakeMTP,
page: { x: 332, y: 132 },
scroll: { left: 10, top: 20 },
zoomLvl: 0.75,
gridOffset: { x: 30, y: 40 },
});
expect(result).toEqual({ x: 0, y: 130 });
});
it("translates screen coords to garden coords: swapped X&Y", () => {
const fakeMTP = fakeMapTransformProps();
fakeMTP.xySwap = true;
fakeMTP.quadrant = 3;
fakeMTP.gridSize = { x: 150, y: 300 };
const result = translateScreenToGarden({
mapTransformProps: fakeMTP,
page: { x: 332, y: 132 },
scroll: { left: 10, top: 20 },
zoomLvl: 0.75,
gridOffset: { x: 30, y: 40 },
});
expect(result).toEqual({ x: 130, y: 0 });
});
});
@ -147,65 +185,89 @@ describe("getbotSize()", () => {
describe("getMapSize()", () => {
it("calculates map size", () => {
const mapSize = getMapSize(
{ x: 2000, y: 1000 },
fakeMapTransformProps(),
{ x: 100, y: 50 });
expect(mapSize).toEqual({ x: 2200, y: 1100 });
expect(mapSize).toEqual({ h: 1600, w: 3200 });
});
it("calculates map size: X&Y Swapped", () => {
const fakeMPT = fakeMapTransformProps();
fakeMPT.xySwap = true;
const mapSize = getMapSize(
fakeMPT,
{ x: 100, y: 50 });
expect(mapSize).toEqual({ h: 3200, w: 1600 });
});
});
describe("transformXY", () => {
const gridSize = { x: 2000, y: 1000 };
const mapTransformProps = fakeMapTransformProps();
mapTransformProps.gridSize = { x: 2000, y: 1000 };
type QXY = { qx: number, qy: number };
const transformCheck =
(original: QXY, transformed: QXY, transformProps: MapTransformProps) => {
transformProps.xySwap = false;
expect(transformXY(original.qx, original.qy, transformProps))
.toEqual(transformed);
expect(transformXY(transformed.qx, transformed.qy, transformProps))
.toEqual(original);
transformProps.xySwap = true;
const transformedYX = { qx: transformed.qy, qy: transformed.qx };
expect(transformXY(original.qx, original.qy, transformProps))
.toEqual(transformedYX);
expect(transformXY(transformed.qx, transformed.qy, transformProps))
.toEqual({ qx: original.qy, qy: original.qx });
};
it("calculates transformed coordinate: quadrant 2", () => {
const original = { qx: 100, qy: 200 };
const transformed = { qx: 100, qy: 200 };
const transformProps = { quadrant: 2, gridSize };
transformCheck(original, transformed, transformProps);
mapTransformProps.quadrant = 2;
transformCheck(original, transformed, mapTransformProps);
});
it("calculates transformed coordinate: quadrant 4", () => {
const original = { qx: 100, qy: 200 };
const transformed = { qx: 1900, qy: 800 };
const transformProps = { quadrant: 4, gridSize };
transformCheck(original, transformed, transformProps);
mapTransformProps.quadrant = 4;
transformCheck(original, transformed, mapTransformProps);
});
it("calculates transformed coordinate: quadrant 4 (outside of grid)", () => {
const original = { qx: 2200, qy: 1100 };
const transformed = { qx: -200, qy: -100 };
const transformProps = { quadrant: 4, gridSize };
transformCheck(original, transformed, transformProps);
mapTransformProps.quadrant = 4;
transformCheck(original, transformed, mapTransformProps);
});
});
describe("transformForQuadrant()", () => {
const mapTransformProps = fakeMapTransformProps();
mapTransformProps.gridSize = { x: 200, y: 100 };
it("calculates transform for quadrant 1", () => {
expect(transformForQuadrant({ quadrant: 1, gridSize: { x: 200, y: 100 } }))
mapTransformProps.quadrant = 1;
expect(transformForQuadrant(mapTransformProps))
.toEqual("scale(-1, 1) translate(-200, 0)");
});
it("calculates transform for quadrant 2", () => {
expect(transformForQuadrant({ quadrant: 2, gridSize: { x: 200, y: 100 } }))
mapTransformProps.quadrant = 2;
expect(transformForQuadrant(mapTransformProps))
.toEqual("scale(1, 1) translate(0, 0)");
});
it("calculates transform for quadrant 3", () => {
expect(transformForQuadrant({ quadrant: 3, gridSize: { x: 200, y: 100 } }))
mapTransformProps.quadrant = 3;
expect(transformForQuadrant(mapTransformProps))
.toEqual("scale(1, -1) translate(0, -100)");
});
it("calculates transform for quadrant 4", () => {
expect(transformForQuadrant({ quadrant: 4, gridSize: { x: 200, y: 100 } }))
mapTransformProps.quadrant = 4;
expect(transformForQuadrant(mapTransformProps))
.toEqual("scale(-1, -1) translate(-200, -100)");
});
});

View File

@ -4,6 +4,7 @@ import { BotExtentsProps } from "./interfaces";
export function BotExtents(props: BotExtentsProps) {
const { stopAtHome, botSize, mapTransformProps } = props;
const { xySwap } = mapTransformProps;
const homeLength = transformXY(
botSize.x.value, botSize.y.value, mapTransformProps);
const homeZero = transformXY(2, 2, mapTransformProps);
@ -27,12 +28,12 @@ export function BotExtents(props: BotExtentsProps) {
}
</g>
<g id="max-lines">
{!botSize.x.isDefault &&
{(xySwap ? !botSize.y.isDefault : !botSize.x.isDefault) &&
<line
x1={homeLength.qx} y1={homeZero.qy}
x2={homeLength.qx} y2={homeLength.qy} />
}
{!botSize.y.isDefault &&
{(xySwap ? !botSize.x.isDefault : !botSize.y.isDefault) &&
<line
x1={homeZero.qx} y1={homeLength.qy}
x2={homeLength.qx} y2={homeLength.qy} />

View File

@ -55,7 +55,7 @@ export function DragHelpers(props: DragHelpersProps) {
const {
dragging, plant, zoomLvl, activeDragXY, mapTransformProps, plantAreaOffset
} = props;
const mapSize = getMapSize(mapTransformProps.gridSize, plantAreaOffset);
const mapSize = getMapSize(mapTransformProps, plantAreaOffset);
const { radius, x, y } = plant.body;
const scale = 1 + Math.round(15 * (1.8 - zoomLvl)) / 10; // scale factor
@ -71,8 +71,8 @@ export function DragHelpers(props: DragHelpersProps) {
</text>}
{dragging && // Active plant
<g id="long-crosshair">
<rect x={qx - 0.5} y={-plantAreaOffset.y} width={1} height={mapSize.y} />
<rect x={-plantAreaOffset.x} y={qy - 0.5} width={mapSize.x} height={1} />
<rect x={qx - 0.5} y={-plantAreaOffset.y} width={1} height={mapSize.h} />
<rect x={-plantAreaOffset.x} y={qy - 0.5} width={mapSize.w} height={1} />
</g>}
{dragging && // Active plant
<g id="short-crosshair">

View File

@ -3,15 +3,14 @@ import { shallow, mount } from "enzyme";
import { Bugs, BugsProps, showBugResetButton, showBugs, resetBugs } from "../bugs";
import { EggKeys, setEggStatus, getEggStatus } from "../status";
import { range } from "lodash";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
const expectAlive = (value: string) =>
expect(getEggStatus(EggKeys.BUGS_ARE_STILL_ALIVE)).toEqual(value);
describe("<Bugs />", () => {
const fakeProps = (): BugsProps => ({
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true }

View File

@ -36,9 +36,7 @@ import { isNumber } from "lodash";
import { TargetCoordinate } from "./target_coordinate";
import { DrawnPoint } from "./drawn_point";
import { Bugs, showBugs } from "./easter_eggs/bugs";
const DRAG_ERROR = `ERROR - Couldn't get zoom level of garden map, check the
handleDrop() or drag() method in garden_map.tsx`;
import { BooleanSetting } from "../../session_keys";
export enum Mode {
none = "none",
@ -73,7 +71,8 @@ export class GardenMap extends
get mapTransformProps(): MapTransformProps {
return {
quadrant: this.props.botOriginQuadrant,
gridSize: this.props.gridSize
gridSize: this.props.gridSize,
xySwap: !!this.props.getConfigValue(BooleanSetting.xy_swap),
};
}
@ -113,7 +112,7 @@ export class GardenMap extends
const map = document.querySelector(".farm-designer-map");
const page = document.querySelector(".farm-designer");
if (el && map && page) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || DRAG_ERROR);
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || "1");
const params: ScreenToGardenParams = {
page: { x: e.pageX, y: e.pageY },
scroll: { left: page.scrollLeft, top: map.scrollTop * zoomLvl },
@ -264,16 +263,19 @@ export class GardenMap extends
const plant = this.getPlant();
const map = document.querySelector(".farm-designer-map");
const { gridSize } = this.props;
const { quadrant, xySwap } = this.mapTransformProps;
if (this.state.isDragging && plant && map) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || DRAG_ERROR);
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || "1");
const { qx, qy } = transformXY(e.pageX, e.pageY, this.mapTransformProps);
const deltaX = Math.round((qx - (this.state.pageX || qx)) / zoomLvl);
const deltaY = Math.round((qy - (this.state.pageY || qy)) / zoomLvl);
const dX = xySwap && (quadrant % 2 === 1) ? -deltaX : deltaX;
const dY = xySwap && (quadrant % 2 === 1) ? -deltaY : deltaY;
this.setState({
pageX: qx, pageY: qy,
activeDragXY: { x: plant.body.x + deltaX, y: plant.body.y + deltaY, z: 0 }
activeDragXY: { x: plant.body.x + dX, y: plant.body.y + dY, z: 0 }
});
this.props.dispatch(movePlant({ deltaX, deltaY, plant, gridSize }));
this.props.dispatch(movePlant({ deltaX: dX, deltaY: dY, plant, gridSize }));
}
break;
case Mode.boxSelect:
@ -313,13 +315,14 @@ export class GardenMap extends
render() {
const { gridSize } = this.props;
const mapSize = getMapSize(gridSize, this.props.gridOffset);
const mapSize = getMapSize(this.mapTransformProps, this.props.gridOffset);
const mapTransformProps = this.mapTransformProps;
const { xySwap } = mapTransformProps;
return <div
className="drop-area"
style={{
height: mapSize.y + "px", maxHeight: mapSize.y + "px",
width: mapSize.x + "px", maxWidth: mapSize.x + "px"
height: mapSize.h + "px", maxHeight: mapSize.h + "px",
width: mapSize.w + "px", maxWidth: mapSize.w + "px"
}}
onDrop={this.handleDrop}
onDragEnter={this.handleDragEnter}
@ -335,7 +338,8 @@ export class GardenMap extends
<svg
id="drop-area-svg"
x={this.props.gridOffset.x} y={this.props.gridOffset.y}
width={gridSize.x} height={gridSize.y}
width={xySwap ? gridSize.y : gridSize.x}
height={xySwap ? gridSize.x : gridSize.y}
onMouseUp={this.endDrag}
onMouseDown={this.startDrag}
onMouseMove={this.drag}

View File

@ -6,7 +6,9 @@ import { Color } from "../../ui/index";
export function Grid(props: GridProps) {
const { mapTransformProps } = props;
const { gridSize } = mapTransformProps;
const { gridSize, xySwap } = mapTransformProps;
const gridSizeW = xySwap ? gridSize.y : gridSize.x;
const gridSizeH = xySwap ? gridSize.x : gridSize.y;
const origin = transformXY(0, 0, mapTransformProps);
const arrowEnd = transformXY(25, 25, mapTransformProps);
const xLabel = transformXY(15, -10, mapTransformProps);
@ -34,10 +36,10 @@ export function Grid(props: GridProps) {
<g id="grid">
<rect id="minor-grid"
width={gridSize.x} height={gridSize.y} fill="url(#minor_grid)" />
width={gridSizeW} height={gridSizeH} fill="url(#minor_grid)" />
<rect id="major-grid" transform={transformForQuadrant(mapTransformProps)}
width={gridSize.x} height={gridSize.y} fill="url(#major_grid)" />
<rect id="border" width={gridSize.x} height={gridSize.y} fill="none"
width={gridSizeW} height={gridSizeH} fill="url(#major_grid)" />
<rect id="border" width={gridSizeW} height={gridSizeH} fill="none"
stroke="rgba(0,0,0,0.3)" strokeWidth={2} />
</g>

View File

@ -45,6 +45,7 @@ export interface GardenMapLegendProps {
export type MapTransformProps = {
quadrant: BotOriginQuadrant,
gridSize: AxisNumberProperty
xySwap: boolean;
};
export interface GardenPlantProps {

View File

@ -2,15 +2,14 @@ import * as React from "react";
import { DragHelperLayer, DragHelperLayerProps } from "../drag_helper_layer";
import { shallow } from "enzyme";
import { fakePlant } from "../../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<DragHelperLayer/>", () => {
function fakeProps(): DragHelperLayerProps {
return {
currentPlant: fakePlant(),
editing: true,
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
dragging: true,
zoomLvl: 1.8,
activeDragXY: { x: undefined, y: undefined, z: undefined },
@ -20,7 +19,7 @@ describe("<DragHelperLayer/>", () => {
it("shows drag helpers", () => {
const p = fakeProps();
const wrapper = shallow(<DragHelperLayer {...p } />);
const wrapper = shallow(<DragHelperLayer {...p} />);
["drag-helpers",
"coordinates-tooltip",
"long-crosshair",
@ -31,7 +30,7 @@ describe("<DragHelperLayer/>", () => {
it("doesn't show drag helpers", () => {
const p = fakeProps();
p.editing = false;
const wrapper = shallow(<DragHelperLayer {...p } />);
const wrapper = shallow(<DragHelperLayer {...p} />);
expect(wrapper.html()).toEqual("<g id=\"drag-helper-layer\"></g>");
});
});

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { FarmBotLayer } from "../farmbot_layer";
import { shallow } from "enzyme";
import { FarmBotLayerProps } from "../../interfaces";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<FarmBotLayer/>", () => {
function fakeProps(): FarmBotLayerProps {
@ -12,9 +13,7 @@ describe("<FarmBotLayer/>", () => {
scaled_encoders: { x: undefined, y: undefined, z: undefined },
raw_encoders: { x: undefined, y: undefined, z: undefined },
},
mapTransformProps: {
quadrant: 1, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
stopAtHome: { x: true, y: true },
botSize: {
x: { value: 3000, isDefault: true },
@ -28,7 +27,7 @@ describe("<FarmBotLayer/>", () => {
it("shows layer elements", () => {
const p = fakeProps();
const result = shallow(<FarmBotLayer {...p } />);
const result = shallow(<FarmBotLayer {...p} />);
const layer = result.find("#farmbot-layer");
expect(layer.find("#virtual-farmbot")).toBeTruthy();
expect(layer.find("#extents")).toBeTruthy();
@ -37,7 +36,7 @@ describe("<FarmBotLayer/>", () => {
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const result = shallow(<FarmBotLayer {...p } />);
const result = shallow(<FarmBotLayer {...p} />);
expect(result.html()).toEqual("<g id=\"farmbot-layer\"></g>");
});
});

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { HoveredPlantLayer, HoveredPlantLayerProps } from "../hovered_plant_layer";
import { shallow } from "enzyme";
import { fakePlant } from "../../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<HoveredPlantLayer/>", () => {
function fakeProps(): HoveredPlantLayerProps {
@ -23,16 +24,14 @@ describe("<HoveredPlantLayer/>", () => {
},
hoveredPlant: fakePlant(),
isEditing: false,
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
}
mapTransformProps: fakeMapTransformProps(),
};
}
it("shows hovered plant icon", () => {
const p = fakeProps();
p.designer.hoveredPlant.icon = "fake icon";
const wrapper = shallow(<HoveredPlantLayer {...p } />);
const wrapper = shallow(<HoveredPlantLayer {...p} />);
const icon = wrapper.find("image").props();
expect(icon.visibility).toBeTruthy();
expect(icon.opacity).toEqual(1);
@ -44,7 +43,7 @@ describe("<HoveredPlantLayer/>", () => {
const p = fakeProps();
p.designer.hoveredPlant.icon = "fake icon";
p.currentPlant = fakePlant();
const wrapper = shallow(<HoveredPlantLayer {...p } />);
const wrapper = shallow(<HoveredPlantLayer {...p} />);
expect(wrapper.find("#selected-plant-indicators").length).toEqual(1);
expect(wrapper.find("Circle").length).toEqual(1);
expect(wrapper.find("Circle").props().selected).toBeTruthy();
@ -55,7 +54,7 @@ describe("<HoveredPlantLayer/>", () => {
it("doesn't show hovered plant icon", () => {
const p = fakeProps();
const wrapper = shallow(<HoveredPlantLayer {...p } />);
const wrapper = shallow(<HoveredPlantLayer {...p} />);
expect(wrapper.html()).toEqual("<g id=\"hovered-plant-layer\"></g>");
});
});

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { ImageLayer, ImageLayerProps } from "../image_layer";
import { shallow } from "enzyme";
import { fakeImage, fakeWebAppConfig } from "../../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
const mockConfig = fakeWebAppConfig();
jest.mock("../../../../resources/selectors", () => {
@ -19,9 +20,7 @@ describe("<ImageLayer/>", () => {
return {
visible: true,
images: [image],
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
cameraCalibrationData: {
offset: { x: "0", y: "0" },
origin: "TOP_LEFT",

View File

@ -16,15 +16,14 @@ import { PlantLayer } from "../plant_layer";
import { shallow } from "enzyme";
import { fakePlant } from "../../../../__test_support__/fake_state/resources";
import { PlantLayerProps } from "../../interfaces";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<PlantLayer/>", () => {
function fakeProps(): PlantLayerProps {
return {
visible: true,
plants: [fakePlant()],
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
currentPlant: undefined,
dragging: false,
editing: false,
@ -38,7 +37,7 @@ describe("<PlantLayer/>", () => {
it("shows plants", () => {
const p = fakeProps();
const wrapper = shallow(<PlantLayer {...p } />);
const wrapper = shallow(<PlantLayer {...p} />);
const layer = wrapper.find("#plant-layer");
expect(layer.find(".plant-link-wrapper").length).toEqual(1);
["soil-cloud",
@ -55,21 +54,21 @@ describe("<PlantLayer/>", () => {
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const wrapper = shallow(<PlantLayer {...p } />);
const wrapper = shallow(<PlantLayer {...p} />);
expect(wrapper.html()).toEqual("<g id=\"plant-layer\"></g>");
});
it("is in clickable mode", () => {
mockPath = "/app/designer/plants";
const p = fakeProps();
const wrapper = shallow(<PlantLayer {...p } />);
const wrapper = shallow(<PlantLayer {...p} />);
expect(wrapper.find("Link").props().style).toEqual({});
});
it("is in non-clickable mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
const wrapper = shallow(<PlantLayer {...p } />);
const wrapper = shallow(<PlantLayer {...p} />);
expect(wrapper.find("Link").props().style)
.toEqual({ pointerEvents: "none" });
});

View File

@ -2,21 +2,20 @@ import * as React from "react";
import { PointLayer, PointLayerProps } from "../point_layer";
import { shallow } from "enzyme";
import { fakePoint } from "../../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<PointLayer/>", () => {
function fakeProps(): PointLayerProps {
return {
visible: true,
points: [fakePoint()],
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
}
mapTransformProps: fakeMapTransformProps(),
};
}
it("shows points", () => {
const p = fakeProps();
const wrapper = shallow(<PointLayer {...p } />);
const wrapper = shallow(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer");
expect(layer.find("GardenPoint").html()).toContain("r=\"100\"");
});
@ -24,7 +23,7 @@ describe("<PointLayer/>", () => {
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const wrapper = shallow(<PointLayer {...p } />);
const wrapper = shallow(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer");
expect(layer.find("GardenPoint").length).toEqual(0);
});

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { SpreadLayer, SpreadLayerProps } from "../spread_layer";
import { shallow } from "enzyme";
import { fakePlant } from "../../../../__test_support__/fake_state/resources";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<SpreadLayer/>", () => {
function fakeProps(): SpreadLayerProps {
@ -9,9 +10,7 @@ describe("<SpreadLayer/>", () => {
visible: true,
plants: [fakePlant()],
currentPlant: undefined,
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
dragging: false,
zoomLvl: 1.8,
activeDragXY: { x: undefined, y: undefined, z: undefined },
@ -22,7 +21,7 @@ describe("<SpreadLayer/>", () => {
it("shows spread", () => {
const p = fakeProps();
const wrapper = shallow(<SpreadLayer {...p } />);
const wrapper = shallow(<SpreadLayer {...p} />);
const layer = wrapper.find("#spread-layer");
expect(layer.find("SpreadCircle").html()).toContain("r=\"125\"");
});
@ -30,7 +29,7 @@ describe("<SpreadLayer/>", () => {
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const wrapper = shallow(<SpreadLayer {...p } />);
const wrapper = shallow(<SpreadLayer {...p} />);
const layer = wrapper.find("#spread-layer");
expect(layer.find("SpreadCircle").length).toEqual(0);
});

View File

@ -9,6 +9,7 @@ jest.mock("../../../../history", () => ({
import * as React from "react";
import { ToolSlotLayer, ToolSlotLayerProps } from "../tool_slot_layer";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
import { fakeResource } from "../../../../__test_support__/fake_resource";
import { ToolSlotPointer } from "../../../../interfaces";
import { shallow } from "enzyme";
@ -34,28 +35,26 @@ describe("<ToolSlotLayer/>", () => {
return {
visible: false,
slots: [{ toolSlot, tool: undefined }],
mapTransformProps: {
quadrant: 1, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
dispatch: jest.fn()
};
}
it("toggles visibility off", () => {
const result = shallow(<ToolSlotLayer {...fakeProps() } />);
const result = shallow(<ToolSlotLayer {...fakeProps()} />);
expect(result.find("ToolSlotPoint").length).toEqual(0);
});
it("toggles visibility on", () => {
const p = fakeProps();
p.visible = true;
const result = shallow(<ToolSlotLayer {...p } />);
const result = shallow(<ToolSlotLayer {...p} />);
expect(result.find("ToolSlotPoint").length).toEqual(1);
});
it("navigates to tools page", async () => {
mockPath = "/app/designer/plants";
const p = fakeProps();
const wrapper = shallow(<ToolSlotLayer {...p } />);
const wrapper = shallow(<ToolSlotLayer {...p} />);
const tools = wrapper.find("g").first();
await tools.simulate("click");
expect(mockHistory).toHaveBeenCalledWith("/app/tools");
@ -64,7 +63,7 @@ describe("<ToolSlotLayer/>", () => {
it("doesn't navigate to tools page", async () => {
mockPath = "/app/designer/plants/1";
const p = fakeProps();
const wrapper = shallow(<ToolSlotLayer {...p } />);
const wrapper = shallow(<ToolSlotLayer {...p} />);
const tools = wrapper.find("g").first();
await tools.simulate("click");
expect(mockHistory).not.toHaveBeenCalled();
@ -74,7 +73,7 @@ describe("<ToolSlotLayer/>", () => {
it("is in non-clickable mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
const wrapper = shallow(<ToolSlotLayer {...p } />);
const wrapper = shallow(<ToolSlotLayer {...p} />);
expect(wrapper.find("g").props().style)
.toEqual({ pointerEvents: "none" });
});

View File

@ -1,13 +1,15 @@
import * as React from "react";
import { MapBackgroundProps } from "./interfaces";
import { Color } from "../../ui/index";
import { getMapSize } from "./util";
export function MapBackground(props: MapBackgroundProps) {
const { mapTransformProps, plantAreaOffset } = props;
const { gridSize } = mapTransformProps;
const { gridSize, xySwap } = mapTransformProps;
const gridSizeW = xySwap ? gridSize.y : gridSize.x;
const gridSizeH = xySwap ? gridSize.x : gridSize.y;
const boardWidth = 20;
const mapWidth = gridSize.x + plantAreaOffset.x * 2;
const mapHeight = gridSize.y + plantAreaOffset.y * 2;
const mapSize = getMapSize(mapTransformProps, plantAreaOffset);
return <g id="map-background">
<defs>
<pattern id="diagonalHatch"
@ -18,16 +20,16 @@ export function MapBackground(props: MapBackgroundProps) {
</defs>
<rect id="bed-border"
x={0} y={0} width={mapWidth} height={mapHeight}
x={0} y={0} width={mapSize.w} height={mapSize.h}
fill={Color.soilBackground} />
<rect id="bed-interior" x={boardWidth / 2} y={boardWidth / 2}
width={mapWidth - boardWidth} height={mapHeight - boardWidth}
width={mapSize.w - boardWidth} height={mapSize.h - boardWidth}
stroke="rgba(120,63,4,0.25)" strokeWidth={boardWidth}
fill={Color.soilBackground} />
<rect id="no-access-perimeter" x={boardWidth} y={boardWidth}
width={mapWidth - boardWidth * 2} height={mapHeight - boardWidth * 2}
width={mapSize.w - boardWidth * 2} height={mapSize.h - boardWidth * 2}
fill="url(#diagonalHatch)" />
<rect id="grid-fill" x={plantAreaOffset.x} y={plantAreaOffset.y}
width={gridSize.x} height={gridSize.y} fill={Color.gridSoil} />
width={gridSizeW} height={gridSizeH} fill={Color.gridSoil} />
</g>;
}

View File

@ -81,11 +81,12 @@ interface TransformProps {
qCoords: { qx: number, qy: number };
size: { x: number, y: number };
imageOrigin: string;
xySwap: boolean;
}
/* Image transform string. Flip and place image at the correct map location. */
const transform = (props: TransformProps): string => {
const { quadrant, qCoords, size, imageOrigin } = props;
const { quadrant, qCoords, size, imageOrigin, xySwap } = props;
const { qx, qy } = qCoords;
const orginAdjusted = originAdjustment(imageOrigin);
const quadrantAdjusted = quadrantAdjustment(quadrant);
@ -101,7 +102,11 @@ const transform = (props: TransformProps): string => {
x: round(flip.x * qx + toZero.x, PRECISION),
y: round(flip.y * qy + toZero.y, PRECISION)
};
return `scale(${flip.x}, ${flip.y}) translate(${translate.x}, ${translate.y})`;
const xySwapTransform = xySwap
? ` rotate(90) scale(${-1}, ${1}) translate(${-size.y}, ${-size.y})`
: "";
return `scale(${flip.x}, ${flip.y}) translate(${translate.x}, ${translate.y})`
+ xySwapTransform;
};
export interface MapImageProps {
@ -123,7 +128,7 @@ export function MapImage(props: MapImageProps) {
const imageOffsetX = parse(offset.x);
const imageOffsetY = parse(offset.y);
const imageOrigin = origin ? origin.split("\"").join("") : undefined;
const { quadrant } = props.mapTransformProps;
const { quadrant, xySwap } = props.mapTransformProps;
/* Check if the image exists. */
if (image) {
@ -144,7 +149,7 @@ export function MapImage(props: MapImageProps) {
y: y + imageOffsetY - size.y / 2
};
const qCoords = transformXY(o.x, o.y, props.mapTransformProps);
const transformProps = { quadrant, qCoords, size, imageOrigin };
const transformProps = { quadrant, qCoords, size, imageOrigin, xySwap };
return <image
xlinkHref={imageUrl}
height={size.y} width={size.x} x={0} y={0}

View File

@ -22,13 +22,16 @@ export interface ToolSlotGraphicProps {
y: number;
pulloutDirection: ToolPulloutDirection;
quadrant: BotOriginQuadrant;
xySwap: boolean;
}
const toolbaySlotAngle = (
pulloutDirection: ToolPulloutDirection,
quadrant: BotOriginQuadrant) => {
quadrant: BotOriginQuadrant,
xySwap: boolean) => {
const rawAngle = () => {
switch (pulloutDirection) {
const direction = pulloutDirection + (xySwap ? 2 : 0);
switch (direction > 4 ? direction % 4 : direction) {
case ToolPulloutDirection.POSITIVE_X: return 0;
case ToolPulloutDirection.NEGATIVE_X: return 180;
case ToolPulloutDirection.NEGATIVE_Y: return 90;
@ -56,8 +59,8 @@ export enum ToolNames {
}
export const ToolbaySlot = (props: ToolSlotGraphicProps) => {
const { id, x, y, pulloutDirection, quadrant } = props;
const angle = toolbaySlotAngle(pulloutDirection, quadrant);
const { id, x, y, pulloutDirection, quadrant, xySwap } = props;
const angle = toolbaySlotAngle(pulloutDirection, quadrant, xySwap);
return <g id={"toolbay-slot"}>
<defs>
<g id={"toolbay-slot-" + id}

View File

@ -12,9 +12,11 @@ enum Anchor {
export const textAnchorPosition = (
pulloutDirection: ToolPulloutDirection,
quadrant: BotOriginQuadrant): { x: number, y: number, anchor: string } => {
quadrant: BotOriginQuadrant,
xySwap: boolean): { x: number, y: number, anchor: string } => {
const rawAnchor = () => {
switch (pulloutDirection) {
const direction = pulloutDirection + (xySwap ? 2 : 0);
switch (direction > 4 ? direction % 4 : direction) {
case ToolPulloutDirection.POSITIVE_X: return Anchor.start;
case ToolPulloutDirection.NEGATIVE_X: return Anchor.end;
case ToolPulloutDirection.NEGATIVE_Y: return Anchor.middleTop;
@ -48,11 +50,12 @@ interface ToolLabelProps {
y: number;
pulloutDirection: ToolPulloutDirection;
quadrant: BotOriginQuadrant;
xySwap: boolean;
}
export const ToolLabel = (props: ToolLabelProps) => {
const { toolName, hovered, x, y, pulloutDirection, quadrant } = props;
const labelAnchor = textAnchorPosition(pulloutDirection, quadrant);
const { toolName, hovered, x, y, pulloutDirection, quadrant, xySwap } = props;
const labelAnchor = textAnchorPosition(pulloutDirection, quadrant, xySwap);
return <text textAnchor={labelAnchor.anchor}
visibility={hovered ? "visible" : "hidden"}

View File

@ -36,7 +36,7 @@ export class ToolSlotPoint extends
render() {
const { id, x, y, pullout_direction } = this.slot.toolSlot.body;
const { mapTransformProps } = this.props;
const { quadrant } = mapTransformProps;
const { quadrant, xySwap } = mapTransformProps;
const { qx, qy } = transformXY(x, y, this.props.mapTransformProps);
const toolName = this.slot.tool ? this.slot.tool.body.name : "no tool";
const toolProps = {
@ -52,7 +52,8 @@ export class ToolSlotPoint extends
x={qx}
y={qy}
pulloutDirection={pullout_direction}
quadrant={quadrant} />}
quadrant={quadrant}
xySwap={xySwap} />}
{(this.slot.tool || !pullout_direction) &&
<Tool
@ -65,7 +66,8 @@ export class ToolSlotPoint extends
x={qx}
y={qy}
pulloutDirection={pullout_direction}
quadrant={quadrant} />
quadrant={quadrant}
xySwap={xySwap} />
</g>;
}
}

View File

@ -44,7 +44,7 @@ export function round(num: number) {
* zoom level
* quadrant
* grid size
* XY swap (coming soon)
* XY swap
*
*/
@ -69,18 +69,22 @@ export function translateScreenToGarden(
params: ScreenToGardenParams
): XYCoordinate {
const { page, scroll, zoomLvl, mapTransformProps, gridOffset } = params;
const { xySwap } = mapTransformProps;
const screenXY = page;
const mapXY = ["x", "y"].reduce<XYCoordinate>(
(result: XYCoordinate, axis: "x" | "y") => {
const unscrolled = screenXY[axis] - scroll[leftOrTop[axis]];
const unscrolled = screenXY[axis] + scroll[leftOrTop[axis]];
const map = unscrolled - mapPadding[leftOrTop[axis]];
const grid = map - gridOffset[axis] * zoomLvl;
const unscaled = round(grid / zoomLvl);
result[axis] = unscaled;
return result;
}, { x: 0, y: 0 });
const gardenXY = quadTransform({ coordinate: mapXY, mapTransformProps });
return gardenXY;
const coordinate = xySwap ? { x: mapXY.y, y: mapXY.x } : mapXY;
const gardenXY = transformXY(coordinate.x, coordinate.y, mapTransformProps);
return xySwap
? { x: gardenXY.qy, y: gardenXY.qx }
: { x: gardenXY.qx, y: gardenXY.qy };
}
/* BotOriginQuadrant diagram
@ -112,7 +116,7 @@ interface QuadTransformParams {
/** Quadrant coordinate transformation */
function quadTransform(params: QuadTransformParams): XYCoordinate {
const { coordinate, mapTransformProps } = params;
const { quadrant, gridSize } = mapTransformProps;
const { gridSize, quadrant } = mapTransformProps;
if (isBotOriginQuadrant(quadrant)) {
return ["x", "y"].reduce<XYCoordinate>(
(result: XYCoordinate, axis: "x" | "y") => {
@ -146,9 +150,24 @@ function quadTransform(params: QuadTransformParams): XYCoordinate {
export function transformXY(
x: number,
y: number,
mapTransformProps: MapTransformProps
rawMapTransformProps: MapTransformProps
): { qx: number, qy: number } {
const transformed = quadTransform({ coordinate: { x, y }, mapTransformProps });
const { quadrant, gridSize, xySwap } = rawMapTransformProps;
const coordinate = {
x: xySwap ? y : x,
y: xySwap ? x : y,
};
const transformed = quadTransform({
coordinate,
mapTransformProps: {
quadrant,
gridSize: {
x: xySwap ? gridSize.y : gridSize.x,
y: xySwap ? gridSize.x : gridSize.y,
},
xySwap
}
});
return {
qx: transformed.x,
qy: transformed.y
@ -184,13 +203,18 @@ export function getBotSize(
/** Calculate map dimensions */
export function getMapSize(
gridSize: AxisNumberProperty,
mapTransformProps: MapTransformProps,
gridOffset: AxisNumberProperty
): AxisNumberProperty {
return {
): { w: number, h: number } {
const { gridSize, xySwap } = mapTransformProps;
const mapSize = {
x: gridSize.x + gridOffset.x * 2,
y: gridSize.y + gridOffset.y * 2
};
return {
w: xySwap ? mapSize.y : mapSize.x,
h: xySwap ? mapSize.x : mapSize.y
};
}
/** Transform object based on selected map quadrant and grid size. */

View File

@ -3,36 +3,37 @@ import { shallow } from "enzyme";
import { BotOriginQuadrant } from "../../../interfaces";
import { BotFigure, BotFigureProps } from "../bot_figure";
import { Color } from "../../../../ui/index";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<BotFigure/>", () => {
function fakeProps(): BotFigureProps {
return {
name: "",
position: { x: 0, y: 0, z: 0 },
mapTransformProps: {
quadrant: 1, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 }
};
}
function checkPositionForQuadrant(
quadrant: BotOriginQuadrant,
xySwap: boolean,
expected: { x: number, y: number },
name: string,
opacity: number) {
it(`shows ${name} in correct location for quadrant ${quadrant}`, () => {
const p = fakeProps();
p.mapTransformProps.quadrant = quadrant;
p.mapTransformProps.xySwap = xySwap;
p.name = name;
const result = shallow(<BotFigure {...p} />);
const expectedGantryProps = expect.objectContaining({
id: "gantry",
x: expected.x - 10,
y: -100,
width: 20,
height: 1700,
x: xySwap ? -100 : expected.x - 10,
y: xySwap ? expected.x - 10 : -100,
width: xySwap ? 1700 : 20,
height: xySwap ? 20 : 1700,
fill: Color.darkGray,
fillOpacity: opacity
});
@ -41,8 +42,8 @@ describe("<BotFigure/>", () => {
const expectedUTMProps = expect.objectContaining({
id: "UTM",
cx: expected.x,
cy: expected.y,
cx: xySwap ? expected.y : expected.x,
cy: xySwap ? expected.x : expected.y,
r: 35,
fill: Color.darkGray,
fillOpacity: opacity
@ -52,11 +53,15 @@ describe("<BotFigure/>", () => {
});
}
checkPositionForQuadrant(1, { x: 3000, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(2, { x: 0, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(3, { x: 0, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(4, { x: 3000, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(2, { x: 0, y: 0 }, "encoders", 0.25);
checkPositionForQuadrant(1, false, { x: 3000, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(2, false, { x: 0, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(3, false, { x: 0, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(4, false, { x: 3000, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(1, true, { x: 0, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(2, true, { x: 0, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(3, true, { x: 3000, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(4, true, { x: 3000, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(2, false, { x: 0, y: 0 }, "encoders", 0.25);
it("changes location", () => {
const p = fakeProps();

View File

@ -16,15 +16,14 @@ import * as React from "react";
import { shallow } from "enzyme";
import { BotPeripheralsProps, BotPeripherals } from "../bot_peripherals";
import { BooleanSetting } from "../../../../session_keys";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<BotPeripherals/>", () => {
function fakeProps(): BotPeripheralsProps {
return {
peripherals: [{ label: "", value: false }],
position: { x: 0, y: 0, z: 0 },
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 }
};
}
@ -34,7 +33,7 @@ describe("<BotPeripherals/>", () => {
const p = fakeProps();
p.peripherals[0].label = name;
p.peripherals[0].value = false;
const wrapper = shallow(<BotPeripherals {...p } />);
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find(`#${name}`).length).toEqual(0);
});
}
@ -42,11 +41,11 @@ describe("<BotPeripherals/>", () => {
function animationToggle(
props: BotPeripheralsProps, enabled: number, disabled: number) {
mockStorj[BooleanSetting.disable_animations] = false;
const wrapperEnabled = shallow(<BotPeripherals {...props } />);
const wrapperEnabled = shallow(<BotPeripherals {...props} />);
expect(wrapperEnabled.find("use").length).toEqual(enabled);
mockStorj[BooleanSetting.disable_animations] = true;
const wrapperDisabled = shallow(<BotPeripherals {...props } />);
const wrapperDisabled = shallow(<BotPeripherals {...props} />);
expect(wrapperDisabled.find("use").length).toEqual(disabled);
}
@ -54,19 +53,48 @@ describe("<BotPeripherals/>", () => {
const p = fakeProps();
p.peripherals[0].label = "lights";
p.peripherals[0].value = true;
const wrapper = shallow(<BotPeripherals {...p } />);
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find("#lights").length).toEqual(1);
expect(wrapper.find("rect").last().props()).toEqual({
fill: "url(#LightingGradient)",
height: 1700, width: 400, x: 0, y: -100
});
expect(wrapper.find("use").first().props()).toEqual({
xlinkHref: "#light-half",
transform: "rotate(0, 0, 750)"
});
expect(wrapper.find("use").last().props()).toEqual({
xlinkHref: "#light-half",
transform: "rotate(180, 0, 750)"
});
});
it("displays light: X&Y swapped", () => {
const p = fakeProps();
p.peripherals[0].label = "lights";
p.peripherals[0].value = true;
p.mapTransformProps.xySwap = true;
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find("#lights").length).toEqual(1);
expect(wrapper.find("rect").last().props()).toEqual({
fill: "url(#LightingGradient)",
height: 1700, width: 400, x: -100, y: 0
});
expect(wrapper.find("use").first().props()).toEqual({
xlinkHref: "#light-half",
transform: "rotate(90, 750, 850)"
});
expect(wrapper.find("use").last().props()).toEqual({
xlinkHref: "#light-half",
transform: "rotate(270, -100, 0)"
});
});
it("displays water", () => {
const p = fakeProps();
p.peripherals[0].label = "water valve";
p.peripherals[0].value = true;
const wrapper = shallow(<BotPeripherals {...p } />);
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find("#water").length).toEqual(1);
expect(wrapper.find("circle").last().props()).toEqual({
cx: 0, cy: 0, fill: "rgb(11, 83, 148)", fillOpacity: 0.2, r: 55
@ -78,7 +106,7 @@ describe("<BotPeripherals/>", () => {
const p = fakeProps();
p.peripherals[0].label = "vacuum pump";
p.peripherals[0].value = true;
const wrapper = shallow(<BotPeripherals {...p } />);
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find("#vacuum").length).toEqual(1);
expect(wrapper.find("circle").last().props()).toEqual({
fill: "url(#WaveGradient)", cx: 0, cy: 0, r: 100

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { shallow } from "enzyme";
import { BotTrail, BotTrailProps, VirtualTrail } from "../bot_trail";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<BotTrail/>", () => {
function fakeProps(): BotTrailProps {
@ -12,9 +13,7 @@ describe("<BotTrail/>", () => {
{ coord: { x: 4, y: 4 }, water: 20 }]);
return {
position: { x: 0, y: 0, z: 0 },
mapTransformProps: {
quadrant: 2, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
peripherals: []
};
}

View File

@ -16,6 +16,7 @@ import { VirtualFarmBot } from "../index";
import { shallow } from "enzyme";
import { VirtualFarmBotProps } from "../../interfaces";
import { Dictionary } from "farmbot";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<VirtualFarmBot/>", () => {
function fakeProps(): VirtualFarmBotProps {
@ -25,9 +26,7 @@ describe("<VirtualFarmBot/>", () => {
scaled_encoders: { x: undefined, y: undefined, z: undefined },
raw_encoders: { x: undefined, y: undefined, z: undefined },
},
mapTransformProps: {
quadrant: 1, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 },
peripherals: [],
eStopStatus: false
@ -35,7 +34,7 @@ describe("<VirtualFarmBot/>", () => {
}
it("shows bot position", () => {
const wrapper = shallow(<VirtualFarmBot {...fakeProps() } />);
const wrapper = shallow(<VirtualFarmBot {...fakeProps()} />);
const figures = wrapper.find("BotFigure");
expect(figures.length).toEqual(1);
expect(figures.last().props().name).toEqual("motor-position");
@ -43,13 +42,13 @@ describe("<VirtualFarmBot/>", () => {
it("shows trail", () => {
mockStorj["display_trail"] = true;
const wrapper = shallow(<VirtualFarmBot {...fakeProps() } />);
const wrapper = shallow(<VirtualFarmBot {...fakeProps()} />);
expect(wrapper.find("BotTrail").length).toEqual(1);
});
it("shows encoder position", () => {
mockStorj["encoder_figure"] = true;
const wrapper = shallow(<VirtualFarmBot {...fakeProps() } />);
const wrapper = shallow(<VirtualFarmBot {...fakeProps()} />);
const figures = wrapper.find("BotFigure");
expect(figures.length).toEqual(2);
expect(figures.last().props().name).toEqual("encoder-position");

View File

@ -3,14 +3,13 @@ import { shallow } from "enzyme";
import {
NegativePositionLabel, NegativePositionLabelProps
} from "../negative_position_labels";
import { fakeMapTransformProps } from "../../../../__test_support__/map_transform_props";
describe("<NegativePositionLabel />", () => {
const fakeProps = (): NegativePositionLabelProps => {
return {
position: { x: 1234, y: undefined, z: undefined },
mapTransformProps: {
quadrant: 1, gridSize: { x: 3000, y: 1500 }
},
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 }
};
};

View File

@ -26,18 +26,18 @@ export class BotFigure extends
render() {
const { name, position, plantAreaOffset, eStopStatus, mapTransformProps
} = this.props;
const mapSize = getMapSize(mapTransformProps.gridSize, plantAreaOffset);
const { xySwap } = mapTransformProps;
const mapSize = getMapSize(mapTransformProps, plantAreaOffset);
const positionQ = transformXY(
(position.x || 0), (position.y || 0), mapTransformProps);
const color = eStopStatus ? Color.virtualRed : Color.darkGray;
const opacity = name.includes("encoder") ? 0.25 : 0.75;
return <g id={name}>
<rect id="gantry"
x={positionQ.qx - 10}
y={-plantAreaOffset.y
}
width={20}
height={mapSize.y}
x={xySwap ? -plantAreaOffset.x : positionQ.qx - 10}
y={xySwap ? positionQ.qy - 10 : -plantAreaOffset.y}
width={xySwap ? mapSize.w : 20}
height={xySwap ? 20 : mapSize.h}
fillOpacity={opacity}
fill={color} />
<circle id="UTM"

View File

@ -5,6 +5,7 @@ import { BotPosition } from "../../../devices/interfaces";
import * as _ from "lodash";
import { Session } from "../../../session";
import { BooleanSetting } from "../../../session_keys";
import { trim } from "../../../util";
export interface BotPeripheralsProps {
position: BotPosition;
@ -14,8 +15,8 @@ export interface BotPeripheralsProps {
}
function lightsFigure(
props: { i: number, x: number, y: number, height: number }) {
const { i, x, y, height } = props;
props: { i: number, x: number, y: number, height: number, xySwap: boolean }) {
const { i, x, y, height, xySwap } = props;
const mapHeightMid = height / 2 + y;
return <g id="lights" key={`peripheral_${i}`}>
<defs>
@ -34,9 +35,13 @@ function lightsFigure(
</defs>
<use xlinkHref="#light-half"
transform={`rotate(0, ${x}, ${mapHeightMid})`} />
transform={trim(`rotate(${xySwap ? 90 : 0},
${xySwap ? height / 2 + x : x},
${mapHeightMid})`)} />
<use xlinkHref="#light-half"
transform={`rotate(180, ${x}, ${mapHeightMid})`} />
transform={trim(`rotate(${xySwap ? 270 : 180},
${x},
${xySwap ? y : mapHeightMid})`)} />
</g>;
}
@ -117,7 +122,8 @@ function vacuumFigure(
export function BotPeripherals(props: BotPeripheralsProps) {
const { peripherals, position, plantAreaOffset, mapTransformProps } = props;
const mapSize = getMapSize(mapTransformProps.gridSize, plantAreaOffset);
const { xySwap } = mapTransformProps;
const mapSize = getMapSize(mapTransformProps, plantAreaOffset);
const positionQ = transformXY(
(position.x || 0), (position.y || 0), mapTransformProps);
@ -126,9 +132,10 @@ export function BotPeripherals(props: BotPeripheralsProps) {
if (x.label.toLowerCase().includes("light") && x.value) {
return lightsFigure({
i,
x: positionQ.qx,
y: -plantAreaOffset.y,
height: mapSize.y
x: xySwap ? -plantAreaOffset.y : positionQ.qx,
y: xySwap ? positionQ.qy : -plantAreaOffset.y,
height: xySwap ? mapSize.w : mapSize.h,
xySwap,
});
} else if (x.label.toLowerCase().includes("water") && x.value) {
return waterFigure({

Some files were not shown because too many files have changed in this diff Show More