odeon_uk 4.0.1 → 5.1.0

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 (73) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/release.yml +73 -0
  3. data/.github/workflows/test.yml +43 -0
  4. data/.gitignore +1 -1
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +36 -0
  7. data/README.md +100 -21
  8. data/Rakefile +14 -15
  9. data/lib/odeon_uk/cinema.rb +40 -22
  10. data/lib/odeon_uk/internal/api_response.rb +19 -39
  11. data/lib/odeon_uk/internal/guest_token.rb +23 -0
  12. data/lib/odeon_uk/internal/parser/api/showtime.rb +81 -0
  13. data/lib/odeon_uk/internal/title_sanitizer.rb +3 -8
  14. data/lib/odeon_uk/performance.rb +34 -41
  15. data/lib/odeon_uk/version.rb +3 -3
  16. data/lib/odeon_uk.rb +16 -2
  17. data/odeon_uk.gemspec +10 -6
  18. data/test/fixtures/api/showtimes-824.json +66 -0
  19. data/test/fixtures/api/sites.json +32 -0
  20. data/test/lib/odeon_uk/cinema_test.rb +66 -215
  21. data/test/lib/odeon_uk/internal/api_response_test.rb +48 -29
  22. data/test/lib/odeon_uk/internal/fixture_shape_test.rb +14 -0
  23. data/test/lib/odeon_uk/internal/guest_token_test.rb +84 -0
  24. data/test/lib/odeon_uk/internal/parser/api/showtime_test.rb +77 -0
  25. data/test/lib/odeon_uk/internal/title_sanitizer_test.rb +109 -89
  26. data/test/lib/odeon_uk/performance_test.rb +129 -146
  27. data/test/lib/odeon_uk/version_test.rb +3 -3
  28. data/test/live/api_shape_test.rb +19 -0
  29. data/test/live/integration_test.rb +21 -0
  30. data/test/live/live_helper.rb +11 -0
  31. data/test/package_test.rb +56 -0
  32. data/test/support/api_fixtures_helper.rb +5 -30
  33. data/test/support/api_shapes.rb +46 -0
  34. data/test/support/fake_api_response.rb +9 -6
  35. data/test/test_helper.rb +6 -3
  36. metadata +67 -89
  37. data/.travis.yml +0 -7
  38. data/lib/odeon_uk/internal/parser/api/film_lookup.rb +0 -34
  39. data/lib/odeon_uk/internal/parser/api/performance_day.rb +0 -92
  40. data/rake/fixture_creator.rb +0 -46
  41. data/test/fixtures/api/all_cinemas.plist +0 -0
  42. data/test/fixtures/api/app_init.plist +0 -0
  43. data/test/fixtures/api/film_times/71-14876.plist +0 -0
  44. data/test/fixtures/api/film_times/71-15408.plist +0 -0
  45. data/test/fixtures/api/film_times/71-15684.plist +0 -0
  46. data/test/fixtures/api/film_times/71-15866.plist +0 -0
  47. data/test/fixtures/api/film_times/71-15868.plist +0 -0
  48. data/test/fixtures/api/film_times/71-15961.plist +0 -0
  49. data/test/fixtures/api/film_times/71-15963.plist +0 -0
  50. data/test/fixtures/api/film_times/71-15965.plist +0 -0
  51. data/test/fixtures/api/film_times/71-15967.plist +0 -0
  52. data/test/fixtures/api/film_times/71-15969.plist +0 -0
  53. data/test/fixtures/api/film_times/71-15977.plist +0 -0
  54. data/test/fixtures/api/film_times/71-15980.plist +0 -0
  55. data/test/fixtures/api/film_times/71-16037.plist +0 -0
  56. data/test/fixtures/api/film_times/71-16136.plist +0 -0
  57. data/test/fixtures/api/film_times/71-16142.plist +0 -0
  58. data/test/fixtures/api/film_times/71-16143.plist +0 -0
  59. data/test/fixtures/api/film_times/71-16187.plist +0 -0
  60. data/test/fixtures/api/film_times/71-16369.plist +0 -0
  61. data/test/fixtures/api/film_times/71-16370.plist +0 -0
  62. data/test/fixtures/api/film_times/71-16374.plist +0 -0
  63. data/test/fixtures/api/film_times/71-16386.plist +0 -0
  64. data/test/fixtures/api/film_times/71-16387.plist +0 -0
  65. data/test/fixtures/api/film_times/71-16388.plist +0 -0
  66. data/test/fixtures/api/film_times/71-16438.plist +0 -0
  67. data/test/fixtures/api/film_times/71-16454.plist +0 -0
  68. data/test/fixtures/api/film_times/71-16457.plist +0 -0
  69. data/test/fixtures/api/film_times/71-16477.plist +0 -0
  70. data/test/fixtures/api/film_times/71-16478.plist +0 -0
  71. data/test/fixtures/api/film_times/71-16480.plist +0 -0
  72. data/test/fixtures/api/film_times/71-16482.plist +0 -0
  73. data/test/fixtures/api/film_times/71-16489.plist +0 -0
