|
| 1 | +require File.expand_path('../test_helper', __dir__) |
| 2 | + |
| 3 | +class DatetimeFormatTest < ActiveSupport::TestCase |
| 4 | + include IssueDatetimeTestHelper |
| 5 | + |
| 6 | + fixtures :projects, :users, :email_addresses, :trackers, :projects_trackers, |
| 7 | + :issue_statuses, :issues, :enumerations, :enabled_modules, |
| 8 | + :members, :member_roles, :roles, :custom_fields, :custom_values |
| 9 | + |
| 10 | + def setup |
| 11 | + enable_issue_datetime(Issue.find(1).tracker_id, zone: 'Tokyo') |
| 12 | + @field = IssueCustomField.create!(name: 'Inspection at', field_format: 'datetime', |
| 13 | + is_for_all: true, tracker_ids: Tracker.pluck(:id)) |
| 14 | + @issue = Issue.find(1) |
| 15 | + end |
| 16 | + |
| 17 | + test 'the format is registered and offered alongside date' do |
| 18 | + names = Redmine::FieldFormat.available_formats |
| 19 | + |
| 20 | + assert_includes names, 'datetime' |
| 21 | + assert_includes names, 'date', 'the built-in date format must remain available' |
| 22 | + end |
| 23 | + |
| 24 | + test 'the format is labelled for the picker' do |
| 25 | + assert_equal 'label_datetime', Redmine::FieldFormat.find('datetime').label |
| 26 | + end |
| 27 | + |
| 28 | + test 'a naive local value round-trips through the custom field' do |
| 29 | + @issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:15'} |
| 30 | + |
| 31 | + assert @issue.save |
| 32 | + assert_equal '2026-08-03T09:15', @issue.reload.custom_field_value(@field) |
| 33 | + end |
| 34 | + |
| 35 | + # Stored naive, interpreted in the reference zone: the cast must land on the |
| 36 | + # same wall clock the user typed, not shift it. |
| 37 | + test 'casting interprets the stored value in the reference zone' do |
| 38 | + cast = @field.format.cast_single_value(@field, '2026-08-03T09:15') |
| 39 | + |
| 40 | + assert_equal 'Asia/Tokyo', cast.time_zone.tzinfo.name |
| 41 | + assert_equal '09:15', cast.strftime('%H:%M') |
| 42 | + assert_equal Time.utc(2026, 8, 3, 0, 15), cast.utc |
| 43 | + end |
| 44 | + |
| 45 | + test 'a value without a time is rejected' do |
| 46 | + @issue.custom_field_values = {@field.id.to_s => '2026-08-03'} |
| 47 | + |
| 48 | + assert_not @issue.save |
| 49 | + end |
| 50 | + |
| 51 | + test 'a value with a zone offset is rejected, since storage is naive' do |
| 52 | + @issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:15+09:00'} |
| 53 | + |
| 54 | + assert_not @issue.save |
| 55 | + end |
| 56 | + |
| 57 | + test 'an impossible time is rejected' do |
| 58 | + @issue.custom_field_values = {@field.id.to_s => '2026-08-03T25:99'} |
| 59 | + |
| 60 | + assert_not @issue.save |
| 61 | + end |
| 62 | + |
| 63 | + test 'a blank value is allowed when the field is not required' do |
| 64 | + @issue.custom_field_values = {@field.id.to_s => ''} |
| 65 | + |
| 66 | + assert @issue.save |
| 67 | + end |
| 68 | + |
| 69 | + # The year must always be present: a locale default such as :short renders |
| 70 | + # "03 Aug 09:15", which is ambiguous for anything not in the current year. |
| 71 | + test 'formatting shows the full date and the time' do |
| 72 | + formatted = @field.format.formatted_value(nil, @field, '2026-08-03T09:15') |
| 73 | + |
| 74 | + assert_includes formatted, '09:15' |
| 75 | + assert_includes formatted, '2026' |
| 76 | + end |
| 77 | + |
| 78 | + test 'formatting honours the admin date and time settings' do |
| 79 | + with_settings date_format: '%d/%m/%Y', time_format: '%H:%M' do |
| 80 | + assert_equal '03/08/2026 09:15', |
| 81 | + @field.format.formatted_value(nil, @field, '2026-08-03T09:15') |
| 82 | + end |
| 83 | + end |
| 84 | + |
| 85 | + # Values are shown on one clock for everyone, so formatting must not follow the |
| 86 | + # viewer's own time zone the way Redmine's format_time helper would. |
| 87 | + test 'formatting does not follow the viewer time zone' do |
| 88 | + # Captured before anything that can raise, so the ensure block always |
| 89 | + # restores rather than assuming what the surrounding state was. |
| 90 | + previous_user = User.current |
| 91 | + user = User.find(2) |
| 92 | + original_zone = user.pref.time_zone |
| 93 | + User.current = user |
| 94 | + user.pref.update(time_zone: 'UTC') |
| 95 | + |
| 96 | + assert_includes @field.format.formatted_value(nil, @field, '2026-08-03T09:15'), '09:15' |
| 97 | + ensure |
| 98 | + user&.pref&.update(time_zone: original_zone) |
| 99 | + User.current = previous_user |
| 100 | + end |
| 101 | + |
| 102 | + test 'formatting a blank value yields an empty string, not an error' do |
| 103 | + assert_equal '', @field.format.formatted_value(nil, @field, '') |
| 104 | + end |
| 105 | + |
| 106 | + # ISO 8601 sorts correctly as a plain string, which is why storage uses it: |
| 107 | + # ordering needs no special casing. |
| 108 | + test 'stored values sort chronologically as strings' do |
| 109 | + values = ['2026-08-03T09:15', '2026-08-03T08:00', '2026-01-15T23:59', '2026-08-03T10:00'] |
| 110 | + |
| 111 | + assert_equal ['2026-01-15T23:59', '2026-08-03T08:00', '2026-08-03T09:15', '2026-08-03T10:00'], |
| 112 | + values.sort |
| 113 | + end |
| 114 | + |
| 115 | + test 'the filter is declared as a datetime so the framework can filter on it' do |
| 116 | + assert_equal({type: :datetime}, @field.format.query_filter_options(@field, nil)) |
| 117 | + end |
| 118 | + |
| 119 | + # Unlike the start/due times, an arbitrary datetime field is not forced onto |
| 120 | + # the interval grid: it may legitimately record an off-grid moment. |
| 121 | + test 'an off-grid minute is accepted' do |
| 122 | + @issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:07'} |
| 123 | + |
| 124 | + assert @issue.save |
| 125 | + assert_equal '2026-08-03T09:07', @issue.reload.custom_field_value(@field) |
| 126 | + end |
| 127 | +end |
0 commit comments