ldbws 1.0.3 → 1.0.4

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/Gemfile.lock +147 -49
  4. data/LICENSE +1 -1
  5. data/README.md +2 -2
  6. data/doc/LICENSE.html +403 -47
  7. data/doc/Ldbws/Request/GetDeparturesBoard.html +487 -54
  8. data/doc/Ldbws/Request/GetDeparturesBoardWithDetails.html +487 -54
  9. data/doc/Ldbws/Request/GetServiceDetails.html +484 -51
  10. data/doc/Ldbws/Request/GetStationBoard.html +489 -56
  11. data/doc/Ldbws/Request/GetStationBoardWithDetails.html +489 -56
  12. data/doc/Ldbws/Request/ParamValidationError.html +538 -69
  13. data/doc/Ldbws/Request.html +470 -47
  14. data/doc/Ldbws/RequestError.html +535 -72
  15. data/doc/Ldbws/ResponseParsingError.html +475 -49
  16. data/doc/Ldbws/ResponseTypes/BaseStationBoard.html +565 -56
  17. data/doc/Ldbws/ResponseTypes/CallingPoint.html +498 -65
  18. data/doc/Ldbws/ResponseTypes/Coach.html +487 -54
  19. data/doc/Ldbws/ResponseTypes/DepartureItem.html +485 -52
  20. data/doc/Ldbws/ResponseTypes/DepartureItemWithDetails.html +485 -52
  21. data/doc/Ldbws/ResponseTypes/DeparturesBoard.html +484 -51
  22. data/doc/Ldbws/ResponseTypes/DeparturesBoardWithDetails.html +484 -51
  23. data/doc/Ldbws/ResponseTypes/Formation.html +485 -52
  24. data/doc/Ldbws/ResponseTypes/LoadingCategory.html +486 -53
  25. data/doc/Ldbws/ResponseTypes/Location.html +486 -53
  26. data/doc/Ldbws/ResponseTypes/ServiceDetails.html +510 -77
  27. data/doc/Ldbws/ResponseTypes/ServiceItem.html +503 -70
  28. data/doc/Ldbws/ResponseTypes/ServiceItemWithCallingPoints.html +485 -52
  29. data/doc/Ldbws/ResponseTypes/StationBoard.html +486 -53
  30. data/doc/Ldbws/ResponseTypes/StationBoardWithDetails.html +486 -53
  31. data/doc/Ldbws/ResponseTypes/ToiletAvailability.html +485 -52
  32. data/doc/Ldbws/ResponseTypes.html +470 -47
  33. data/doc/Ldbws/Service.html +1083 -245
  34. data/doc/Ldbws.html +520 -73
  35. data/doc/created.rid +19 -20
  36. data/doc/css/rdoc.css +1926 -378
  37. data/doc/index.html +434 -104
  38. data/doc/js/aliki.js +489 -0
  39. data/doc/js/bash_highlighter.js +167 -0
  40. data/doc/js/c_highlighter.js +299 -0
  41. data/doc/js/search_controller.js +129 -0
  42. data/doc/js/search_data.js +1 -0
  43. data/doc/js/search_navigation.js +105 -0
  44. data/doc/js/search_ranker.js +239 -0
  45. data/doc/js/theme-toggle.js +112 -0
  46. data/ldbws.gemspec +7 -6
  47. data/lib/ldbws/request/base.rb +0 -3
  48. data/lib/ldbws/request/get_departures_board.rb +3 -1
  49. data/lib/ldbws/request/get_service_details.rb +2 -0
  50. data/lib/ldbws/request/get_station_board.rb +12 -2
  51. data/lib/ldbws/response_types/calling_point.rb +2 -2
  52. data/lib/ldbws/response_types/station_board.rb +6 -0
  53. data/lib/ldbws/version.rb +1 -1
  54. data/spec/helpers/request_helper.rb +27 -0
  55. data/spec/request/get_departures_board_spec.rb +55 -0
  56. data/spec/request/get_service_details_spec.rb +16 -0
  57. data/spec/request/get_station_board_spec.rb +73 -0
  58. metadata +29 -21
  59. data/.envrc +0 -1
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Aliki Search Implementation
3
+ *
4
+ * Search algorithm with the following priorities:
5
+ * 1. Exact full_name match always wins (for namespace/method queries)
6
+ * 2. Exact name match gets high priority
7
+ * 3. Match types:
8
+ * - Namespace queries (::) and method queries (# or .) match against full_name
9
+ * - Regular queries match against unqualified name
10
+ * - Prefix (10000) > substring (5000) > fuzzy (1000)
11
+ * 4. First character determines type priority:
12
+ * - Starts with lowercase: methods first
13
+ * - Starts with uppercase: classes/modules/constants first
14
+ * 5. Within same type priority:
15
+ * - Unqualified match > qualified match
16
+ * - Shorter name > longer name
17
+ * 6. Class methods > instance methods
18
+ * 7. Result limit: 30
19
+ * 8. Minimum query length: 1 character
20
+ */
21
+
22
+ var MAX_RESULTS = 30;
23
+ var MIN_QUERY_LENGTH = 1;
24
+
25
+ /*
26
+ * Scoring constants - organized in tiers where each tier dominates lower tiers.
27
+ * This ensures match type always beats type priority, etc.
28
+ *
29
+ * Tier 0: Exact matches (immediate return)
30
+ * Tier 1: Match type (prefix > substring > fuzzy)
31
+ * Tier 2: Exact name bonus
32
+ * Tier 3: Type priority (method vs class based on query case)
33
+ * Tier 4: Minor bonuses (top-level, class method, name length)
34
+ */
35
+ var SCORE_EXACT_FULL_NAME = 1000000; // Tier 0: Query exactly matches full_name
36
+ var SCORE_MATCH_PREFIX = 10000; // Tier 1: Query is prefix of name
37
+ var SCORE_MATCH_SUBSTRING = 5000; // Tier 1: Query is substring of name
38
+ var SCORE_MATCH_FUZZY = 1000; // Tier 1: Query chars appear in order
39
+ var SCORE_EXACT_NAME = 500; // Tier 2: Name exactly equals query
40
+ var SCORE_TYPE_PRIORITY = 100; // Tier 3: Preferred type (method/class)
41
+ var SCORE_TOP_LEVEL = 50; // Tier 4: Top-level over namespaced
42
+ var SCORE_CLASS_METHOD = 10; // Tier 4: Class method over instance method
43
+
44
+ /**
45
+ * Check if all characters in query appear in order in target
46
+ * e.g., "addalias" fuzzy matches "add_foo_alias"
47
+ */
48
+ function fuzzyMatch(target, query) {
49
+ var ti = 0;
50
+ for (var qi = 0; qi < query.length; qi++) {
51
+ ti = target.indexOf(query[qi], ti);
52
+ if (ti === -1) return false;
53
+ ti++;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Parse and normalize a search query
60
+ * @param {string} query - The raw search query
61
+ * @returns {Object} Parsed query with normalized form and flags
62
+ */
63
+ function parseQuery(query) {
64
+ // Lowercase for case-insensitive matching (so "hash" finds both Hash class and #hash methods)
65
+ var normalized = query.toLowerCase();
66
+ var isNamespaceQuery = query.includes('::');
67
+ var isMethodQuery = query.includes('#') || query.includes('.');
68
+
69
+ // Normalize . to :: (RDoc uses :: for class methods in full_name)
70
+ if (query.includes('.')) {
71
+ normalized = normalized.replace(/\./g, '::');
72
+ }
73
+
74
+ return {
75
+ original: query,
76
+ normalized: normalized,
77
+ isNamespaceQuery: isNamespaceQuery,
78
+ isMethodQuery: isMethodQuery,
79
+ // Namespace and method queries match against full_name instead of name
80
+ matchesFullName: isNamespaceQuery || isMethodQuery,
81
+ // If query starts with lowercase, prioritize methods; otherwise prioritize classes/modules/constants
82
+ prioritizeMethod: !/^[A-Z]/.test(query)
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Main search function
88
+ * @param {string} query - The search query
89
+ * @param {Array} index - The search index to search in
90
+ * @returns {Array} Array of matching entries, sorted by relevance
91
+ */
92
+ function search(query, index) {
93
+ if (!query || query.length < MIN_QUERY_LENGTH) {
94
+ return [];
95
+ }
96
+
97
+ var q = parseQuery(query);
98
+ var results = [];
99
+
100
+ for (var i = 0; i < index.length; i++) {
101
+ var entry = index[i];
102
+ var score = computeScore(entry, q);
103
+
104
+ if (score !== null) {
105
+ results.push({ entry: entry, score: score });
106
+ }
107
+ }
108
+
109
+ results.sort(function(a, b) {
110
+ return b.score - a.score;
111
+ });
112
+
113
+ return results.slice(0, MAX_RESULTS).map(function(r) {
114
+ return r.entry;
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Compute the relevance score for an entry
120
+ * @param {Object} entry - The search index entry
121
+ * @param {Object} q - Parsed query from parseQuery()
122
+ * @returns {number|null} Score or null if no match
123
+ */
124
+ function computeScore(entry, q) {
125
+ var name = entry.name;
126
+ var fullName = entry.full_name;
127
+ var type = entry.type;
128
+
129
+ var nameLower = name.toLowerCase();
130
+ var fullNameLower = fullName.toLowerCase();
131
+
132
+ // Exact full_name match (e.g., "Array#filter" matches Array#filter)
133
+ if (q.matchesFullName && fullNameLower === q.normalized) {
134
+ return SCORE_EXACT_FULL_NAME;
135
+ }
136
+
137
+ var matchScore = 0;
138
+ var target = q.matchesFullName ? fullNameLower : nameLower;
139
+
140
+ if (target.startsWith(q.normalized)) {
141
+ matchScore = SCORE_MATCH_PREFIX; // Prefix (e.g., "Arr" matches "Array")
142
+ } else if (target.includes(q.normalized)) {
143
+ matchScore = SCORE_MATCH_SUBSTRING; // Substring (e.g., "ray" matches "Array")
144
+ } else if (fuzzyMatch(target, q.normalized)) {
145
+ matchScore = SCORE_MATCH_FUZZY; // Fuzzy (e.g., "addalias" matches "add_foo_alias")
146
+ } else {
147
+ return null;
148
+ }
149
+
150
+ var score = matchScore;
151
+ var isMethod = (type === 'instance_method' || type === 'class_method');
152
+
153
+ if (q.prioritizeMethod ? isMethod : !isMethod) {
154
+ score += SCORE_TYPE_PRIORITY;
155
+ }
156
+
157
+ if (type === 'class_method') score += SCORE_CLASS_METHOD;
158
+ if (name === fullName) score += SCORE_TOP_LEVEL; // Top-level (Hash) > namespaced (Foo::Hash)
159
+ if (nameLower === q.normalized) score += SCORE_EXACT_NAME; // Exact name match
160
+ score -= name.length;
161
+
162
+ return score;
163
+ }
164
+
165
+ /**
166
+ * SearchRanker class for compatibility with the Search UI
167
+ * Provides ready() and find() interface
168
+ */
169
+ function SearchRanker(index) {
170
+ this.index = index;
171
+ this.handlers = [];
172
+ }
173
+
174
+ SearchRanker.prototype.ready = function(fn) {
175
+ this.handlers.push(fn);
176
+ };
177
+
178
+ SearchRanker.prototype.find = function(query) {
179
+ var q = parseQuery(query);
180
+ var rawResults = search(query, this.index);
181
+ var results = rawResults.map(function(entry) {
182
+ return formatResult(entry, q);
183
+ });
184
+
185
+ var _this = this;
186
+ this.handlers.forEach(function(fn) {
187
+ fn.call(_this, results, true);
188
+ });
189
+ };
190
+
191
+ /**
192
+ * Format a search result entry for display
193
+ */
194
+ function formatResult(entry, q) {
195
+ var result = {
196
+ title: highlightMatch(entry.full_name, q),
197
+ path: entry.path,
198
+ type: entry.type
199
+ };
200
+
201
+ if (entry.snippet) {
202
+ result.snippet = entry.snippet;
203
+ }
204
+
205
+ return result;
206
+ }
207
+
208
+ /**
209
+ * Add highlight markers (\u0001 and \u0002) to matching portions of text
210
+ * @param {string} text - The text to highlight
211
+ * @param {Object} q - Parsed query from parseQuery()
212
+ */
213
+ function highlightMatch(text, q) {
214
+ if (!text || !q) return text;
215
+
216
+ var textLower = text.toLowerCase();
217
+ var query = q.normalized;
218
+
219
+ // Try contiguous match first (prefix or substring)
220
+ var matchIndex = textLower.indexOf(query);
221
+ if (matchIndex !== -1) {
222
+ return text.substring(0, matchIndex) +
223
+ '\u0001' + text.substring(matchIndex, matchIndex + query.length) + '\u0002' +
224
+ text.substring(matchIndex + query.length);
225
+ }
226
+
227
+ // Fall back to fuzzy highlight (highlight each matched character)
228
+ var result = '';
229
+ var ti = 0;
230
+ for (var qi = 0; qi < query.length; qi++) {
231
+ var charIndex = textLower.indexOf(query[qi], ti);
232
+ if (charIndex === -1) return text;
233
+ result += text.substring(ti, charIndex);
234
+ result += '\u0001' + text[charIndex] + '\u0002';
235
+ ti = charIndex + 1;
236
+ }
237
+ result += text.substring(ti);
238
+ return result;
239
+ }
@@ -0,0 +1,112 @@
1
+ (function() {
2
+ 'use strict';
3
+
4
+ const STORAGE_KEY = 'rdoc-theme';
5
+ const THEME_LIGHT = 'light';
6
+ const THEME_DARK = 'dark';
7
+
8
+ /**
9
+ * Get the user's theme preference
10
+ * Priority: localStorage > system preference > light (default)
11
+ */
12
+ function getThemePreference() {
13
+ // Check localStorage first
14
+ const stored = localStorage.getItem(STORAGE_KEY);
15
+ if (stored === THEME_LIGHT || stored === THEME_DARK) {
16
+ return stored;
17
+ }
18
+
19
+ // Check system preference
20
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
21
+ return THEME_DARK;
22
+ }
23
+
24
+ return THEME_LIGHT;
25
+ }
26
+
27
+ /**
28
+ * Apply theme to document
29
+ */
30
+ function applyTheme(theme) {
31
+ document.documentElement.setAttribute('data-theme', theme);
32
+ localStorage.setItem(STORAGE_KEY, theme);
33
+
34
+ // Update toggle button icon
35
+ const toggleBtn = document.getElementById('theme-toggle');
36
+ if (toggleBtn) {
37
+ const icon = toggleBtn.querySelector('.theme-toggle-icon');
38
+ if (icon) {
39
+ icon.textContent = theme === THEME_DARK ? '☀️' : '🌙';
40
+ }
41
+ toggleBtn.setAttribute('aria-label',
42
+ theme === THEME_DARK ? 'Switch to light mode' : 'Switch to dark mode'
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Toggle between light and dark themes
49
+ */
50
+ function toggleTheme() {
51
+ const currentTheme = document.documentElement.getAttribute('data-theme') || THEME_LIGHT;
52
+ const newTheme = currentTheme === THEME_LIGHT ? THEME_DARK : THEME_LIGHT;
53
+ applyTheme(newTheme);
54
+
55
+ // Announce to screen readers
56
+ announceThemeChange(newTheme);
57
+ }
58
+
59
+ /**
60
+ * Announce theme change to screen readers
61
+ */
62
+ function announceThemeChange(theme) {
63
+ const announcement = document.createElement('div');
64
+ announcement.setAttribute('role', 'status');
65
+ announcement.setAttribute('aria-live', 'polite');
66
+ announcement.className = 'sr-only';
67
+ announcement.textContent = `Switched to ${theme} mode`;
68
+ document.body.appendChild(announcement);
69
+
70
+ // Remove after announcement
71
+ setTimeout(() => {
72
+ document.body.removeChild(announcement);
73
+ }, 1000);
74
+ }
75
+
76
+ /**
77
+ * Initialize theme on page load
78
+ */
79
+ function initTheme() {
80
+ // Apply theme immediately to prevent flash
81
+ const theme = getThemePreference();
82
+ applyTheme(theme);
83
+
84
+ // Set up toggle button listener
85
+ const toggleBtn = document.getElementById('theme-toggle');
86
+ if (toggleBtn) {
87
+ toggleBtn.addEventListener('click', toggleTheme);
88
+ }
89
+
90
+ // Listen for system theme changes
91
+ if (window.matchMedia) {
92
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
93
+ // Only auto-switch if user hasn't manually set a preference
94
+ const stored = localStorage.getItem(STORAGE_KEY);
95
+ if (!stored) {
96
+ applyTheme(e.matches ? THEME_DARK : THEME_LIGHT);
97
+ }
98
+ });
99
+ }
100
+ }
101
+
102
+ // Initialize immediately (before DOMContentLoaded to prevent flash)
103
+ if (document.readyState === 'loading') {
104
+ // Apply theme as early as possible
105
+ const theme = getThemePreference();
106
+ document.documentElement.setAttribute('data-theme', theme);
107
+
108
+ document.addEventListener('DOMContentLoaded', initTheme);
109
+ } else {
110
+ initTheme();
111
+ }
112
+ })();
data/ldbws.gemspec CHANGED
@@ -14,12 +14,13 @@ Gem::Specification.new do |spec|
14
14
 
15
15
  spec.version = Ldbws::VERSION
16
16
  spec.files = `git ls-files`.split($\)
17
+ spec.required_ruby_version = ">= 3.3"
17
18
 
18
- spec.add_dependency "dry-schema", "~> 1.13"
19
- spec.add_dependency "faraday", "~> 2.7"
20
- spec.add_dependency "nokogiri", "~> 1.14"
19
+ spec.add_dependency "dry-schema", "~> 1.16"
20
+ spec.add_dependency "faraday", "~> 2.15"
21
+ spec.add_dependency "nokogiri", "~> 1.19"
21
22
 
22
- spec.add_development_dependency "rdoc", "~> 6.5.0"
23
- spec.add_development_dependency "rspec", "~> 3.11"
24
- spec.add_development_dependency "rufo", "~> 0.13.0"
23
+ spec.add_development_dependency "rdoc", "~> 8.1"
24
+ spec.add_development_dependency "rspec", "~> 3.13"
25
+ spec.add_development_dependency "rufo", "~> 0.18"
25
26
  end
@@ -12,9 +12,6 @@ module Ldbws::Request # :nodoc:
12
12
 
13
13
  # A CRS code (eg ‘WAT’ for London Waterloo). These are not exhaustively validated.
14
14
  Crs = String.constrained(size: 3)
15
-
16
- # A specific type used when filtering by CRS.
17
- FilterType = String.enum("to", "from")
18
15
  end
19
16
 
20
17
  # \Base request type used whem querying LDBWS: provides basic functionality that can be overridden on a per-subclass
@@ -17,7 +17,7 @@ module Ldbws::Request
17
17
  config.validate_keys = true
18
18
 
19
19
  required(:crs).filled(Types::Crs)
20
- required(:filter_list).array(Types::Crs, min_size?: 1)
20
+ required(:filter_list).value(array[Types::Crs], min_size?: 1)
21
21
  optional(:time_offset).filled(:integer)
22
22
  optional(:time_window).filled(:integer)
23
23
  end
@@ -28,6 +28,8 @@ module Ldbws::Request
28
28
  # :nodoc:
29
29
  RESULT_TYPE = Ldbws::ResponseTypes::DeparturesBoard
30
30
 
31
+ protected
32
+
31
33
  # :nodoc:
32
34
  def to_soap_params
33
35
  @params.tap do |params|
@@ -21,6 +21,8 @@ module Ldbws::Request
21
21
  # :nodoc:
22
22
  RESULT_TYPE = Ldbws::ResponseTypes::ServiceDetails
23
23
 
24
+ protected
25
+
24
26
  # :nodoc:
25
27
  def to_soap_params
26
28
  {
@@ -21,7 +21,7 @@ module Ldbws::Request
21
21
  required(:crs).filled(Types::Crs)
22
22
  optional(:num_rows).filled(:integer)
23
23
  optional(:filter_crs).filled(Types::Crs)
24
- optional(:filter_type).filled(Types::FilterType)
24
+ optional(:filter_type).filled(Types::String.enum("to", "from"))
25
25
  optional(:time_offset).filled(:integer)
26
26
  optional(:time_window).filled(:integer)
27
27
  end
@@ -32,13 +32,23 @@ module Ldbws::Request
32
32
  # :nodoc:
33
33
  RESULT_TYPE = Ldbws::ResponseTypes::StationBoard
34
34
 
35
+ def initialize(args)
36
+ # there doesn’t seem to be a nice way of getting Dry::Types to do a case-insensitive enum, so instead we’ll do
37
+ # some manual case-coersion here
38
+ args[:filter_type].downcase! if args[:filter_type]
39
+
40
+ super(args)
41
+ end
42
+
43
+ protected
44
+
35
45
  # :nodoc:
36
46
  def to_soap_params
37
47
  @params.tap do |params|
38
48
  # CRSes should always be upcased
39
49
  params[:crs].upcase!
40
50
  params[:filter_crs].upcase! if params[:filter_crs]
41
- params[:filter_type].downcase! if params[:filter_crs]
51
+ params[:filter_type].downcase! if params[:filter_type]
42
52
 
43
53
  # filter type isn’t needed if we’re not filtering
44
54
  params.delete(:filter_type) unless params[:filter_crs]
@@ -12,7 +12,7 @@ module Ldbws::ResponseTypes
12
12
  # at:: the actual time (as a string)
13
13
  # cancelled::
14
14
  # length::
15
- # detatch_front::
15
+ # detach_front::
16
16
  # formation:: information about the formation of the train
17
17
  # adhoc_alerts::
18
18
  # cancel_reason::
@@ -28,7 +28,7 @@ module Ldbws::ResponseTypes
28
28
  property :at, String
29
29
  property :cancelled, Boolean, selector: "isCancelled"
30
30
  property :length, Integer
31
- property :detatch_front, Boolean, default: false
31
+ property :detach_front, Boolean, default: false
32
32
  property :formation, Formation
33
33
  collection :adhoc_alerts, "adhocAlertText", String
34
34
  property :cancel_reason, String
@@ -18,6 +18,12 @@ module Ldbws::ResponseTypes
18
18
  collection :nrcc_messages, "message", StrippedString
19
19
  property :platform_available, Boolean, default: false
20
20
  property :are_services_available, Boolean, default: true
21
+
22
+ # Returns a combined list of all services (train, bus, and ferry), ordered by scheduled time of departure or
23
+ # arrival.
24
+ def all_services
25
+ (train_services + bus_services + ferry_services).sort_by { |s| s.std || s.sta }
26
+ end
21
27
  end
22
28
 
23
29
  # Represents a station arrival/departure board in LDBWS.
data/lib/ldbws/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Ldbws
2
2
  # The current gem version.
3
- VERSION = "1.0.3"
3
+ VERSION = "1.0.4"
4
4
  end
@@ -0,0 +1,27 @@
1
+ require "nokogiri"
2
+
3
+ module RequestHelper
4
+ def expect_ok(params)
5
+ ret = nil
6
+ expect { ret = subject.new(params) }.not_to raise_error
7
+ ret
8
+ end
9
+
10
+ def expect_error(params, field, message)
11
+ expect { subject.new(params) }.to raise_error do |err|
12
+ expect(err).to be_a(Ldbws::Request::ParamValidationError)
13
+
14
+ messages = err.messages
15
+ expect(messages[field]).not_to be_nil
16
+ expect(messages[field].to_s).to include(message)
17
+ end
18
+ end
19
+
20
+ def to_soap_str(params)
21
+ Nokogiri::XML::Builder.new do |xml|
22
+ xml.Request { # This node is entirely arbitrary
23
+ subject.new(params).to_soap(xml)
24
+ }
25
+ end.to_xml
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ require "spec_helper"
2
+ require "helpers/request_helper"
3
+
4
+ describe Ldbws::Request::GetDeparturesBoard do
5
+ include RequestHelper
6
+
7
+ let(:subject) { Ldbws::Request::GetDeparturesBoard }
8
+
9
+ context :validation do
10
+ context :crs do
11
+ it "requires a CRS" do
12
+ expect_error({}, :crs, "is missing")
13
+ end
14
+
15
+ it "enforces the length requirement" do
16
+ expect_error({ crs: "x" }, :crs, "length must be 3")
17
+ expect_error({ crs: "xxxx" }, :crs, "length must be 3")
18
+ end
19
+ end
20
+
21
+ context :filter_list do
22
+ it "requires at least one to be specified" do
23
+ expect_error({}, :filter_list, "is missing")
24
+
25
+ expect_error({ filter_list: [] }, :filter_list, "cannot be less than 1")
26
+ end
27
+
28
+ it "enforces the length requirement" do
29
+ expect_error({ filter_list: ["x"] }, :filter_list, "length must be 3")
30
+ expect_error({ filter_list: ["xxxx"] }, :filter_list, "length must be 3")
31
+ end
32
+
33
+ it "works with something that looks like a CRS (inexhaustive validation)" do
34
+ expect_ok(crs: "xxx", filter_list: %w{ abc def })
35
+ end
36
+ end
37
+ end
38
+
39
+ context :to_soap do
40
+ it "upcases the CRS field" do
41
+ soap = to_soap_str(crs: "cdf", filter_list: %w{ pad pmh swa })
42
+
43
+ expect(soap).to include("<crs>CDF</crs>")
44
+ end
45
+
46
+ it "correctly encodes the filterList field" do
47
+ soap = to_soap_str(crs: "cdf", filter_list: %w{ pad pmh swa })
48
+
49
+ # strip newlines and indents to make matching easier
50
+ soap.gsub!(/\n\s*/, "")
51
+
52
+ expect(soap).to include("<filterList><crs>PAD</crs><crs>PMH</crs><crs>SWA</crs></filterList>")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ require "spec_helper"
2
+ require "helpers/request_helper"
3
+
4
+ describe Ldbws::Request::GetServiceDetails do
5
+ include RequestHelper
6
+
7
+ let (:subject) { Ldbws::Request::GetServiceDetails }
8
+
9
+ context :to_soap do
10
+ it "upcases the service ID" do
11
+ soap = to_soap_str(service_id: "1234567crdfcen_")
12
+
13
+ expect(soap).to include("<serviceID>1234567CRDFCEN_</serviceID>")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,73 @@
1
+ require "spec_helper"
2
+ require "helpers/request_helper"
3
+
4
+ describe Ldbws::Request::GetStationBoard do
5
+ include RequestHelper
6
+
7
+ let (:subject) { Ldbws::Request::GetStationBoard }
8
+
9
+ context :validation do
10
+ context :crs do
11
+ it "requires the parameter" do
12
+ expect_error({}, :crs, "is missing")
13
+ end
14
+
15
+ it "enforces the length requirement" do
16
+ expect_error({ crs: "x" }, :crs, "length must be 3")
17
+ expect_error({ crs: "xxxx" }, :crs, "length must be 3")
18
+ end
19
+
20
+ it "works with something that looks like a CRS (inexhaustive validation)" do
21
+ expect_ok(crs: "xxx")
22
+ end
23
+ end
24
+
25
+ context :filter_crs do
26
+ it "enforces the length requirement" do
27
+ expect_error({ filter_crs: "x" }, :filter_crs, "length must be 3")
28
+ expect_error({ filter_crs: "xxxx" }, :filter_crs, "length must be 3")
29
+ end
30
+
31
+ it "works with something that looks like a CRS (inexhaustive validation)" do
32
+ expect_ok(crs: "xxx", filter_crs: "abc")
33
+ end
34
+ end
35
+
36
+ context :filter_type do
37
+ it "doesn’t allow arbitrary values" do
38
+ expect_error({ filter_type: "wibble" }, :filter_type, "must be one of: to, from")
39
+ end
40
+
41
+ it "allows real values" do
42
+ expect_ok(crs: "xxx", filter_type: "to")
43
+ end
44
+
45
+ it "is case-insensitive" do
46
+ expect_ok(crs: "xxx", filter_type: "from")
47
+ expect_ok(crs: "xxx", filter_type: "FROM")
48
+ expect_ok(crs: "xxx", filter_type: "fRoM")
49
+ end
50
+ end
51
+ end
52
+
53
+ context :to_soap do
54
+ it "upcases all CRSen" do
55
+ soap = to_soap_str(crs: "cdf", filter_crs: "swa")
56
+
57
+ expect(soap).to include("<crs>CDF</crs>")
58
+ expect(soap).to include("<filterCrs>SWA</filterCrs>")
59
+ end
60
+
61
+ it "downcases the filter_type" do
62
+ soap = to_soap_str(crs: "cdf", filter_crs: "swa", filter_type: "FROM")
63
+
64
+ expect(soap).to include("<filterType>from</filterType>")
65
+ end
66
+
67
+ it "removes filter_type if filter_crs is not set" do
68
+ soap = to_soap_str(crs: "cdf", filter_type: "from")
69
+
70
+ expect(soap).not_to include("<filterType>from</filterType>")
71
+ end
72
+ end
73
+ end