@@ -1,262 +1,113 @@
1
1
  require_relative '../../test_helper'
2
2
 
3
- describe OdeonUk::Cinema do
4
- include ApiFixturesHelper
5
-
6
- let(:described_class) { OdeonUk::Cinema }
7
- let(:api_response) { FakeApiResponse.new }
8
-
9
- before { WebMock.disable_net_connect! }
10
- after { WebMock.allow_net_connect! }
11
-
12
- describe '.new(id)' do
13
- subject { described_class.new(id) }
14
-
15
- describe 'Brighton' do
16
- let(:id) { 71 }
17
-
18
- it 'returns a cinema with a string id' do
19
- subject.must_be_instance_of(described_class)
20
- subject.id.must_equal(id.to_s)
21
- end
22
- end
3
+ class OdeonUk::CinemaTest < Minitest::Test
4
+ def setup
5
+ @api_response = FakeApiResponse.new
23
6
  end
24
7
 
25
- describe '.all' do
26
- subject { described_class.all }
8
+ def test_new_returns_cinema_with_string_id
9
+ cinema = OdeonUk::Cinema.new(824)
27
10
 
28
- it 'returns an array of cinemas' do
29
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
30
- subject.must_be_instance_of(Array)
31
- subject.each do |element|
32
- element.must_be_instance_of(described_class)
33
- end
34
- subject.count.must_be :>, 100
35
- end
36
- end
11
+ assert_instance_of OdeonUk::Cinema, cinema
12
+ assert_equal '824', cinema.id
37
13
  end
38
14
 
39
- describe '#adr' do
40
- subject { described_class.new(id).adr }
41
-
42
- describe 'short address' do
43
- let(:id) { 71 }
44
-
45
- it 'returns the postcode' do
46
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
47
- subject.must_equal(street_address: 'Kingswest',
48
- extended_address: nil,
49
- locality: 'Brighton',
50
- region: nil,
51
- postal_code: 'BN1 2RE',
52
- country_name: 'United Kingdom')
53
- end
54
- end
55
- end
15
+ def test_new_zero_pads_id
16
+ assert_equal '017', OdeonUk::Cinema.new(17).id
56
17
  end
57
18
 
58
- describe '#address' do
59
- subject { described_class.new(id).address }
60
-
61
- describe 'London address' do
62
- let(:id) { 105 }
19
+ def test_all_returns_cinemas
20
+ with_fake_api do
21
+ cinemas = OdeonUk::Cinema.all
63
22
 
64
- it 'returns the postcode' do
65
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
66
- subject.must_equal(street_address: '24-26 Leicester Square',
67
- extended_address: nil,
68
- locality: 'London',
69
- region: nil,
70
- postal_code: 'WC2H 7JY',
71
- country_name: 'United Kingdom')
72
- end
73
- end
23
+ assert(cinemas.all? { |cinema| cinema.instance_of?(OdeonUk::Cinema) })
24
+ assert_equal %w(017 153 824 955), cinemas.map(&:id)
74
25
  end
75
26
  end
76
27
 
77
- describe '#brand' do
78
- subject { described_class.new(id).brand }
79
- describe 'short address' do
80
- let(:id) { 71 }
28
+ def test_adr_has_no_postcode
29
+ expected = {
30
+ street_address: '3 Example Parade',
31
+ extended_address: 'Seafront',
32
+ locality: 'Brighton',
33
+ region: nil,
34
+ postal_code: nil,
35
+ country_name: 'United Kingdom'
36
+ }
81
37
 
82
- it 'returns the postcode' do
83
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
84
- subject.must_equal 'Odeon'
85
- end
86
- end
38
+ with_fake_api do
39
+ assert_equal expected, OdeonUk::Cinema.new(824).adr
87
40
  end
88
41
  end
89
42
 
