ldbws 1.0.2 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +1 -1
  3. data/.ruby-version +1 -0
  4. data/Gemfile.lock +147 -49
  5. data/LICENSE +1 -1
  6. data/README.md +17 -11
  7. data/doc/LICENSE.html +403 -47
  8. data/doc/Ldbws/Request/GetDeparturesBoard.html +487 -54
  9. data/doc/Ldbws/Request/GetDeparturesBoardWithDetails.html +487 -54
  10. data/doc/Ldbws/Request/GetServiceDetails.html +484 -51
  11. data/doc/Ldbws/Request/GetStationBoard.html +489 -56
  12. data/doc/Ldbws/Request/GetStationBoardWithDetails.html +489 -56
  13. data/doc/Ldbws/Request/ParamValidationError.html +538 -69
  14. data/doc/Ldbws/Request.html +470 -47
  15. data/doc/Ldbws/RequestError.html +535 -72
  16. data/doc/Ldbws/ResponseParsingError.html +475 -49
  17. data/doc/Ldbws/ResponseTypes/BaseStationBoard.html +565 -56
  18. data/doc/Ldbws/ResponseTypes/CallingPoint.html +498 -65
  19. data/doc/Ldbws/ResponseTypes/Coach.html +487 -54
  20. data/doc/Ldbws/ResponseTypes/DepartureItem.html +485 -52
  21. data/doc/Ldbws/ResponseTypes/DepartureItemWithDetails.html +485 -52
  22. data/doc/Ldbws/ResponseTypes/DeparturesBoard.html +484 -51
  23. data/doc/Ldbws/ResponseTypes/DeparturesBoardWithDetails.html +484 -51
  24. data/doc/Ldbws/ResponseTypes/Formation.html +485 -52
  25. data/doc/Ldbws/ResponseTypes/LoadingCategory.html +486 -53
  26. data/doc/Ldbws/ResponseTypes/Location.html +486 -53
  27. data/doc/Ldbws/ResponseTypes/ServiceDetails.html +510 -77
  28. data/doc/Ldbws/ResponseTypes/ServiceItem.html +503 -70
  29. data/doc/Ldbws/ResponseTypes/ServiceItemWithCallingPoints.html +485 -52
  30. data/doc/Ldbws/ResponseTypes/StationBoard.html +486 -53
  31. data/doc/Ldbws/ResponseTypes/StationBoardWithDetails.html +486 -53
  32. data/doc/Ldbws/ResponseTypes/ToiletAvailability.html +485 -52
  33. data/doc/Ldbws/ResponseTypes.html +470 -47
  34. data/doc/Ldbws/Service.html +1083 -245
  35. data/doc/Ldbws.html +520 -73
  36. data/doc/README_md.html +14 -20
  37. data/doc/created.rid +19 -20
  38. data/doc/css/rdoc.css +1926 -378
  39. data/doc/index.html +441 -108
  40. data/doc/js/aliki.js +489 -0
  41. data/doc/js/bash_highlighter.js +167 -0
  42. data/doc/js/c_highlighter.js +299 -0
  43. data/doc/js/navigation.js.gz +0 -0
  44. data/doc/js/search_controller.js +129 -0
  45. data/doc/js/search_data.js +1 -0
  46. data/doc/js/search_index.js +1 -1
  47. data/doc/js/search_index.js.gz +0 -0
  48. data/doc/js/search_navigation.js +105 -0
  49. data/doc/js/search_ranker.js +239 -0
  50. data/doc/js/searcher.js.gz +0 -0
  51. data/doc/js/theme-toggle.js +112 -0
  52. data/doc/table_of_contents.html +2 -3
  53. data/ldbws.gemspec +7 -6
  54. data/lib/ldbws/request/base.rb +0 -3
  55. data/lib/ldbws/request/get_departures_board.rb +5 -1
  56. data/lib/ldbws/request/get_service_details.rb +5 -1
  57. data/lib/ldbws/request/get_station_board.rb +14 -1
  58. data/lib/ldbws/response_types/calling_point.rb +2 -2
  59. data/lib/ldbws/response_types/station_board.rb +6 -0
  60. data/lib/ldbws/service.rb +2 -4
  61. data/lib/ldbws/version.rb +1 -1
  62. data/spec/helpers/request_helper.rb +27 -0
  63. data/spec/request/get_departures_board_spec.rb +55 -0
  64. data/spec/request/get_service_details_spec.rb +16 -0
  65. data/spec/request/get_station_board_spec.rb +73 -0
  66. metadata +29 -21
  67. 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
+ }
Binary file
@@ -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
+ })();
@@ -34,9 +34,8 @@
34
34
  <a href="README_md.html">README</a>
35
35
 
36
36
  <ul>
