seimi 0.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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seimi
4
+ module Kanji
5
+ DIGITS = %w[〇 一 二 三 四 五 六 七 八 九].freeze
6
+ PARSE_DIGITS = DIGITS.each_with_index.to_h.merge("零" => 0).freeze
7
+ SMALL_UNITS = { "十" => 10, "百" => 100, "千" => 1000 }.freeze
8
+ LARGE_UNITS = [["兆", 1_000_000_000_000], ["億", 100_000_000], ["万", 10_000]].freeze
9
+
10
+ module_function
11
+
12
+ def from_i(number)
13
+ integer = Integer(number)
14
+ return "零" if integer.zero?
15
+ return "負#{from_i(-integer)}" if integer.negative?
16
+
17
+ groups = []
18
+ while integer.positive?
19
+ groups << integer % 10_000
20
+ integer /= 10_000
21
+ end
22
+
23
+ groups.each_with_index.filter_map do |group, index|
24
+ next if group.zero?
25
+
26
+ "#{under_10000(group)}#{large_unit_at(index)}"
27
+ end.reverse.join
28
+ end
29
+
30
+ def to_i(text)
31
+ source = String(text)
32
+ return 0 if source == "零" || source == "〇"
33
+
34
+ negative = source.start_with?("負")
35
+ source = source.delete_prefix("負")
36
+ total = 0
37
+
38
+ LARGE_UNITS.each do |unit, scale|
39
+ next unless source.include?(unit)
40
+
41
+ head, source = source.split(unit, 2)
42
+ total += under_10000_to_i(head) * scale
43
+ end
44
+
45
+ total += under_10000_to_i(source)
46
+ negative ? -total : total
47
+ end
48
+
49
+ def rational(value)
50
+ rational = Rational(value)
51
+ return from_i(rational.numerator) if rational.denominator == 1
52
+
53
+ numerator = rational.numerator
54
+ sign = numerator.negative? ? "負" : ""
55
+ "#{sign}#{from_i(rational.denominator)}分の#{from_i(numerator.abs)}"
56
+ end
57
+
58
+ def decimal(value, digits = 3)
59
+ formatted = format("%.#{digits}f", value)
60
+ integer, fraction = formatted.split(".", 2)
61
+ "#{from_i(integer.to_i)}・#{fraction.chars.map { |char| DIGITS[char.to_i] }.join}"
62
+ end
63
+
64
+ def under_10000(number)
65
+ integer = Integer(number)
66
+ raise ArgumentError, "out of range" if integer.negative? || integer >= 10_000
67
+
68
+ thousands, rem = integer.divmod(1000)
69
+ hundreds, rem = rem.divmod(100)
70
+ tens, ones = rem.divmod(10)
71
+
72
+ [
73
+ unit_part(thousands, "千"),
74
+ unit_part(hundreds, "百"),
75
+ unit_part(tens, "十"),
76
+ ones.positive? ? DIGITS[ones] : nil
77
+ ].compact.join
78
+ end
79
+
80
+ def large_unit_at(index)
81
+ case index
82
+ when 0 then ""
83
+ when 1 then "万"
84
+ when 2 then "億"
85
+ when 3 then "兆"
86
+ else
87
+ raise ArgumentError, "number is too large"
88
+ end
89
+ end
90
+
91
+ def unit_part(value, unit)
92
+ return nil if value.zero?
93
+ return unit if value == 1
94
+
95
+ "#{DIGITS[value]}#{unit}"
96
+ end
97
+
98
+ def under_10000_to_i(text)
99
+ return 0 if text.empty?
100
+
101
+ total = 0
102
+ current = nil
103
+
104
+ text.each_char do |char|
105
+ if PARSE_DIGITS.key?(char)
106
+ current = PARSE_DIGITS.fetch(char)
107
+ elsif SMALL_UNITS.key?(char)
108
+ total += (current || 1) * SMALL_UNITS.fetch(char)
109
+ current = nil
110
+ else
111
+ raise ArgumentError, "unknown kanji digit: #{char}"
112
+ end
113
+ end
114
+
115
+ total + (current || 0)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seimi
4
+ module Sangi
5
+ CELL_WIDTH = 5
6
+ CELL_HEIGHT = 5
7
+
8
+ module_function
9
+
10
+ def render(number)
11
+ digits = Integer(number).abs.digits.reverse
12
+ cells = digits.each_with_index.map do |digit, index|
13
+ power = digits.length - index - 1
14
+ power.even? ? vertical_cell(digit) : horizontal_cell(digit)
15
+ end
16
+
17
+ [frame("┌", "┬", "┐", cells.length),
18
+ *body_lines(cells),
19
+ frame("└", "┴", "┘", cells.length)].join("\n")
20
+ end
21
+
22
+ def vertical_cell(digit)
23
+ return empty_cell if digit.zero?
24
+
25
+ rows = blank_cell
26
+ if digit <= 5
27
+ digit.times { |index| rows[index] = " | " }
28
+ else
29
+ rows[0] = " --- "
30
+ (digit - 5).times { |index| rows[index + 1] = " | " }
31
+ end
32
+ rows
33
+ end
34
+
35
+ def horizontal_cell(digit)
36
+ return empty_cell if digit.zero?
37
+
38
+ rows = blank_cell
39
+ if digit <= 5
40
+ digit.times { |index| rows[CELL_HEIGHT - 1 - index] = " --- " }
41
+ else
42
+ rows[0] = " | "
43
+ (digit - 5).times { |index| rows[CELL_HEIGHT - 1 - index] = " --- " }
44
+ end
45
+ rows
46
+ end
47
+
48
+ def body_lines(cells)
49
+ (0...CELL_HEIGHT).map do |line|
50
+ "│#{cells.map { |cell| cell[line] }.join("│")}│"
51
+ end
52
+ end
53
+
54
+ def frame(left, middle, right, count)
55
+ "#{left}#{Array.new(count, "─" * CELL_WIDTH).join(middle)}#{right}"
56
+ end
57
+
58
+ def blank_cell
59
+ Array.new(CELL_HEIGHT, " " * CELL_WIDTH)
60
+ end
61
+
62
+ def empty_cell
63
+ [" ", " ", " [ ] ", " ", " "]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seimi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/seimi.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seimi/version"
4
+ require_relative "seimi/errors"
5
+ require_relative "seimi/elements"
6
+ require_relative "seimi/kanji"
7
+ require_relative "seimi/sangi"
8
+ require_relative "seimi/formula"
9
+ require_relative "seimi/equation"
10
+
11
+ module Seimi
12
+ def self.molar_mass(formula)
13
+ Formula.parse(formula).molar_mass
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TestBuntai < Minitest::Test
6
+ PROHIBITED = /(です|ます|である|ください)/
7
+ REQUIRED = /(候|也|べし|給え)/
8
+
9
+ def test_error_messages_have_required_style
10
+ constants = Seimi.constants.grep(/\AMSG_/)
11
+
12
+ refute_empty constants
13
+ constants.each do |constant|
14
+ value = Seimi.const_get(constant)
15
+ refute_match PROHIBITED, value
16
+ assert_match REQUIRED, value
17
+ end
18
+ end
19
+
20
+ def test_readme_body_uses_required_style
21
+ readme = File.read(File.expand_path("../README.md", __dir__), encoding: "UTF-8")
22
+ body = readme.gsub(/```.*?```/m, "")
23
+
24
+ refute_match PROHIBITED, body
25
+ end
26
+ end
data/test/test_cli.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+ require_relative "test_helper"
6
+
7
+ class TestCLI < Minitest::Test
8
+ EXE = File.expand_path("../exe/seimi", __dir__)
9
+
10
+ def test_kaibou_success
11
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-Ilib", EXE, "kaibou", "Ca(OH)2")
12
+
13
+ assert_predicate status, :success?, stderr
14
+ assert_includes stdout, "候"
15
+ assert_includes stdout, "七十四"
16
+ end
17
+
18
+ def test_kaibou_error
19
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-Ilib", EXE, "kaibou", "Xx")
20
+
21
+ refute_predicate status, :success?, stdout
22
+ assert_equal 1, status.exitstatus
23
+ assert_includes stderr, "咎:"
24
+ end
25
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TestEquation < Minitest::Test
6
+ def test_iron_oxide
7
+ result = Seimi::Equation.balance("Fe + O2 -> Fe2O3")
8
+
9
+ assert_equal [4, 3, 2], result.coefficients
10
+ assert_equal ["Fe", "O2", "Fe2O3"], result.species
11
+ assert_equal 2, result.lhs_size
12
+ assert_equal ["Fe", "O"], result.elements
13
+ assert_equal "4Fe + 3O2 -> 2Fe2O3", result.to_s
14
+ end
15
+
16
+ def test_propane
17
+ result = Seimi::Equation.balance("C3H8 + O2 -> CO2 + H2O")
18
+
19
+ assert_equal [1, 5, 3, 4], result.coefficients
20
+ end
21
+
22
+ def test_permanganate_and_hydrochloric_acid
23
+ result = Seimi::Equation.balance("KMnO4 + HCl -> KCl + MnCl2 + H2O + Cl2")
24
+
25
+ assert_equal [2, 16, 2, 2, 8, 5], result.coefficients
26
+ end
27
+
28
+ def test_unbalanced
29
+ assert_raises(Seimi::UnbalancedError) do
30
+ Seimi::Equation.balance("H2 -> H2O")
31
+ end
32
+ end
33
+
34
+ def test_matrix_contains_rational_values
35
+ result = Seimi::Equation.balance("Fe + O2 -> Fe2O3")
36
+
37
+ assert result.matrix.all? { |row| row.all? { |value| value.is_a?(Rational) } }
38
+ end
39
+
40
+ def test_ionic_equation_balances_charge
41
+ result = Seimi::Equation.balance("Ag+ + Cl- -> AgCl")
42
+
43
+ assert_equal [1, 1, 1], result.coefficients
44
+ assert_includes result.elements, Seimi::Equation::CHARGE_BALANCE_SYMBOL
45
+ assert_equal [Rational(1), Rational(-1), Rational(0)], result.matrix.last
46
+ end
47
+
48
+ def test_ionic_equation_without_spaces
49
+ result = Seimi::Equation.balance("Ag++Cl-->AgCl")
50
+
51
+ assert_equal [1, 1, 1], result.coefficients
52
+ assert_equal ["Ag+", "Cl-", "AgCl"], result.species
53
+ end
54
+
55
+ def test_multivalent_ionic_equation_without_spaces
56
+ result = Seimi::Equation.balance("Ba^2++SO4^2-->BaSO4")
57
+
58
+ assert_equal [1, 1, 1], result.coefficients
59
+ assert_equal ["Ba^2+", "SO4^2-", "BaSO4"], result.species
60
+ end
61
+
62
+ def test_hydrate_equation
63
+ result = Seimi::Equation.balance("CuSO4·5H2O -> CuSO4 + H2O")
64
+
65
+ assert_equal [1, 1, 5], result.coefficients
66
+ end
67
+
68
+ def test_isotope_labels_are_balanced_separately
69
+ assert_raises(Seimi::UnbalancedError) do
70
+ Seimi::Equation.balance("[13C]O2 -> CO2")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TestFormula < Minitest::Test
6
+ def test_molar_mass_shortcut
7
+ assert_in_delta 18.015, Seimi.molar_mass("H2O"), 0.001
8
+ end
9
+
10
+ def test_parenthesized_formula
11
+ assert_equal({ "Ca" => 1, "O" => 2, "H" => 2 }, Seimi::Formula.parse("Ca(OH)2").composition)
12
+ end
13
+
14
+ def test_nested_count_formula
15
+ assert_equal({ "Mg" => 3, "P" => 2, "O" => 8 }, Seimi::Formula.parse("Mg3(PO4)2").composition)
16
+ end
17
+
18
+ def test_unknown_element_message
19
+ error = assert_raises(Seimi::UnknownElementError) do
20
+ Seimi::Formula.parse("Xx")
21
+ end
22
+
23
+ assert_equal format(Seimi::MSG_UNKNOWN_ELEMENT, "Xx"), error.message
24
+ assert_equal 118, Seimi::ELEMENTS.length
25
+ end
26
+
27
+ def test_ion_charge
28
+ sulfate = Seimi::Formula.parse("SO4^2-")
29
+ ammonium = Seimi::Formula.parse("NH4+")
30
+ iron = Seimi::Formula.parse("Fe3+")
31
+
32
+ assert_equal({ "S" => 1, "O" => 4 }, sulfate.composition)
33
+ assert_equal(-2, sulfate.charge)
34
+ assert_equal({ "N" => 1, "H" => 4 }, ammonium.composition)
35
+ assert_equal(1, ammonium.charge)
36
+ assert_equal({ "Fe" => 1 }, iron.composition)
37
+ assert_equal(3, iron.charge)
38
+ end
39
+
40
+ def test_hydrate_formula
41
+ formula = Seimi::Formula.parse("CuSO4·5H2O")
42
+
43
+ assert_equal({ "Cu" => 1, "S" => 1, "O" => 9, "H" => 10 }, formula.composition)
44
+ assert_in_delta 249.677, formula.molar_mass, 0.001
45
+ end
46
+
47
+ def test_isotope_formula
48
+ formula = Seimi::Formula.parse("[13C]H4")
49
+
50
+ assert_equal({ "C" => 1, "H" => 4 }, formula.composition)
51
+ assert_equal({ "13C" => 1, "H" => 4 }, formula.balance_composition)
52
+ assert_in_delta 17.0353548351, formula.molar_mass, 0.000001
53
+ assert_equal ["13C", 1, 13.0033548351], formula.breakdown.first
54
+ end
55
+
56
+ def test_exact_isotope_formula
57
+ formula = Seimi::Formula.parse("[13C][1H]4")
58
+
59
+ assert_in_delta 17.03465496402, formula.molar_mass, 0.000000001
60
+ end
61
+
62
+ def test_deuterium_and_tritium_aliases
63
+ deuterium_water = Seimi::Formula.parse("D2O")
64
+ tritium = Seimi::Formula.parse("T2")
65
+
66
+ assert_equal({ "H" => 2, "O" => 1 }, deuterium_water.composition)
67
+ assert_equal({ "2H" => 2, "O" => 1 }, deuterium_water.balance_composition)
68
+ assert_in_delta 20.0272035562, deuterium_water.molar_mass, 0.000000001
69
+ assert_equal({ "H" => 2 }, tritium.composition)
70
+ assert_equal({ "3H" => 2 }, tritium.balance_composition)
71
+ end
72
+
73
+ def test_unknown_isotope
74
+ assert_raises(Seimi::ParseError) do
75
+ Seimi::Formula.parse("[999C]H4")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "seimi"
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TestKanji < Minitest::Test
6
+ def test_from_i_and_to_i_round_trip
7
+ [0, 7, 10, 58, 100, 1858, 10_005, 27_682_574_402].each do |number|
8
+ assert_equal number, Seimi::Kanji.to_i(Seimi::Kanji.from_i(number))
9
+ end
10
+ end
11
+
12
+ def test_specific_kanji
13
+ assert_equal "千八百五十八", Seimi::Kanji.from_i(1858)
14
+ assert_equal 1858, Seimi::Kanji.to_i("千八百五十八")
15
+ end
16
+
17
+ def test_rational
18
+ assert_equal "三分の一", Seimi::Kanji.rational(Rational(1, 3))
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seimi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: 化学式の分子の量を秤り、反応式の割合の数を厳密に釣合はすRubyGemに候
41
+ email:
42
+ - t.yudai92@gmail.com
43
+ executables:
44
+ - seimi
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - Gemfile
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - exe/seimi
54
+ - lib/seimi.rb
55
+ - lib/seimi/cli.rb
56
+ - lib/seimi/elements.rb
57
+ - lib/seimi/equation.rb
58
+ - lib/seimi/errors.rb
59
+ - lib/seimi/formula.rb
60
+ - lib/seimi/formula/parser.rb
61
+ - lib/seimi/isotopes.rb
62
+ - lib/seimi/kanji.rb
63
+ - lib/seimi/sangi.rb
64
+ - lib/seimi/version.rb
65
+ - test/test_buntai.rb
66
+ - test/test_cli.rb
67
+ - test/test_equation.rb
68
+ - test/test_formula.rb
69
+ - test/test_helper.rb
70
+ - test/test_kanji.rb
71
+ homepage: https://github.com/ydah/seimi
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ source_code_uri: https://github.com/ydah/seimi
76
+ changelog_uri: https://github.com/ydah/seimi/blob/main/CHANGELOG.md
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.6
92
+ specification_version: 4
93
+ summary: 舎密の式を解剖し釣合はする絡繰に候
94
+ test_files: []