90
- describe '#country_name' do
91
- subject { described_class.new(id).country_name }
92
- describe 'short address' do
93
- let(:id) { 71 }
43
+ def test_address_for_london
44
+ expected = {
45
+ street_address: '2 Example Square',
46
+ extended_address: '',
47
+ locality: 'London',
48
+ region: nil,
49
+ postal_code: nil,
50
+ country_name: 'United Kingdom'
51
+ }
94
52
 
95
- it 'returns the postcode' do
96
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
97
- subject.must_equal 'United Kingdom'
98
- end
99
- end
53
+ with_fake_api do
54
+ assert_equal expected, OdeonUk::Cinema.new(153).address
100
55
  end
101
56
  end
102
57
 
103
- describe '#extended_address' do
104
- subject { described_class.new(id).extended_address }
105
- describe 'short address' do
106
- let(:id) { 71 }
107
-
108
- it 'returns the postcode' do
109
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
110
- subject.must_equal ''
111
- end
112
- end
113
- end
58
+ def test_brand
59
+ assert_equal 'Odeon', OdeonUk::Cinema.new(824).brand
114
60
  end
115
61
 
116
- describe '#full_name' do
117
- subject { described_class.new(id).full_name }
118
- describe 'Brighton' do
119
- let(:id) { 71 }
120
-
121
- it 'returns cinema name' do
122
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
123
- subject.must_equal 'Odeon Brighton'
124
- end
125
- end
126
- end
127
-
128
- describe 'Leicester Square' do
129
- let(:id) { 105 }
130
-
131
- it 'returns cinema name' do
132
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
133
- subject.must_equal 'Odeon Leicester Square'
134
- end
135
- end
62
+ def test_full_name
63
+ with_fake_api do
64
+ assert_equal 'Odeon Brighton', OdeonUk::Cinema.new(824).full_name
65
+ assert_equal 'Odeon Leicester Square', OdeonUk::Cinema.new(153).full_name
136
66
  end
137
67
  end
138
68
 
139
- describe '#locality' do
140
- subject { described_class.new(id).locality }
141
- describe 'Short Address (Brighton)' do
142
- let(:id) { 71 }
143
-
144
- it 'returns town name' do
145
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
146
- subject.must_equal 'Brighton'
147
- end
148
- end
69
+ def test_name_removes_london_prefix
70
+ with_fake_api do
71
+ assert_equal 'Leicester Square', OdeonUk::Cinema.new(153).name
149
72
  end
150
73
  end
151
74
 
152
- describe '#name' do
153
- subject { described_class.new(id).name }
154
- describe 'Brighton' do
155
- let(:id) { 71 }
156
-
157
- it 'returns cinema name' do
158
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
159
- subject.must_equal 'Brighton'
160
- end
161
- end
162
- end
163
-
164
- describe 'Leicester Square' do
165
- let(:id) { 105 }
166
-
167
- it 'returns cinema name' do
168
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
169
- subject.must_equal 'Leicester Square'
170
- end
171
- end
75
+ def test_postal_code_is_empty
76
+ with_fake_api do
77
+ assert_equal '', OdeonUk::Cinema.new(824).postal_code
172
78
  end
173
79
  end
174
80
 
175
- describe '#postal_code' do
176
- subject { described_class.new(id).postal_code }
177
- describe 'short address' do
178
- let(:id) { 71 }
179
-
180
- it 'returns the postcode' do
181
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
182
- subject.must_equal 'BN1 2RE'
183
- end
184
- end
185
- end
186
-
187
- describe 'short address (London)' do
188
- let(:id) { 211 }
189
-
190
- it 'returns the postcode' do
191
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
192
- subject.must_equal 'SE1 8XR'
193
- end
194
- end
195
- end
196
-
197
- describe 'short address (extra London Postcode)' do
198
- let(:id) { 105 }
199
-
200
- it 'returns the postcode' do
201
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
202
- subject.must_equal 'WC2H 7JY'
203
- end
204
- end
81
+ def test_slug
82
+ with_fake_api do
83
+ assert_equal 'odeon-brighton', OdeonUk::Cinema.new(824).slug
205
84
  end
206
85
  end
207
86
 
208
- describe '#region' do
209
- subject { described_class.new(id).region }
210
- describe 'short address' do
211
- let(:id) { 71 }
212
-
213
- it 'returns the postcode' do
214
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
215
- subject.must_equal ''
216
- end
217
- end
87
+ def test_url
88
+ with_fake_api do
89
+ assert_equal 'https://www.odeon.co.uk/cinemas/brighton/',
90
+ OdeonUk::Cinema.new(824).url
218
91
  end