37
- <li><a href="README_md.html#label-ldbws-ruby">ldbws-ruby</a>
38
- <li><a href="README_md.html#label-HOWTO">HOWTO</a>
39
- <li><a href="README_md.html#label-A+note+about+rate+limiting">A note about rate limiting</a>
37
+ <li><a href="README_md.html#label-ldbws">ldbws</a>
38
+ <li><a href="README_md.html#label-Installation-2Fusage">Installation/usage</a>
40
39
  <li><a href="README_md.html#label-Error-handling">Error-handling</a>
41
40
  <li><a href="README_md.html#label-Ldbws-3A-3ARequest-3A-3AParamValidationError"><code>Ldbws::Request::ParamValidationError</code></a>
42
41
  <li><a href="README_md.html#label-Ldbws-3A-3ARequestError"><code>Ldbws::RequestError</code></a>
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
@@ -14,8 +14,10 @@ module Ldbws::Request
14
14
  class GetDeparturesBoard < Base
15
15
  # :nodoc:
16
16
  SCHEMA = Dry::Schema.Params do
17
+ config.validate_keys = true
18
+
17
19
  required(:crs).filled(Types::Crs)
18
- required(:filter_list).array(Types::Crs, min_size?: 1)
20
+ required(:filter_list).value(array[Types::Crs], min_size?: 1)
19
21
  optional(:time_offset).filled(:integer)
20
22
  optional(:time_window).filled(:integer)
21
23
  end
@@ -26,6 +28,8 @@ module Ldbws::Request
26
28
  # :nodoc:
27
29
  RESULT_TYPE = Ldbws::ResponseTypes::DeparturesBoard
28
30
 
31
+ protected
32
+
29
33
  # :nodoc:
30
34
  def to_soap_params
31
35
  @params.tap do |params|
@@ -10,6 +10,8 @@ module Ldbws::Request
10
10
  class GetServiceDetails < Base
11
11
  # :nodoc:
12
12
  SCHEMA = Dry::Schema.Params do
13
+ config.validate_keys = true
14
+
13
15
  required(:service_id).filled(:string)
14
16
  end
15
17
 
@@ -19,10 +21,12 @@ module Ldbws::Request
19
21
  # :nodoc:
20
22
  RESULT_TYPE = Ldbws::ResponseTypes::ServiceDetails
21
23
 
24
+ protected
25
+
22
26
  # :nodoc:
23
27
  def to_soap_params
24
28
  {
25
- serviceID: @params[:service_id],
29
+ serviceID: @params[:service_id].upcase,
26
30
  }
27
31
  end
28
32
  end
@@ -16,10 +16,12 @@ module Ldbws::Request
16
16
  class GetStationBoard < Base
17
17
  # :nodoc:
18
18
  SCHEMA = Dry::Schema.Params do
19
+ config.validate_keys = true
20
+
19
21
  required(:crs).filled(Types::Crs)
20
22
  optional(:num_rows).filled(:integer)
21
23
  optional(:filter_crs).filled(Types::Crs)
22
- optional(:filter_type).filled(Types::FilterType)
24
+ optional(:filter_type).filled(Types::String.enum("to", "from"))
23
25
  optional(:time_offset).filled(:integer)
24
26
  optional(:time_window).filled(:integer)
25
27
  end
@@ -30,12 +32,23 @@ module Ldbws::Request
30
32
  # :nodoc:
31
33
  RESULT_TYPE = Ldbws::ResponseTypes::StationBoard
32
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
+
33
45
  # :nodoc:
34
46
  def to_soap_params
35
47
  @params.tap do |params|
36
48
  # CRSes should always be upcased
37
49
  params[:crs].upcase!
38
50
  params[:filter_crs].upcase! if params[:filter_crs]
51
+ params[:filter_type].downcase! if params[:filter_type]
39
52
 
40
53
  # filter type isn’t needed if we’re not filtering
41
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/service.rb CHANGED
@@ -202,17 +202,15 @@ module Ldbws
202
202
  end
203
203
 
204
204
  def perform_soap_request(action, body)
205
- uri = URI(SERVICE_URI)
206
-
207
205
  conn = Faraday.new(
208
- url: "#{uri.scheme}://#{uri.host}",
206
+ url: "#{SERVICE_URI.scheme}://#{SERVICE_URI.host}",
209
207
  headers: {
210
208
  'Content-Type': "text/xml",
211
209
  'SOAPAction': action,
212
210
  },
213
211
  )
214
212
 
215
- conn.post(uri.path, body).body
213
+ conn.post(SERVICE_URI.path, body).body
216
214
  end
217
215
 
218
216
  def extract_error(xml)
data/lib/ldbws/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Ldbws
2
2
  # The current gem version.
3
- VERSION = "1.0.2"
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