diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c0bf28ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/ruby_prof_reports +/stackprof_reports + +data_1k.txt +data_10k.txt +data_100k.txt +data_large.txt +data_large.txt.gz +data_small.txt + +.ruby-version +result.json + diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..f959bd79 --- /dev/null +++ b/case-study.md @@ -0,0 +1,93 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: количество потребляемой памяти при обработке файла в 10 000 строк. + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. +Переписал тест используя Rspec matchers - изменил логику проверки соответствия ожидаемого и полученного результата (сравниваются значения по ключам хэшей) + +Также добавил проверку на количество потребляемой памяти, используя `perform_allocation` + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений. + +Вот как я построил `feedback_loop`: +1. Написал тест используя Rspec, с проверкой логики выполнения и количеству потребляемой памяти. *(после каждой оптимизации проверяю)* +2. Подготовил файлы для профилирования: + - memory_profiler.rb - для отдельного профилирования с помощью гема `Memory profiler` + - profilers.rb - генерирует отчёты для `Ruby-prof` в режиме профилирования аллокаций в формате Flat, Graph, Callstac, а также в режиме профилирования памяти в формате CallTree. И генерирует отчёты для `Stackprof` - пользовался редко, в основном смотрел визуализацию графа. + +## Вникаем в детали системы, чтобы найти главные точки роста +Смотрим, как обстоят дела с фризом строк. Его нет, и на 20 000 строках программа занимает 520МБ. Добавляем фриз, получаем - 515 МБ. Хорошо + +Изучаем, что показывает `GS.stat`, `ObjectSpace.count`. На 10 000 строках видим работу GC: +```log +:total_allocated_objects => 398540, +:total_freed_objects => 282935, +``` +Было освобождено достаточно много объектов, это говорит о том, что GC в Ruby v3.3.0 работает достаточно эффективно. Также это подтверждается замером с помощью `Memory profiler` - всего было использовано 767 MB, а осталось 4,24 KB (в уроке оставалось 5,76 МБ) + +Использование Memory profiler начинает показывать первые точки роста + +### Ваша находка №1 +- MemoryProfiler показывает главную точку роста - неэффективное добавление элементов в массив - при заполнении массивов sessions и users. +- переписал добавление элемента в массив без инициирования дополнительных элементов +- Метрика изменилась с 767 МБ до 245 МБ +- Неэффективное сложение массивов использовалось в двух местах: 506 МБ и 18 МБ. После оптимизации оба метода стали использовать одинаковое количество памяти - 530 КБ, хотя в первом месте метод вызывается чаще в несколько раз. Интересно... + +### Ваша находка №2 +- Stackprof, Graphviz.dot, Ruby-prof:flat - показывают, что по количеству аллокаций Array#select является точкой роста +- Чтобы улучшить производительность создал вспомогательную хэш-таблицу в которой сгруппировал сессии по *user_id* +- Метрика изменилась с 245 МБ до 63 МБ +- Array#select занимал 182 МБ, теперь 26 МБ + +### Ваша находка №3 +- MemoryProfiler показывает что второй случай со сложением массивов начинает являться точкой роста с 17 МБ. Оптимизируем как и в первом кейсе. +- Метрика изменилась с 63 МБ до 46 МБ +- строка со сложением стала занимать 730 КБ вместо 17 МБ, количество всех массивов уменьшилось с 65028 до 60972 + +### Ваша находка №4 +- MemoryProfiler указывает на строку с парсингом даты. Оптимизируем +- Метрика изменилась с 46 МБ до 35 МБ +- строка с датой занимала *13 МБ* и аллоцировала *171163 (!)* объекта. После оптимизации стала занимать *1 МБ с 9929 объектами*. Очень наглядная оптимизация :smirk: + +### Ваша находка №5 +- Ruby-prof: flat, graph, callstack, KCacheGrind показывает что Array#all? является точкой роста. Избавляемся от его применения, так как этот метод создаёт ещё три дополнительных объекта +- Метрика практически не изменилась: с 35 до 34 МБ +- выполнение кода в строке занимало 1 МБ, стало 183 КБ + +### Ваша находка №5 +- Дальнейшее профилирование указывает на split (это необходимо) и генерацию и накопление массивов с пользователями и сессиями. +- Поэтому решаем переписать программу на потоковый режим работы. В память будет загружаться информация о пользователе и его сессиях, считаться статистика, и записываться в файл. После чего в память будет загружаться следующий пользователь. +- Метрика показывает результат выполнения 21 МБ. Причём как на 10_000, так и на 3_250_940 строк (файл data_large.txt). Это означает, что мы вложились в бюджет (< 70 МБ). Ура! :smiley: + +### Остальные находки +- В процессе оптимизации программы визуально выявляются ещё достаточное количество мест, которые, как кажется, можно оптимизировать. Но так как это будет хлопотно и нецелесообразно, а в бюджет уже укладываемся - то оставляем без изменений +- После оптимизации программы решил проверить как влияет фриз строк после оптимизации, и выяснил интересный момент - на 100 000 строках: + - 146 МБ - без фриза + - 139 МБ - с фризом +Получается, данная оптимизация не зависит от количества данных, а зависит от количества использования String в программе? + +### Замер скорости +- После оптимизации по памяти провели тестирование на времени выполнения программы. С файлом data_large.txt оно составило - **всего лишь 22 секунды(!)** против 32 сек до оптимизации памяти. Возможно, разница больше, так как замеры выполнялись на разных конфигурациях VM + +## Результаты +- В результате проделанной оптимизации наконец удалось обработать файл с данными. Удалось улучшить метрику системы с 767 МБ до 29 МБ и уложиться в заданный бюджет < 70 MB. +- Приобрел практические навыки в оптимизации используемой памяти в работе приложений +- На практике выявил сильную взаимосвязь между оптимизацией CPU и памятью +- Закрепил навыки по построению эффективного `Feedback-loop` + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал тест для проверки потребляемой памяти и количества аллоцированных объектов. diff --git a/memory_profiler.rb b/memory_profiler.rb new file mode 100644 index 00000000..22939671 --- /dev/null +++ b/memory_profiler.rb @@ -0,0 +1,12 @@ +# memory_profiler (ruby 2.3.8+) +# allocated - total memory allocated during profiler run +# retained - survived after MemoryProfiler finished + +require_relative 'work_method.rb' +require 'benchmark' +require 'memory_profiler' + +report = MemoryProfiler.report do + work('data_100k.txt', disable_gc: false) +end +report.pretty_print(scale_bytes: true) diff --git a/profilers.rb b/profilers.rb new file mode 100644 index 00000000..5ea8e5d9 --- /dev/null +++ b/profilers.rb @@ -0,0 +1,52 @@ +# Stackprof ObjectAllocations and Flamegraph +# +# Text: +# stackprof stackprof_reports/stackprof.dump +# stackprof stackprof_reports/stackprof.dump --method Object#work +# +# Graphviz: +# stackprof --graphviz stackprof_reports/stackprof.dump > stackprof_reports/graphviz.dot +# dot -Tpng stackprof_reports/graphviz.dot > stackprof_reports/graphviz.png +# imgcat stackprof_reports/graphviz.png + +require 'stackprof' +require 'ruby-prof' +require_relative 'work_method.rb' + +StackProf.run(mode: :object, out: 'stackprof_reports/stackprof.dump', raw: true) do + work('data_small.txt', disable_gc: false) +end + +# ruby-prof +# dot -Tpng graphviz.dot > graphviz.png +# imgcat graphviz.png +# cat ruby_prof_reports/flat.txt + +RubyProf.measure_mode = RubyProf::ALLOCATIONS + +result = RubyProf::Profile.profile do + work('data_small.txt', disable_gc: true) +end + +printer = RubyProf::FlatPrinter.new(result) +printer.print(File.open('ruby_prof_reports/flat.txt', 'w+')) + +printer = RubyProf::GraphHtmlPrinter.new(result) +printer.print(File.open('ruby_prof_reports/graph.html', 'w+')) + +printer = RubyProf::CallStackPrinter.new(result) +printer.print(File.open('ruby_prof_reports/callstack.html', 'w+')) + +# printer = RubyProf::DotPrinter.new(result) +# printer.print(File.open('ruby_prof_reports/graphviz.dot', 'w+')) + +# На этот раз профилируем не allocations, а объём памяти! +RubyProf.measure_mode = RubyProf::MEMORY + +result = RubyProf::Profile.profile do + work('data_small.txt', disable_gc: false) +end + +printer = RubyProf::CallTreePrinter.new(result) +printer.print(path: 'ruby_prof_reports', profile: 'profile') + diff --git a/task-2.rb b/task-2.rb index 34e09a3c..659f5574 100644 --- a/task-2.rb +++ b/task-2.rb @@ -1,9 +1,6 @@ -# Deoptimized version of homework task +# frozen_string_literal: true require 'json' -require 'pry' -require 'date' -require 'minitest/autorun' class User attr_reader :attributes, :sessions @@ -12,166 +9,132 @@ def initialize(attributes:, sessions:) @attributes = attributes @sessions = sessions end -end -def parse_user(user) - fields = user.split(',') - parsed_result = { - 'id' => fields[1], - 'first_name' => fields[2], - 'last_name' => fields[3], - 'age' => fields[4], - } + def collect_stats + # Собираем количество сессий + sessions_count = sessions.count + + # Собираем количество времени + total_time = sessions.sum {|s| s['time'].to_i}.to_s + ' min.' + + # Выбираем самую длинную сессию пользователя + longest_session = sessions.map {|s| s['time']}.map! {|t| t.to_i}.max.to_s + ' min.' + + # Браузеры пользователя через запятую + browsers = sessions.map {|s| s['browser']}.map! {|b| b.upcase}.sort + + # Хоть раз использовал IE? + used_IE = browsers.any? { |b| b =~ /INTERNET EXPLORER/ } + + # Всегда использовал только Chrome? + always_used_chrome = browsers.all? { |b| b =~ /CHROME/ } + + # Даты сессий через запятую в обратном порядке в формате iso8601 + dates = sessions.map! { |session| session['date'] }.sort.reverse + + { + 'sessionsCount' => sessions_count, + 'totalTime' => total_time, + 'longestSession' => longest_session, + 'browsers' => browsers.join(', '), + 'usedIE' => used_IE, + 'alwaysUsedChrome' => always_used_chrome, + 'dates' => dates + } + end end -def parse_session(session) - fields = session.split(',') - parsed_result = { - 'user_id' => fields[1], - 'session_id' => fields[2], - 'browser' => fields[3], - 'time' => fields[4], - 'date' => fields[5], +def parse_user(fields) + { + 'id' => fields[0], + 'first_name' => fields[1], + 'last_name' => fields[2], + 'age' => fields[3] } end -def collect_stats_from_users(report, users_objects, &block) - users_objects.each do |user| - user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}" - report['usersStats'][user_key] ||= {} - report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user)) - end +def parse_session(fields) + { + 'user_id' => fields[0], + 'session_id' => fields[1], + 'browser' => fields[2], + 'time' => fields[3], + 'date' => fields[4] + } end def work - file_lines = File.read('data.txt').split("\n") - - users = [] - sessions = [] + file = File.open('result.json', 'w') + total_users = 0 + total_sessions = 0 + uniqueBrowsers = Set.new - file_lines.each do |line| - cols = line.split(',') - users = users + [parse_user(line)] if cols[0] == 'user' - sessions = sessions + [parse_session(line)] if cols[0] == 'session' - end + current_user = nil + prev_user = nil - # Отчёт в json - # - Сколько всего юзеров + - # - Сколько всего уникальных браузеров + - # - Сколько всего сессий + - # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + - # - # - По каждому пользователю - # - сколько всего сессий + - # - сколько всего времени + - # - самая длинная сессия + - # - браузеры через запятую + - # - Хоть раз использовал IE? + - # - Всегда использовал только Хром? + - # - даты сессий в порядке убывания через запятую + - - report = {} - - report[:totalUsers] = users.count - - # Подсчёт количества уникальных браузеров - uniqueBrowsers = [] - sessions.each do |session| - browser = session['browser'] - uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser } - end + write_start_stats(file) - report['uniqueBrowsersCount'] = uniqueBrowsers.count + File.foreach('data_large.txt') do |line| + line_type, *fields = line.chomp.split(',') - report['totalSessions'] = sessions.count + case line_type + when 'user' + total_users += 1 - report['allBrowsers'] = - sessions - .map { |s| s['browser'] } - .map { |b| b.upcase } - .sort - .uniq - .join(',') + prev_user = current_user + current_user = User.new(attributes: parse_user(fields), sessions: []) - # Статистика по пользователям - users_objects = [] + if prev_user != nil + write_stats(file, prev_user) + end - users.each do |user| - attributes = user - user_sessions = sessions.select { |session| session['user_id'] == user['id'] } - user_object = User.new(attributes: attributes, sessions: user_sessions) - users_objects = users_objects + [user_object] + when 'session' + total_sessions += 1 + session = parse_session(fields) + current_user.sessions << session + uniqueBrowsers << session['browser'] + end end - report['usersStats'] = {} +report = { + totalUsers: total_users, + totalSessions: total_sessions, + uniqueBrowsersCount: uniqueBrowsers.uniq.count, + allBrowsers: uniqueBrowsers.map!(&:upcase).sort.join(',') +} - # Собираем количество сессий по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'sessionsCount' => user.sessions.count } - end + write_end_stats(file, current_user, report) - # Собираем количество времени по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'totalTime' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.' } - end + file.close - # Выбираем самую длинную сессию пользователя - collect_stats_from_users(report, users_objects) do |user| - { 'longestSession' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.max.to_s + ' min.' } - end + puts 'Finish work' + puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end - # Браузеры пользователя через запятую - collect_stats_from_users(report, users_objects) do |user| - { 'browsers' => user.sessions.map {|s| s['browser']}.map {|b| b.upcase}.sort.join(', ') } - end +def write_start_stats(file) + file.puts('{"usersStats": {') +end - # Хоть раз использовал IE? - collect_stats_from_users(report, users_objects) do |user| - { 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } } - end +def write_stats(file, user) + user_stats = user.collect_stats + user_key = "#{user.attributes['first_name']} #{user.attributes['last_name']}" + file.write '"' + user_key + '":' + file.write user_stats.to_json - # Всегда использовал только Chrome? - collect_stats_from_users(report, users_objects) do |user| - { 'alwaysUsedChrome' => user.sessions.map{|s| s['browser']}.all? { |b| b.upcase =~ /CHROME/ } } - end + file.puts ',' +end - # Даты сессий через запятую в обратном порядке в формате iso8601 - collect_stats_from_users(report, users_objects) do |user| - { 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } } - end +def write_end_stats(file, user, report) + user_stats = user.collect_stats + user_key = "#{user.attributes['first_name']} #{user.attributes['last_name']}" + file.write '"' + user_key + '":' + file.write user_stats.to_json - File.write('result.json', "#{report.to_json}\n") - puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) -end + file.puts('},') -class TestMe < Minitest::Test - def setup - File.write('result.json', '') - File.write('data.txt', -'user,0,Leida,Cira,0 -session,0,0,Safari 29,87,2016-10-23 -session,0,1,Firefox 12,118,2017-02-27 -session,0,2,Internet Explorer 28,31,2017-03-28 -session,0,3,Internet Explorer 28,109,2016-09-15 -session,0,4,Safari 39,104,2017-09-27 -session,0,5,Internet Explorer 35,6,2016-09-01 -user,1,Palmer,Katrina,65 -session,1,0,Safari 17,12,2016-10-21 -session,1,1,Firefox 32,3,2016-12-20 -session,1,2,Chrome 6,59,2016-11-11 -session,1,3,Internet Explorer 10,28,2017-04-29 -session,1,4,Chrome 13,116,2016-12-28 -user,2,Gregory,Santos,86 -session,2,0,Chrome 35,6,2018-09-21 -session,2,1,Safari 49,85,2017-05-22 -session,2,2,Firefox 47,17,2018-02-02 -session,2,3,Chrome 20,84,2016-11-25 -') - end + file.write report.to_json.to_s[1..-2] - def test_result - work - expected_result = JSON.parse('{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.","longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35, SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23","2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.","browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.","longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}') - assert_equal expected_result, JSON.parse(File.read('result.json')) - end + file.puts('}') end + +work \ No newline at end of file diff --git a/work_method.rb b/work_method.rb new file mode 100644 index 00000000..daf4c037 --- /dev/null +++ b/work_method.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'json' + +class User + attr_reader :attributes, :sessions + + def initialize(attributes:, sessions:) + @attributes = attributes + @sessions = sessions + end + + def collect_stats + # Собираем количество сессий + sessions_count = sessions.count + + # Собираем количество времени + total_time = sessions.sum {|s| s['time'].to_i}.to_s + ' min.' + + # Выбираем самую длинную сессию пользователя + longest_session = sessions.map {|s| s['time']}.map! {|t| t.to_i}.max.to_s + ' min.' + + # Браузеры пользователя через запятую + browsers = sessions.map {|s| s['browser']}.map! {|b| b.upcase}.sort + + # Хоть раз использовал IE? + used_IE = browsers.any? { |b| b =~ /INTERNET EXPLORER/ } + + # Всегда использовал только Chrome? + always_used_chrome = browsers.all? { |b| b =~ /CHROME/ } + + # Даты сессий через запятую в обратном порядке в формате iso8601 + dates = sessions.map! { |session| session['date'] }.sort.reverse + + { + 'sessionsCount' => sessions_count, + 'totalTime' => total_time, + 'longestSession' => longest_session, + 'browsers' => browsers.join(', '), + 'usedIE' => used_IE, + 'alwaysUsedChrome' => always_used_chrome, + 'dates' => dates + } + end +end + +def parse_user(fields) + { + 'id' => fields[0], + 'first_name' => fields[1], + 'last_name' => fields[2], + 'age' => fields[3] + } +end + +def parse_session(fields) + { + 'user_id' => fields[0], + 'session_id' => fields[1], + 'browser' => fields[2], + 'time' => fields[3], + 'date' => fields[4] + } +end + +def work(filename = '', disable_gc: false) + puts 'Start work' + GC.disable if disable_gc + filename = (filename == '') ? 'data.txt' : filename + + file = File.open('result.json', 'w') + total_users = 0 + total_sessions = 0 + uniqueBrowsers = Set.new + + current_user = nil + prev_user = nil + + write_start_stats(file) + + File.foreach(ENV['DATA_FILE'] || filename) do |line| + line_type, *fields = line.chomp.split(',') + + case line_type + when 'user' + total_users += 1 + + prev_user = current_user + current_user = User.new(attributes: parse_user(fields), sessions: []) + + if prev_user != nil + write_stats(file, prev_user) + end + + when 'session' + total_sessions += 1 + session = parse_session(fields) + current_user.sessions << session + uniqueBrowsers << session['browser'] + end + end + +report = { + totalUsers: total_users, + totalSessions: total_sessions, + uniqueBrowsersCount: uniqueBrowsers.uniq.count, + allBrowsers: uniqueBrowsers.map!(&:upcase).sort.join(',') +} + + write_end_stats(file, current_user, report) + + file.close + + puts 'Finish work' + puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end + +def write_start_stats(file) + file.puts('{"usersStats": {') +end + +def write_stats(file, user) + user_stats = user.collect_stats + user_key = "#{user.attributes['first_name']} #{user.attributes['last_name']}" + file.write '"' + user_key + '":' + file.write user_stats.to_json + + file.puts ',' +end + +def write_end_stats(file, user, report) + user_stats = user.collect_stats + user_key = "#{user.attributes['first_name']} #{user.attributes['last_name']}" + file.write '"' + user_key + '":' + file.write user_stats.to_json + + file.puts('},') + + file.write report.to_json.to_s[1..-2] + + file.puts('}') +end + diff --git a/work_spec.rb b/work_spec.rb new file mode 100644 index 00000000..7c427448 --- /dev/null +++ b/work_spec.rb @@ -0,0 +1,61 @@ +require_relative 'task-2' +require 'rspec-benchmark' + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end + +describe 'Memory benchmark' do + context 'works within acceptable memory limits' do + it 'does not exceed 15 MB' do + expect { work }.to perform_allocation(15 * 1024 * 1024).bytes + end + end + + it 'should not allocate more than 110000 objects' do + `head -n 1000 data_large.txt > data.txt` + + expect { work }.to perform_allocation(20000) + end +end + +describe 'Correct workflow' do + before do + File.write('result.json', '') + File.write('data.txt', +'user,0,Leida,Cira,0 +session,0,0,Safari 29,87,2016-10-23 +session,0,1,Firefox 12,118,2017-02-27 +session,0,2,Internet Explorer 28,31,2017-03-28 +session,0,3,Internet Explorer 28,109,2016-09-15 +session,0,4,Safari 39,104,2017-09-27 +session,0,5,Internet Explorer 35,6,2016-09-01 +user,1,Palmer,Katrina,65 +session,1,0,Safari 17,12,2016-10-21 +session,1,1,Firefox 32,3,2016-12-20 +session,1,2,Chrome 6,59,2016-11-11 +session,1,3,Internet Explorer 10,28,2017-04-29 +session,1,4,Chrome 13,116,2016-12-28 +user,2,Gregory,Santos,86 +session,2,0,Chrome 35,6,2018-09-21 +session,2,1,Safari 49,85,2017-05-22 +session,2,2,Firefox 47,17,2018-02-02 +session,2,3,Chrome 20,84,2016-11-25 +') + end + + it 'returns correct result' do + work + result_data = JSON.parse(File.read('result.json')) + expected_result = JSON.parse('{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.","longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35, SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23","2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.","browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.","longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}') + + expect(result_data['totalUsers']).to eq(expected_result['totalUsers']) + expect(result_data['uniqueBrowsersCount']).to eq(expected_result['uniqueBrowsersCount']) + expect(result_data['totalSessions']).to eq(expected_result['totalSessions']) + expect(result_data['allBrowsers']).to eq(expected_result['allBrowsers']) + + result_data['usersStats'].each do |user, user_stat| + expect(user_stat).to eq(expected_result['usersStats'][user]) + end + end +end