219
92
  end
220
93
 
221
- describe '#slug' do
222
- subject { described_class.new(id).slug }
223
-
224
- describe 'short address' do
225
- let(:id) { 71 }
226
-
227
- it 'returns first line of address' do
228
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
229
- subject.must_equal 'odeon-brighton'
230
- end
231
- end
94
+ def test_url_keeps_london_prefix
95
+ with_fake_api do
96
+ assert_equal 'https://www.odeon.co.uk/cinemas/london-leicester-square/',
97
+ OdeonUk::Cinema.new(153).url
232
98
  end
233
99
  end
234
100
 
235
- describe '#street_address' do
236
- subject { described_class.new(id).street_address }
237
-
238
- describe 'short address' do
239
- let(:id) { 71 }
240
-
241
- it 'returns first line of address' do
242
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
243
- subject.must_equal 'Kingswest'
244
- end
245
- end
101
+ def test_url_uses_slug_that_differs_from_name
102
+ with_fake_api do
103
+ assert_equal 'https://www.odeon.co.uk/cinemas/manchester-trafford-centre/',
104
+ OdeonUk::Cinema.new(955).url
246
105
  end
247
106
  end
248
107
 
249
- describe '#url' do
250
- subject { described_class.new(id).url }
108
+ private
251
109
 
252
- describe 'short address' do
253
- let(:id) { 71 }
254
-
255
- it 'returns first line of address' do
256
- OdeonUk::Internal::ApiResponse.stub :new, api_response do
257
- subject.must_equal 'http://www.odeon.co.uk/cinemas/brighton/71/'
258
- end
259
- end
260
- end
110
+ def with_fake_api(&block)
111
+ OdeonUk::Internal::ApiResponse.stub(:new, @api_response, &block)
261
112
  end
262
113
  end
@@ -1,47 +1,66 @@
1
1
  require_relative '../../../test_helper'
2
2
 
3
- describe OdeonUk::Internal::ApiResponse do
3
+ class OdeonUk::Internal::ApiResponseTest < Minitest::Test
4
4
  include ApiFixturesHelper
5
5
 
6
- let(:described_class) { OdeonUk::Internal::ApiResponse }
6
+ BASE_URL = 'https://vwc.odeon.co.uk/WSVistaWebClient/ocapi/v1'.freeze
7
+ HEADERS = {
8
+ 'Authorization' => 'Bearer guest-jwt', 'Connect-Region-Code' => 'UK'
9
+ }.freeze
7
10
 
8
- before { WebMock.disable_net_connect! }
9
- after { WebMock.allow_net_connect! }
11
+ def setup
12
+ OdeonUk.token = 'guest-jwt'
13
+ end
14
+
15
+ def teardown
16
+ OdeonUk.token = nil
17
+ super
18
+ end
19
+
20
+ def test_sites
21
+ stub_request(:get, "#{BASE_URL}/sites")
22
+ .with(headers: HEADERS)
23
+ .to_return(status: 200, body: sites_json)
24
+
25
+ sites = api.sites
26
+
27
+ assert_kind_of Array, sites
28
+ assert_includes sites.first.keys, 'id'
29
+ end
10
30
 
11
- describe '#app_init' do
12
- subject { described_class.new.app_init }
31
+ def test_showtimes
32
+ stub_request(:get, "#{BASE_URL}/showtimes/by-business-date/2026-10-24")
33
+ .with(query: { 'siteIds' => '824' }, headers: HEADERS)
34
+ .to_return(status: 200, body: showtimes_json('824'))
13
35
 
14
- before { stub_post('app-init', nil, app_init_plist) }
36
+ showtimes = api.showtimes('824', Date.new(2026, 10, 24))
15
37
 
16
- it 'returns a hash' do
17
- subject.class.must_equal Hash
18
- subject.keys.must_equal(%w(films offers))
19
- end
38
+ assert_equal %w(businessDate relatedData showtimes), showtimes.keys.sort
20
39
  end
21
40
 
22
- describe '#all_cinemas' do
23
- subject { described_class.new.all_cinemas }
41
+ def test_missing_token_raises_before_request
42
+ OdeonUk.token = nil
43
+
44
+ assert_raises(OdeonUk::AuthenticationError) { api.sites }
45
+ end
24
46
 
