ruby_robot 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +133 -0
  5. data/Guardfile +77 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +50 -0
  8. data/Rakefile +24 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/exe/ruby_robot +34 -0
  12. data/exe/ruby_robot_grpc_client +53 -0
  13. data/exe/ruby_robot_grpc_server +46 -0
  14. data/exe/ruby_robot_web +19 -0
  15. data/json_schema/request.schema.json +37 -0
  16. data/json_schema/response.schema.json +24 -0
  17. data/lib/public/favicon-16x16.png +0 -0
  18. data/lib/public/favicon-32x32.png +0 -0
  19. data/lib/public/index.html +95 -0
  20. data/lib/public/swagger-ui-bundle.js +93 -0
  21. data/lib/public/swagger-ui-standalone-preset.js +13 -0
  22. data/lib/public/swagger-ui.css +3 -0
  23. data/lib/public/swagger-ui.js +8 -0
  24. data/lib/public/swagger.json +179 -0
  25. data/lib/ruby_robot.rb +16 -0
  26. data/lib/ruby_robot/cacerts.crt +92 -0
  27. data/lib/ruby_robot/construction_error.rb +4 -0
  28. data/lib/ruby_robot/grpc/robot_service.rb +102 -0
  29. data/lib/ruby_robot/grpc/ruby_robot_services_pb.rb +34 -0
  30. data/lib/ruby_robot/grpc_helper.rb +15 -0
  31. data/lib/ruby_robot/grpc_ruby/ruby_robot_pb.rb +36 -0
  32. data/lib/ruby_robot/grpc_shell.rb +85 -0
  33. data/lib/ruby_robot/netflix_tabletop.rb +14 -0
  34. data/lib/ruby_robot/placement_error.rb +4 -0
  35. data/lib/ruby_robot/robot.rb +71 -0
  36. data/lib/ruby_robot/ruby_robot.proto +53 -0
  37. data/lib/ruby_robot/schema_loader.rb +10 -0
  38. data/lib/ruby_robot/shell.rb +204 -0
  39. data/lib/ruby_robot/tabletop.rb +120 -0
  40. data/lib/ruby_robot/version.rb +3 -0
  41. data/lib/ruby_robot/webapp.rb +263 -0
  42. data/ruby_robot.gemspec +48 -0
  43. metadata +258 -0