25
- before { stub_post('all-cinemas', nil, all_cinemas_plist) }
47
+ def test_rejected_token_raises
48
+ stub_request(:get, "#{BASE_URL}/sites").to_return(status: 401)
26
49
 
27
- it 'returns a hash' do
28
- subject.class.must_equal Hash
29
- subject.keys.must_equal(%w(sites))
30
- end
50
+ assert_raises(OdeonUk::AuthenticationError) { api.sites }
31
51
  end
32
52
 
33
- describe '#showtimes(id)' do
34
- subject { described_class.new.film_times(71, 15_130) }
53
+ def test_server_error_omits_body
54
+ stub_request(:get, "#{BASE_URL}/sites").to_return(status: 500, body: 'secret')
55
+
56
+ error = assert_raises(RuntimeError) { api.sites }
57
+
58
+ assert_equal 'Vista HTTP error 500', error.message
59
+ end
35
60
 
36
- before do
37
- stub_post('film-times',
38
- { s: '71', m: '15130' },
39
- film_times_plist_random)
40
- end
61
+ private
41
62
 
42
- it 'returns an array' do
43
- subject.class.must_equal Array
44
- subject.each { |group| group.keys.must_equal(%w(date attributes)) }
45
- end
63
+ def api
64
+ OdeonUk::Internal::ApiResponse.new
46
65
  end
47
66
  end
@@ -0,0 +1,14 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class FixtureShapeTest < Minitest::Test
4
+ include ApiFixturesHelper
5
+ include ApiShapes
6
+
7
+ def test_sites_fixture_matches_shape
8
+ assert_sites_shape(JSON.parse(sites_json))
9
+ end
10
+
11
+ def test_showtimes_fixture_matches_shape
12
+ assert_showtimes_shape(JSON.parse(showtimes_json('824')))
13
+ end
14
+ end
@@ -0,0 +1,84 @@
1
+ require_relative '../../../test_helper'
2
+ require 'base64'
3
+ require 'tmpdir'
4
+
5
+ class OdeonUk::Internal::GuestTokenTest < Minitest::Test
6
+ def setup
7
+ @directory = Dir.mktmpdir('odeon-guest-token')
8
+ end
9
+
10
+ def teardown
11
+ FileUtils.remove_entry(@directory)
12
+ OdeonUk.token = nil
13
+ end
14
+
15
+ def test_fetch_returns_token_from_page_data
16
+ token = token_expiring_at(Time.now + 3600)
17
+ curl = fake_curl("#{page_with(token)}\n200")
18
+
19
+ assert_equal token, guest_token(curl).fetch
20
+ end
21
+
22
+ def test_fetch_rejects_expired_token
23
+ curl = fake_curl("#{page_with(token_expiring_at(Time.now - 60))}\n200")
24
+
25
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
26
+ end
27
+
28
+ def test_fetch_rejects_page_without_page_data
29
+ curl = fake_curl("<html></html>\n200")
30
+
31
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
32
+ end
33
+
34
+ def test_fetch_rejects_redirect_to_queue
35
+ curl = fake_curl("\n302")
36
+
37
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
38
+ end
39
+
40
+ def test_fetch_rejects_failed_command
41
+ curl = fake_curl('', exit_status: 7)
42
+
43
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
44
+ end
45
+
46
+ def test_fetch_explains_missing_executable
47
+ error = assert_raises(OdeonUk::AuthenticationError) do
48
+ guest_token(File.join(@directory, 'missing')).fetch
49
+ end
50
+
51
+ assert_match(/curl-impersonate/, error.message)
52
+ end
53
+
54
+ def test_acquire_token_sets_token
55
+ token = token_expiring_at(Time.now + 3600)
56
+
57
+ OdeonUk.acquire_token!(curl: fake_curl("#{page_with(token)}\n200"))
58
+
59
+ assert_equal token, OdeonUk.token
60
+ end
61
+
62
+ private
63
+
64
+ def guest_token(curl)
65
+ OdeonUk::Internal::GuestToken.new(curl: curl)
66
+ end
67
+
68
+ def token_expiring_at(time)
69
+ payload = Base64.urlsafe_encode64(JSON.generate('exp' => time.to_i), padding: false)
70
+ "header.#{payload}.signature"
71
+ end
72
+
73
+ def page_with(token)
74
+ data = { 'api' => { 'apiUrl' => 'https://vwc.odeon.co.uk/WSVistaWebClient', 'authToken' => token } }
75
+ "<script>\n window.initialData = #{JSON.generate(data)};\n window.pageData = [];\n</script>"
76
+ end
77
+
78
+ def fake_curl(output, exit_status: 0)
79
+ path = File.join(@directory, 'curl-impersonate')
80
+ File.write(path, "#!#{RbConfig.ruby}\nSTDOUT.write(#{output.dump})\nexit #{exit_status}\n")
81
+ File.chmod(0o755, path)
82
+ path
83
+ end
84
+ end
@@ -0,0 +1,77 @@
1
+ require_relative '../../../../../test_helper'
2
+
3
+ class OdeonUk::Internal::Parser::Api::ShowtimeTest < Minitest::Test
4
+ FILMS = {
5
+ 'HO1' => { 'title' => { 'text' => 'Jailer 2 (Tamil)' } },
6
+ 'HO2' => { 'title' => { 'text' => 'Cine-Excess 2026: Scum Of The Earth' } },
7
+ 'HO3' => { 'title' => { 'text' => 'From Dusk Till Dawn (1996)' } }
8
+ }.freeze
9
+
10
+ ATTRIBUTES = {
11
+ '1' => { 'shortName' => { 'text' => 'TamilA' }, 'name' => { 'text' => 'Tamil (Audio)' } },
12
+ '2' => { 'shortName' => { 'text' => 'EnglishSub' }, 'name' => { 'text' => 'English (Sub)' } },
13
+ '3' => { 'shortName' => { 'text' => 'IMAX' }, 'name' => { 'text' => 'IMAX' } },
14
+ '4' => { 'shortName' => { 'text' => 'OpenCapt' }, 'name' => { 'text' => 'Open Captioned' } },
15
+ '5' => { 'shortName' => { 'text' => 'Wheelchair' }, 'name' => { 'text' => 'Wheelchair Accessible' } }
16
+ }.freeze
17
+
18
+ def test_to_hash
19
+ expected = {
20
+ booking_url: 'https://www.odeon.co.uk/ticketing/seat-picker/?showtimeId=824-1',
21
+ dimension: '2d',
22
+ festival: nil,
23
+ film_name: 'Jailer 2',
24
+ film_year: nil,
25
+ language: nil,
26
+ starting_at: Time.utc(2026, 10, 24, 17, 30),
27
+ variant: []
28
+ }
29
+
30
+ assert_equal expected, parse
31
+ end
32
+
33
+ def test_3d_glasses
34
+ assert_equal '3d', parse(requires3d: true)[:dimension]
35
+ end
36
+
37
+ def test_audio_language
38
+ assert_equal 'Tamil', parse(attribute_ids: %w(1 2 3 5))[:language]
39
+ end
40
+
41
+ def test_maps_known_attributes_to_variants
42
+ assert_equal %w(subtitled imax), parse(attribute_ids: %w(1 2 3 5))[:variant]
43
+ end
44
+
45
+ def test_festival_moves_from_title_to_attribute
46
+ showtime = parse(film_id: 'HO2')
47
+
48
+ assert_equal Cinebase::Festival.new(name: 'Cine-Excess', year: 2026), showtime[:festival]
49
+ assert_equal 'Scum Of The Earth', showtime[:film_name]
50
+ end
51
+
52
+ def test_film_year_moves_from_title_to_attribute
53
+ showtime = parse(film_id: 'HO3')
54
+
55
+ assert_equal 1996, showtime[:film_year]
56
+ assert_equal 'From Dusk Till Dawn', showtime[:film_name]
57
+ end
58
+
59
+ def test_open_captions_and_subtitles_are_one_variant
60
+ assert_equal %w(subtitled), parse(attribute_ids: %w(2 4))[:variant]
61
+ end
62
+
63
+ private
64
+
65
+ def parse(attribute_ids: [], requires3d: false, film_id: 'HO1')
66
+ data = {
67
+ 'id' => '824-1',
68
+ 'filmId' => film_id,
69
+ 'schedule' => { 'startsAt' => '2026-10-24T18:30:00+01:00' },
70
+ 'attributeIds' => attribute_ids,
71
+ 'requires3dGlasses' => requires3d
72
+ }
73
+ OdeonUk::Internal::Parser::Api::Showtime.new(
74
+ data, films: FILMS, attributes: ATTRIBUTES
75
+ ).to_hash
76
+ end
77
+ end