@@ -0,0 +1,53 @@
1
+ //
2
+ // gRPC service definition for the RubyRobot.
3
+ //
4
+ syntax = "proto3";
5
+
6
+ import "google/protobuf/empty.proto";
7
+
8
+ option java_multiple_files = true;
9
+ option java_package = "net.avilla.netflix.studio.robot";
10
+ option java_outer_classname = "RobotProto";
11
+ option objc_class_prefix = "RBT";
12
+
13
+ package RubyRobot;
14
+
15
+ //
16
+ // Service definitions
17
+ //
18
+ service RubyRobot {
19
+ rpc Left(google.protobuf.Empty) returns (RubyRobotResponse) {}
20
+ rpc Move(google.protobuf.Empty) returns (RubyRobotResponse) {}
21
+ rpc Place(RubyRobotRequest) returns (RubyRobotResponse) {}
22
+ rpc Remove(google.protobuf.Empty) returns (google.protobuf.Empty) {}
23
+ rpc Report(google.protobuf.Empty) returns (RubyRobotResponse) {}
24
+ rpc Right(google.protobuf.Empty) returns (RubyRobotResponse) {}
25
+ }
26
+
27
+ //
28
+ // Message type definitions
29
+ //
30
+ message RubyRobotRequest {
31
+ int32 x = 1;
32
+ int32 y = 2;
33
+ enum Direction {
34
+ // Clockwise from NORTH
35
+ NORTH=0;
36
+ EAST =1;
37
+ SOUTH=2;
38
+ WEST =3;
39
+ }
40
+ Direction direction = 3;
41
+ }
42
+
43
+ message RubyRobotError {
44
+ int32 error = 1;
45
+ string message = 2;
46
+ }
47
+
48
+ message RubyRobotResponse {
49
+ oneof response {
50
+ RubyRobotRequest current_state = 1;
51
+ RubyRobotError error = 2;
52
+ }
53
+ }
@@ -0,0 +1,10 @@
1
+ module RubyRobot
2
+ module SchemaLoader
3
+
4
+ def load_schema(name)
5
+ schema_path = File.join(File.dirname(__FILE__), '..', '..', 'json_schema', "#{name}.schema.json")
6
+ schema = JSON.load(File.new(schema_path))
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,204 @@
1
+ #
2
+ # Behold: I stand on the shoulders of giants...rossmeissl@github.com
3
+ # is the _(wo?)man_
4
+ #
5
+ require 'bombshell'
6
+ require 'logger'
7
+
8
+ module RubyRobot
9
+ class Shell < ::Bombshell::Environment
10
+ include ::Bombshell::Shell
11
+
12
+ prompt_with 'ILoveNetflixStudio'
13
+
14
+ attr_reader :logger
15
+
16
+ def initialize
17
+ @logger = Logger.new(STDOUT)
18
+ @logger.formatter = proc { |severity, datetime, progname, msg|
19
+ "#{msg}\n"
20
+ }
21
+ end
22
+
23
+ #
24
+ # Place a robot
25
+ #
26
+ def PLACE(x, y, direction)
27
+ # Save state in case place is called w/ invalid coords
28
+ orig_robot = @robot
29
+ orig_tabletop = @tabletop
30
+ # TODO: What happens when place is called > 1x per session?
31
+ # Answer under time crunch: just replace the Robot and Tabletop
32
+ @robot = Robot.new(direction)
33
+ @tabletop = NetflixTabletop.new
34
+ begin
35
+ @tabletop.place(@robot, x: x, y: y)
36
+ true
37
+ rescue
38
+ @robot = orig_robot
39
+ @tabletop = orig_tabletop
40
+ @logger.info $!
41
+ false
42
+ end
43
+ end
44
+
45
+ def MOVE
46
+ return if @robot.nil?
47
+ @robot.move
48
+ end
49
+
50
+ def LEFT
51
+ return if @robot.nil?
52
+ @robot.left
53
+ end
54
+
55
+ def RIGHT
56
+ return if @robot.nil?
57
+ @robot.right
58
+ end
59
+
60
+ def REPORT(to_stderr=true)
61
+ return nil if @robot.nil?
62
+ @logger.info(@robot.report) if to_stderr
63
+ @robot.report
64
+ end
65
+
66
+ # Exit Bombshell
67
+ alias :QUIT :quit
68
+ end
69
+ end
70
+
71
+ #
72
+ # Monkeypatch Irb to allow no-arg uppercase methods
73
+ #
74
+ module IRB
75
+ class Irb
76
+ # Evaluates input for this session.
77
+ def eval_input
78
+ @scanner.set_prompt do
79
+ |ltype, indent, continue, line_no|
80
+ if ltype
81
+ f = @context.prompt_s
82
+ elsif continue
83
+ f = @context.prompt_c
84
+ elsif indent > 0
85
+ f = @context.prompt_n
86
+ else
87
+ f = @context.prompt_i
88
+ end
89
+ f = "" unless f
90
+ if @context.prompting?
91
+ @context.io.prompt = p = prompt(f, ltype, indent, line_no)
92
+ else
93
+ @context.io.prompt = p = ""
94
+ end
95
+ if @context.auto_indent_mode
96
+ unless ltype
97
+ ind = prompt(@context.prompt_i, ltype, indent, line_no)[/.*\z/].size +
98
+ indent * 2 - p.size
99
+ ind += 2 if continue
100
+ @context.io.prompt = p + " " * ind if ind > 0
101
+ end
102
+ end
103
+ end
104
+
105
+ @scanner.set_input(@context.io) do
106
+ signal_status(:IN_INPUT) do
107
+ if l = @context.io.gets
108
+ #
109
+ # Begin monkeypatch: turn uppercase constants into
110
+ # method calls
111
+ #
112
+ if [:MOVE,:LEFT,:RIGHT,:REPORT,:REMOVE,:QUIT].include?(l.strip.to_sym)
113
+ l = "#{l.strip}()\n"
114
+ end
115
+ #
116
+ # End monkeypatch
117
+ #
118
+ print l if @context.verbose?
119
+ else
120
+ if @context.ignore_eof? and @context.io.readable_after_eof?
121
+ l = "\n"
122
+ if @context.verbose?
123
+ printf "Use \"exit\" to leave %s\n", @context.ap_name
124
+ end
125
+ else
126
+ print "\n"
127
+ end
128
+ end
129
+ l
130
+ end
131
+ end
132
+
133
+ @scanner.each_top_level_statement do |line, line_no|
134
+ signal_status(:IN_EVAL) do
135
+ begin
136
+ line.untaint
137
+ @context.evaluate(line, line_no)
138
+ output_value if @context.echo?
139
+ exc = nil
140
+ rescue Interrupt => exc
141
+ rescue SystemExit, SignalException
142
+ raise
143
+ rescue Exception => exc
144
+ end
145
+ if exc
146
+ print exc.class, ": ", exc, "\n"
147
+ if exc.backtrace && exc.backtrace[0] =~ /irb(2)?(\/.*|-.*|\.rb)?:/ && exc.class.to_s !~ /^IRB/ &&
148
+ !(SyntaxError === exc)
149
+ irb_bug = true
150
+ else
151
+ irb_bug = false
152
+ end
153
+
154
+ messages = []
155
+ lasts = []
156
+ levels = 0
157
+ if exc.backtrace
158
+ for m in exc.backtrace
159
+ m = @context.workspace.filter_backtrace(m) unless irb_bug
160
+ if m
161
+ if messages.size < @context.back_trace_limit
162
+ messages.push "\tfrom "+m
163
+ else
164
+ lasts.push "\tfrom "+m
165
+ if lasts.size > @context.back_trace_limit
166
+ lasts.shift
167
+ levels += 1
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ print messages.join("\n"), "\n"
174
+ unless lasts.empty?
175
+ printf "... %d levels...\n", levels if levels > 0
176
+ print lasts.join("\n"), "\n"
177
+ end
178
+ print "Maybe IRB bug!\n" if irb_bug
179
+ end
180
+ if $SAFE > 2
181
+ abort "Error: irb does not work for $SAFE level higher than 2"
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ #
190
+ # Map these constants to symbols so the shell
191
+ # will pass them through
192
+ #
193
+ module Bombshell
194
+ module Shell
195
+ NORTH=:north
196
+ SOUTH=:south
197
+ EAST=:east
198
+ WEST=:west
199
+ end
200
+ end
201
+ # Tell shell not to show lower-case 'quit' method; it is aliased
202
+ # to #QUIT along w/ all the other upper-case methods.
203
+ ::Bombshell::Shell::Commands::HIDE << :quit
204
+ ::Bombshell::Shell::Commands::HIDE << :logger
@@ -0,0 +1,120 @@
1
+ #
2
+ # This class is a tabletop which is essentially a 2D
3
+ # array even though Ruby doesn't support 2D arrays
4
+ # like in other languages.
5
+ #
6
+ # As per the API instructions, (0,0) is considered
7
+ # to be the SOUTH WEST most corner.
8
+ #
9
+ module RubyRobot
10
+ class Tabletop
11
+ attr_reader :width, :height
12
+
13
+ def initialize(width, height)
14
+ # Store position of each piece
15
+ @width = width
16
+ @height = height
17
+ # Actually, this probably isn't necessary
18
+ # @playing_field = Array.new(@width) { Array.new(@height) }
19
+ # Hash with keys as the robot object and values are x/y coords
20
+ @robots = {}
21
+ end
22
+
23
+ def width_range
24
+ # Exclude width since positions are 0-based
25
+ (0...@width)
26
+ end
27
+
28
+ def height_range
29
+ # Exclude height since positions are 0-based
30
+ (0...@height)
31
+ end
32
+
33
+ def calculate_position(orig_position, direction_sym)
34
+ case direction_sym
35
+ # These are clockwise from north
36
+ when :north then orig_position[:y] += 1
37
+ when :east then orig_position[:x] += 1
38
+ when :south then orig_position[:y] -= 1
39
+ when :west then orig_position[:x] -= 1
40
+ end
41
+ orig_position
42
+ end
43
+
44
+ #
45
+ # Return hash of x/y coords for a placed
46
+ # Robot, else raise PlacementError. Position state doesn't
47
+ # mean much to a Robot, by itself: only in
48
+ # relation to a Tabletop.
49
+ #
50
+ def position(robot)
51
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
52
+ @robots[robot]
53
+ end
54
+
55
+ #
56
+ # Can the Robot move in the specified direction
57
+ # w/o falling off? Even though direction is a
58
+ # property of a specific Robot, pass it in here.
59
+ #
60
+ # Later this could be extended to support multiple
61
+ # Robots on a Tabletop. In that case, this and
62
+ # #move would need to be synchronized...
63
+ #
64
+ def move?(robot, direction_sym)
65
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
66
+ possible_position = calculate_position(@robots[robot].clone, direction_sym)
67
+
68
+ # Is current_position on the board?
69
+ # Check in range (0..width).include?(x) and (0..height).include?(y)
70
+ return false if
71
+ !width_range.include?(possible_position[:x]) or
72
+ !height_range.include?(possible_position[:y])
73
+ true
74
+ end
75
+
76
+ #
77
+ # Move the robot in the specified direction.
78
+ #
79
+ def move(robot, direction_sym)
80
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
81
+ new_position = calculate_position(@robots[robot].clone, direction_sym)
82
+ # Move the robot by placing it at its new location
83
+ place(robot, **new_position)
84
+ end
85
+
86
+ #
87
+ # Is this robot on this Tabletop?
88
+ #
89
+ def placed?(robot)
90
+ @robots.keys.include?(robot)
91
+ end
92
+
93
+ #
94
+ # NYI: Implement for multiple Robots per Tabletop
95
+ #
96
+ def place?(robot, direction_sym)
97
+ true
98
+ end
99
+
100
+ #
101
+ # Place a robot on this board
102
+ #
103
+ def place(robot, x:0, y:0)
104
+ raise PlacementError.new "Coordinates (#{x},#{y}) are not on this board" if
105
+ !width_range.include?(x) || !height_range.include?(y)
106
+ # @playing_field[x][y] = robot
107
+ @robots[robot] = {x: x, y: y}
108
+ robot.place(self)
109
+ end
110
+
111
+ #
112
+ # Human-readable dump of Tabletop, with
113
+ # 2D array index {x:0,y:0} in the lower-left
114
+ # corner of the output.
115
+ #
116
+ def inspect
117
+ @playing_field
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,3 @@
1
+ module RubyRobot
2
+ VERSION = "0.1.9"
3
+ end
@@ -0,0 +1,263 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/swagger-exposer/swagger-exposer'
3
+ require 'ruby_robot'
4
+ require 'json-schema'
5
+ require 'json'
6
+
7
+ module RubyRobot
8
+
9
+ #
10
+ # SwaggerExposer turns out to have a few drawbacks; it doesn't support
11
+ # specifying enumerated string types.
12
+ #
13
+ USE_SWAGGER_EXPOSER=false
14
+
15
+ #
16
+ # Simple Sinatra webapp that supports:
17
+ #
18
+ # Run HTTP GET on /swagger.json to fetch a static JSON OpenAPI description
19
+ # for this webapp (suitable for use with swagger.io's GUI).
20
+ #
21
+ class Webapp < Sinatra::Base
22
+ include ::RubyRobot::SchemaLoader
23
+
24
+ set :public_folder, File.expand_path(File.join(File.dirname(__FILE__), '..', 'public'))
25
+ enable :static
26
+
27
+ # Tell sinatra to listen on all interfaces if it detects
28
+ # it is running on a docker container...otherwise it
29
+ # will just bind to the loopback interface which can't be
30
+ # exposed from the container.
31
+ set :bind, '0.0.0.0' if File.exist?('/.dockerenv')
32
+
33
+ REPORT_EXAMPLE_OBJ = {x:1,y:1,direction: :NORTH}
34
+ REPORT_EXAMPLE = REPORT_EXAMPLE_OBJ.to_json
35
+
36
+ ERR_PLACEMENT_MSG = 'Robot is not currently placed'
37
+
38
+ def request_schema
39
+ @@request_schema ||= schema = load_schema('request')
40
+ end
41
+
42
+ def response_schema
43
+ @@response_schema ||= schema = load_schema('response')
44
+ end
45
+
46
+ def max_error_message_length
47
+ # Grab from the schema
48
+ (response_schema['definitions']['Error']['properties']['message']['maxLength'] || 1024)
49
+ end
50
+
51
+ if USE_SWAGGER_EXPOSER
52
+ register Sinatra::SwaggerExposer
53
+ #
54
+ # Swagger general info
55
+ #
56
+ general_info(
57
+ {
58
+ version: ::RubyRobot::VERSION,
59
+ title: 'RubyRobot',
60
+ description: 'Web interface to RubyRobot API',
61
+ }
62
+ )
63
+
64
+ #
65
+ # Swagger types
66
+ #
67
+ type 'Error', {
68
+ :required => [:code, :message],
69
+ :properties => {
70
+ :code => {
71
+ :type => Integer,
72
+ :example => 400,
73
+ :description => 'The error code',
74
+ },
75
+ :message => {
76
+ :type => String,
77
+ :example => ERR_PLACEMENT_MSG,
78
+ :description => 'The error message',
79
+ }
80
+ }
81
+ }
82
+
83
+ type 'Report', {
84
+ required: [:x, :y, :direction],
85
+ properties: {
86
+ x: {
87
+ type: Integer,
88
+ example: 0
89
+ },
90
+ y: {
91
+ type: Integer,
92
+ example: 0
93
+ },
94
+ direction: {
95
+ type: String,
96
+ example: "NORTH"
97
+ }
98
+ }
99
+ }
100
+ end # if USE_SWAGGER_EXPOSER
101
+
102
+ #
103
+ # "Normal" sinatra/ruby code.
104
+ #
105
+ def robot
106
+ @@robot ||= proc {
107
+ rr = ::RubyRobot::Shell.new
108
+ # In the webapp, don't log the REPORT messages to stdout
109
+ rr.logger.formatter = proc { |severity, datetime, progname, msg| "" }
110
+ rr
111
+ }.call
112
+ end
113
+
114
+ def not_placed_error
115
+ [400, {code: 400, message: ERR_PLACEMENT_MSG}.to_json]
116
+ end
117
+
118
+ def formatted_report
119
+ r = robot.REPORT(false)
120
+ if !r.nil?
121
+ r[:direction] = r[:direction].upcase unless r[:direction].nil?
122
+ end
123
+ r
124
+ end
125
+
126
+ def position_report
127
+ # Pass along the report, but the direction needs to be upcased to
128
+ # comply w/ the JSON schema for the web API
129
+ [200, formatted_report.to_json]
130
+ end
131
+
132
+ if USE_SWAGGER_EXPOSER
133
+ endpoint_description 'Place the robot'
134
+ endpoint_parameter :body, "Robot placement specification object", :body, true, 'Report', {
135
+ example: REPORT_EXAMPLE_OBJ
136
+ }
137
+ endpoint_response 200, 'Report', 'Successful placement'
138
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
139
+ endpoint_tags 'Robot'
140
+ end # if USE_SWAGGER_EXPOSER
141
+ post '/place' do
142
+ content_type :json
143
+ request_params = nil
144
+ result = nil
145
+ begin
146
+ # Parse JSON args
147
+ request.body.rewind
148
+ request_params = JSON.parse(request.body.read)
149
+ # Validate input against JSON schema
150
+ json_schema_errors = JSON::Validator.fully_validate(request_schema, request_params)
151
+ body_json = {code: 400, message: "Bad request: #{json_schema_errors.join('; ')}"[0...max_error_message_length] }.to_json
152
+ body body_json
153
+ return [400, body_json] unless json_schema_errors.empty?
154
+ # Call robot#PLACE: inputs have already been validated by the JSON schema
155
+ robot.PLACE(request_params['x'], request_params['y'], (request_params['direction']))
156
+ body_json = formatted_report.to_json
157
+ body body_json
158
+ [200, body_json]
159
+ rescue
160
+ # TODO: log failing request details to enterprise logging...
161
+ # STDERR.puts $!
162
+ body_json = {code: 400, message: "Bad request (#{$!})"[0...max_error_message_length]}.to_json
163
+ body body_json
164
+ [400, body_json]
165
+ end
166
+ end
167
+
168
+ if USE_SWAGGER_EXPOSER
169
+ endpoint_description 'Move the robot'
170
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
171
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
172
+ endpoint_tags 'Robot'
173
+ end # if USE_SWAGGER_EXPOSER
174
+ post '/move' do
175
+ content_type :json
176
+ if robot.REPORT.nil?
177
+ not_placed_error
178
+ else
179
+ robot.MOVE
180
+ position_report
181
+ end
182
+ end
183
+
184
+ if USE_SWAGGER_EXPOSER
185
+ endpoint_description 'Turn the robot left'
186
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
187
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
188
+ endpoint_tags 'Robot'
189
+ end # if USE_SWAGGER_EXPOSER
190
+ post '/left' do
191
+ content_type :json
192
+ if robot.REPORT.nil?
193
+ not_placed_error
194
+ else
195
+ robot.LEFT
196
+ position_report
197
+ end
198
+ end
199
+
200
+ if USE_SWAGGER_EXPOSER
201
+ endpoint_description 'Turn the robot right'
202
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
203
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
204
+ endpoint_tags 'Robot'
205
+ end # if USE_SWAGGER_EXPOSER
206
+ post '/right' do
207
+ content_type :json
208
+ if robot.REPORT.nil?
209
+ not_placed_error
210
+ else
211
+ robot.RIGHT
212
+ position_report
213
+ end
214
+ end
215
+
216
+ if USE_SWAGGER_EXPOSER
217
+ endpoint_description "Report the robot's position and orientation"
218
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
219
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
220
+ endpoint_tags 'Robot'
221
+ end # if USE_SWAGGER_EXPOSER
222
+ get '/report' do
223
+ content_type :json
224
+ if robot.REPORT.nil?
225
+ not_placed_error
226
+ else
227
+ position_report
228
+ end
229
+ end
230
+
231
+ if !USE_SWAGGER_EXPOSER
232
+ get '/' do
233
+ redirect '/index.html'
234
+ end
235
+ end
236
+
237
+ post '/remove' do
238
+ @@robot = nil
239
+ [200]
240
+ end
241
+
242
+ #
243
+ # For now, just validate the response for POST /place to show
244
+ # responses can be validated within a Sinatra URL handler.
245
+ #
246
+ after '/place' do
247
+ # Validate response
248
+ begin
249
+ obj = JSON.parse(body.first)
250
+ unless JSON::Validator.validate(response_schema, obj)
251
+ # TODO: Enteprise logging
252
+ # Print out the failing constraints
253
+ STDERR.puts JSON::Validator.fully_validate(response_schema, obj)
254
+ STDERR.puts "Return value doesn't match response.schema.json: #{body.first}"
255
+ end
256
+ rescue
257
+ # TODO: Enterprise logging here...
258
+ STDERR.puts "Error (#{$!}) parsing JSON: '#{body}'"
259
+ end
260
+ # STDERR.puts "Failed validation" if JSON::Validator.validate(@response_schema, obj)
261
+ end
262
+ end
263
+ end