├── renovate.json ├── .gitignore ├── .pdd ├── features ├── support │ └── env.rb ├── gem_package.feature ├── empty_dir.feature ├── cli.feature └── step_definitions │ └── steps.rb ├── test ├── test__helper.rb ├── test_champions.rb ├── test_estimates.rb ├── test_estimate.rb └── test_est.rb ├── .rubocop.yml ├── cucumber.yml ├── .0pdd.yml ├── .gitattributes ├── lib ├── est │ ├── version.rb │ ├── methods │ │ └── champions.rb │ ├── estimate.rb │ └── estimates.rb └── est.rb ├── .github └── workflows │ ├── xcop.yml │ ├── reuse.yml │ ├── typos.yml │ ├── pdd.yml │ ├── yamllint.yml │ ├── copyrights.yml │ ├── markdown-lint.yml │ ├── codecov.yml │ ├── actionlint.yml │ ├── rake.yml │ └── license.yml ├── est └── first.est ├── Gemfile ├── REUSE.toml ├── assets ├── est-text.xsl ├── est.xsd └── est.xsl ├── LICENSE.txt ├── LICENSES └── MIT.txt ├── .rultor.yml ├── est.gemspec ├── Rakefile ├── bin └── est └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | .DS_Store 3 | .idea/ 4 | .yardoc/ 5 | *.gem 6 | coverage/ 7 | doc/ 8 | Gemfile.lock 9 | node_modules/ 10 | rdoc/ 11 | vendor/ 12 | -------------------------------------------------------------------------------- /.pdd: -------------------------------------------------------------------------------- 1 | --source=. 2 | --verbose 3 | --exclude target/**/* 4 | --exclude src/main/resources/images/**/* 5 | --rule min-words:20 6 | --rule min-estimate:15 7 | --rule max-estimate:90 8 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'simplecov' 7 | require 'est' 8 | -------------------------------------------------------------------------------- /test/test__helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'simplecov' 7 | require 'est' 8 | require 'minitest/autorun' 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | MethodLength: 5 | Max: 50 6 | require: 7 | - rubocop-rake 8 | - rubocop-minitest 9 | - rubocop-performance 10 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | default: --format pretty 5 | travis: --format progress 6 | html_report: --format progress --format html --out=features_report.html 7 | -------------------------------------------------------------------------------- /.0pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | errors: 5 | - yegor256@gmail.com 6 | # alerts: 7 | # github: 8 | # - yegor256 9 | 10 | tags: 11 | - pdd 12 | - bug 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Check out all text files in UNIX format, with LF as end of line 2 | # Don't change this file. If you have any ideas about it, please 3 | # submit a separate issue about it and we'll discuss. 4 | 5 | * text=auto eol=lf 6 | *.java ident 7 | *.xml ident 8 | -------------------------------------------------------------------------------- /lib/est/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | # Est main module. 7 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 8 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 9 | # License:: MIT 10 | module Est 11 | VERSION = '1.0.snapshot' 12 | end 13 | -------------------------------------------------------------------------------- /.github/workflows/xcop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: xcop 6 | 'on': 7 | push: 8 | pull_request: 9 | jobs: 10 | xcop: 11 | timeout-minutes: 15 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: g4s8/xcop-action@master 16 | -------------------------------------------------------------------------------- /est/first.est: -------------------------------------------------------------------------------- 1 | date: 20-12-2017 2 | author: Yegor Bugayenko 3 | method: champions.pert 4 | scope: 5 | 1: ruby gem scaffolding 6 | 2: integration tests with Cucumber 7 | 3: user documentation 8 | 4: first estimation model 9 | 5: more estimation models 10 | 6: continuous integration configs 11 | champions: 12 | 4: 13 | worst-case: 6 14 | best-case: 1 15 | most-likely: 2 16 | 5: 17 | worst-case: 10 18 | best-case: 2 19 | most-likely: 4 20 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: reuse 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | reuse: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: fsfe/reuse-action@v5 20 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: typos 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | typos: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: crate-ci/typos@v1.32.0 20 | -------------------------------------------------------------------------------- /.github/workflows/pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: pdd 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | pdd: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: volodya-lombrozo/pdd-action@master 20 | -------------------------------------------------------------------------------- /.github/workflows/yamllint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: yamllint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | yamllint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ibiqlik/action-yamllint@v3 20 | -------------------------------------------------------------------------------- /.github/workflows/copyrights.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: copyrights 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | copyrights: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: yegor256/copyrights-action@0.0.8 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | source 'https://rubygems.org' 7 | gemspec 8 | 9 | gem 'coveralls', '~>0.7', require: false 10 | gem 'cucumber', '~>2.0', require: false 11 | gem 'minitest', '~>5.5', require: false 12 | gem 'rake', '>0', require: false 13 | gem 'rdoc', '>0', require: false 14 | gem 'rubocop', '~>1.72', require: false 15 | gem 'rubocop-minitest', '~>0.38', require: false 16 | gem 'rubocop-performance', '~>1.25', require: false 17 | gem 'rubocop-rake', '~>0.7', require: false 18 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: markdown-lint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | paths-ignore: ['paper/**', 'sandbox/**'] 14 | concurrency: 15 | group: markdown-lint-${{ github.ref }} 16 | cancel-in-progress: true 17 | jobs: 18 | markdown-lint: 19 | timeout-minutes: 15 20 | runs-on: ubuntu-24.04 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: DavidAnson/markdownlint-cli2-action@v20.0.0 24 | -------------------------------------------------------------------------------- /features/gem_package.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Gem Package 4 | As a source code writer I want to be able to 5 | package the Gem into .gem file 6 | 7 | Scenario: Gem can be packaged 8 | Given I have a "execs.rb" file with content: 9 | """ 10 | #!/usr/bin/env ruby 11 | require 'rubygems' 12 | spec = Gem::Specification::load('./spec.rb') 13 | fail 'no executables' if spec.executables.empty? 14 | """ 15 | When I run bash with 16 | """ 17 | set -e 18 | cd est 19 | gem build est.gemspec 20 | gem specification --ruby est-*.gem > ../spec.rb 21 | cd .. 22 | ruby execs.rb 23 | """ 24 | Then Exit code is zero 25 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: codecov 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | jobs: 11 | codecov: 12 | timeout-minutes: 15 13 | runs-on: ubuntu-24.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.3 19 | bundler-cache: true 20 | - run: bundle config set --global path "$(pwd)/vendor/bundle" 21 | - run: bundle install --no-color 22 | - run: bundle exec rake 23 | - uses: codecov/codecov-action@v5 24 | with: 25 | token: ${{ secrets.CODECOV_TOKEN }} 26 | -------------------------------------------------------------------------------- /features/empty_dir.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Command Line Processing 4 | As an estimator I want to be able to 5 | call Est as a command line tool 6 | 7 | Scenario: Estimates empty directory 8 | Given I have a "test.txt" file with content: 9 | """ 10 | hello 11 | """ 12 | When I run bin/est with "--dir=. --format=text" 13 | Then Exit code is zero 14 | And Stdout contains "Total: 0" 15 | 16 | Scenario: Estimates absent directory 17 | Given I have a "test.txt" file with content: 18 | """ 19 | hello 20 | """ 21 | When I run bin/est with "--dir=./absent-dir --format=text" 22 | Then Exit code is zero 23 | And Stdout contains "Total: 0" 24 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: actionlint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | actionlint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Download actionlint 20 | id: get_actionlint 21 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 22 | shell: bash 23 | - name: Check workflow files 24 | run: ${{ steps.get_actionlint.outputs.executable }} -color 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/rake.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: rake 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | rake: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-24.04, macos-15, windows-2022] 18 | ruby: [3.3] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - run: bundle config set --global path "$(pwd)/vendor/bundle" 27 | - run: bundle install --no-color 28 | - run: bundle exec rake 29 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | version = 1 5 | [[annotations]] 6 | path = [ 7 | ".DS_Store", 8 | ".gitattributes", 9 | ".gitignore", 10 | ".pdd", 11 | "**.json", 12 | "**.md", 13 | "**.png", 14 | "**.txt", 15 | "**/.DS_Store", 16 | "**/.gitignore", 17 | "**/.pdd", 18 | "**/*.csv", 19 | "**/*.est", 20 | "**/*.jpg", 21 | "**/*.json", 22 | "**/*.md", 23 | "**/*.pdf", 24 | "**/*.png", 25 | "**/*.svg", 26 | "**/*.txt", 27 | "**/*.vm", 28 | "**/CNAME", 29 | "**/Gemfile.lock", 30 | "Gemfile.lock", 31 | "README.md", 32 | "renovate.json", 33 | ] 34 | precedence = "override" 35 | SPDX-FileCopyrightText = "Copyright (c) 2025 Yegor Bugayenko" 36 | SPDX-License-Identifier = "MIT" 37 | -------------------------------------------------------------------------------- /assets/est-text.xsl: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 9 | 10 | Total: 11 | 12 | 13 | 14 | 15 | 16 | 17 | : 18 | 19 | hours by 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/est.xsd: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014-2025 Yegor Bugayenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the 'Software'), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014-2025 Yegor Bugayenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the 'Software'), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.rultor.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | assets: 6 | rubygems.yml: yegor256/home#assets/rubygems.yml 7 | s3cfg: yegor256/home#assets/s3cfg 8 | install: | 9 | sudo gem install pdd -v 0.20.5 10 | sudo gem install est -v 0.3.4 11 | release: 12 | pre: false 13 | script: |- 14 | pdd --verbose --file=/dev/null 15 | sudo bundle install 16 | rake 17 | rm -rf *.gem 18 | sed -i "s/1\.0\.snapshot/${tag}/g" lib/est/version.rb 19 | git add lib/est/version.rb 20 | git commit -m "version set to ${tag}" 21 | gem build est.gemspec 22 | chmod 0600 ../rubygems.yml 23 | gem push *.gem --config-file ../rubygems.yml 24 | s3cmd --no-progress put assets/est.xsd --acl-public --config=../s3cfg s3://est-xsd.teamed.io/${tag}.xsd 25 | s3cmd --no-progress put assets/est.xsl --acl-public --config=../s3cfg s3://est-xsl.teamed.io/${tag}.xsl 26 | est --dir=./est --format=xml --file=est-estimate.xml 27 | s3cmd --no-progress put est-estimate.xml --config=../s3cfg s3://est.teamed.io/est.xml 28 | commanders: 29 | - yegor256 30 | architect: 31 | - yegor256 32 | - davvd 33 | merge: 34 | commanders: [] 35 | deploy: {} 36 | -------------------------------------------------------------------------------- /test/test_champions.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'minitest/autorun' 7 | require 'est/methods/champions' 8 | require 'yaml' 9 | 10 | # Est main module test. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 13 | # License:: MIT 14 | class TestChampions < Minitest::Test 15 | def test_basic_calculation 16 | method = Est::Champions.new( 17 | YAML.load( 18 | ''' 19 | scope: 20 | 1: basic Sinatra scaffolding 21 | 2: front-end HAML files 22 | 3: SASS stylesheet 23 | 4: five model classes with unit tests 24 | 5: PostgreSQL migrations 25 | 6: Cucumber tests for PostgreSQL 26 | 7: Capybara tests for HTML front 27 | 8: CasperJS tests 28 | 9: achieve 80% test coverage 29 | champions: 30 | 7: 31 | worst-case: 40 32 | best-case: 10 33 | most-likely: 18 34 | 4: 35 | worst-case: 30 36 | best-case: 8 37 | most-likely: 16 38 | ''' 39 | ) 40 | ) 41 | assert_equal 79, method.total 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /est.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | lib = File.expand_path('../lib', __FILE__) 7 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 8 | require 'est/version' 9 | 10 | Gem::Specification.new do |s| 11 | s.specification_version = 2 if s.respond_to? :specification_version= 12 | if s.respond_to? :required_rubygems_version= 13 | s.required_rubygems_version = Gem::Requirement.new('>= 0') 14 | end 15 | s.rubygems_version = '2.2.2' 16 | s.required_ruby_version = '>= 1.9.3' 17 | s.name = 'est' 18 | s.version = Est::VERSION 19 | s.license = 'MIT' 20 | s.summary = 'Estimates Automated' 21 | s.description = 'Estimate project size' 22 | s.authors = ['Yegor Bugayenko'] 23 | s.email = 'yegor256@gmail.com' 24 | s.homepage = 'https://github.com/teamed/est' 25 | s.files = `git ls-files | grep -v -E '^(test/|\\.|renovate)'`.split($RS) 26 | s.executables = s.files.grep(/^bin\//) { |f| File.basename(f) } 27 | s.test_files = s.files.grep(/^(test|spec|features)\//) 28 | s.rdoc_options = ['--charset=UTF-8'] 29 | s.extra_rdoc_files = ['README.md', 'LICENSE.txt'] 30 | s.add_runtime_dependency 'nokogiri', '>0' 31 | s.add_runtime_dependency 'slop', '3.6.0' 32 | end 33 | -------------------------------------------------------------------------------- /lib/est/methods/champions.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'est/version' 7 | require 'logger' 8 | require 'yaml' 9 | 10 | # Single estimate. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 13 | # License:: MIT 14 | module Est 15 | # Scope Champions. 16 | # see http://www.technoparkcorp.com/innovations/scope-champions/ 17 | class Champions 18 | # Ctor. 19 | # +yaml+:: YAML config 20 | def initialize(yaml) 21 | @yaml = yaml 22 | end 23 | 24 | # Get total estimate. 25 | def total 26 | n = @yaml['scope'].size 27 | champs = @yaml['champions'] 28 | m = champs.size 29 | k = 0.54 30 | sum = champs.map do |i, e| 31 | total = (e['best-case'].to_i + 32 | e['worst-case'].to_i + 33 | e['most-likely'].to_i * 4) / 6 34 | Est.log.info "#{i}: (#{e['best-case']} + #{e['worst-case']} +"\ 35 | " #{e['most-likely']} * 4) / 6 = #{total}" 36 | total 37 | end.reduce(&:+) 38 | total = sum * k * (n / m) 39 | Est.log.info "#{sum} * #{k} * (#{n} / #{m}) = #{total}" 40 | total.to_i 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.github/workflows/license.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: license 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | license: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - shell: bash 20 | run: | 21 | header="Copyright (c) $(date +%Y) Yegor Bugayenko" 22 | failed="false" 23 | while IFS= read -r file; do 24 | if ! grep -q "${header}" "${file}"; then 25 | failed="true" 26 | echo "⚠️ Copyright header is not found in: ${file}" 27 | else 28 | echo "File looks good: ${file}" 29 | fi 30 | done < <(find . -type f \( \ 31 | -name "Dockerfile" -o \ 32 | -name "LICENSE.txt" -o \ 33 | -name "Makefile" -o \ 34 | -name "Rakefile" -o \ 35 | -name "*.sh" -o \ 36 | -name "*.rb" -o \ 37 | -name "*.fe" -o \ 38 | -name "*.yml" \ 39 | \) -print) 40 | if [ "${failed}" = "true" ]; then 41 | exit 1 42 | fi 43 | -------------------------------------------------------------------------------- /lib/est/estimate.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'est/version' 7 | require 'est/methods/champions' 8 | require 'logger' 9 | require 'yaml' 10 | 11 | # Single estimate. 12 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 13 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 14 | # License:: MIT 15 | module Est 16 | # Estimate. 17 | class Estimate 18 | # Ctor. 19 | # +file+:: File with YAML estimate 20 | def initialize(file) 21 | @yaml = YAML.load_file(file) 22 | fail "failed to read file #{file}" unless @yaml 23 | end 24 | 25 | # Get date. 26 | def date 27 | Date.strptime(@yaml['date'], '%d-%m-%Y') 28 | end 29 | 30 | # Get author. 31 | def author 32 | @yaml['author'] 33 | end 34 | 35 | # Get total estimate. 36 | def total 37 | method = @yaml['method'] 38 | fail "unsupported method #{method}" unless method == 'champions.pert' 39 | Champions.new(@yaml).total 40 | end 41 | 42 | # Constant estimate. 43 | class Const 44 | attr_reader :date, :author, :total 45 | # Ctor. 46 | # +est+:: Estimate 47 | def initialize(est) 48 | @date = est.date 49 | @author = est.author 50 | @total = est.total 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'rubygems' 7 | require 'rake' 8 | require 'rdoc' 9 | require 'rake/clean' 10 | 11 | def name 12 | @name ||= File.basename(Dir['*.gemspec'].first, '.*') 13 | end 14 | 15 | def version 16 | Gem::Specification.load(Dir['*.gemspec'].first).version 17 | end 18 | 19 | task default: [:clean, :test, :features, :rubocop, :copyright] 20 | 21 | require 'rake/testtask' 22 | desc 'Run all unit tests' 23 | Rake::TestTask.new(:test) do |test| 24 | Rake::Cleaner.cleanup_files(['coverage']) 25 | test.libs << 'lib' << 'test' 26 | test.pattern = 'test/**/test_*.rb' 27 | test.verbose = false 28 | end 29 | 30 | require 'rdoc/task' 31 | desc 'Build RDoc documentation' 32 | Rake::RDocTask.new do |rdoc| 33 | rdoc.rdoc_dir = 'rdoc' 34 | rdoc.title = "#{name} #{version}" 35 | rdoc.rdoc_files.include('README*') 36 | rdoc.rdoc_files.include('lib/**/*.rb') 37 | end 38 | 39 | require 'rubocop/rake_task' 40 | desc 'Run RuboCop on all directories' 41 | RuboCop::RakeTask.new(:rubocop) do |task| 42 | task.fail_on_error = true 43 | task.requires << 'rubocop-rspec' 44 | end 45 | 46 | require 'cucumber/rake/task' 47 | Cucumber::Rake::Task.new(:features) do |t| 48 | Rake::Cleaner.cleanup_files(['coverage']) 49 | t.profile = 'travis' 50 | end 51 | Cucumber::Rake::Task.new(:'features:html') do |t| 52 | t.profile = 'html_report' 53 | end 54 | -------------------------------------------------------------------------------- /test/test_estimates.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'minitest/autorun' 7 | require 'nokogiri' 8 | require 'est/estimates' 9 | require 'tmpdir' 10 | require 'slop' 11 | 12 | # Est main module test. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 15 | # License:: MIT 16 | class TestEstimates < Minitest::Test 17 | def test_basic_calculation 18 | Dir.mktmpdir 'test' do |dir| 19 | File.write( 20 | File.join(dir, 'first.est'), 21 | ''' 22 | date: 12-08-2017 23 | author: Yegor Bugayenko 24 | method: champions.pert 25 | scope: 26 | 1: basic Sinatra scaffolding 27 | 2: front-end HAML files 28 | 3: SASS stylesheet 29 | 4: five model classes with unit tests 30 | 5: PostgreSQL migrations 31 | 6: Cucumber tests for PostgreSQL 32 | 7: Capybara tests for HTML front 33 | 8: CasperJS tests 34 | 9: achieve 80% test coverage 35 | champions: 36 | 7: 37 | worst-case: 40 38 | best-case: 10 39 | most-likely: 18 40 | 4: 41 | worst-case: 30 42 | best-case: 8 43 | most-likely: 16 44 | ''' 45 | ) 46 | estimates = Est::Estimates.new(dir) 47 | assert_equal 79, estimates.total 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/test_estimate.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'minitest/autorun' 7 | require 'nokogiri' 8 | require 'est/estimates' 9 | require 'tmpdir' 10 | require 'slop' 11 | 12 | # Est main module test. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 15 | # License:: MIT 16 | class TestEstimate < Minitest::Test 17 | def test_basic_calculation 18 | Dir.mktmpdir 'test' do |dir| 19 | file = File.join(dir, 'first.est') 20 | File.write( 21 | file, 22 | ''' 23 | date: 18-12-2017 24 | author: Jeff Lebowski 25 | method: champions.pert 26 | scope: 27 | 1: basic Sinatra scaffolding 28 | 2: front-end HAML files 29 | 3: SASS stylesheet 30 | 4: five model classes with unit tests 31 | 5: PostgreSQL migrations 32 | 6: Cucumber tests for PostgreSQL 33 | 7: Capybara tests for HTML front 34 | 8: CasperJS tests 35 | 9: achieve 80% test coverage 36 | champions: 37 | 7: 38 | worst-case: 40 39 | best-case: 10 40 | most-likely: 18 41 | 4: 42 | worst-case: 30 43 | best-case: 8 44 | most-likely: 16 45 | ''' 46 | ) 47 | estimate = Est::Estimate.new(file) 48 | assert_equal Date.parse('18-12-2017'), estimate.date 49 | assert_equal 'Jeff Lebowski', estimate.author 50 | assert_equal 79, estimate.total 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/est/estimates.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'est/estimate' 7 | require 'nokogiri' 8 | require 'logger' 9 | require 'time' 10 | 11 | # Est main module. 12 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 13 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 14 | # License:: MIT 15 | module Est 16 | # Estimates. 17 | class Estimates 18 | # Ctor. 19 | # +dir+:: Directory with estimates 20 | def initialize(dir) 21 | @dir = dir 22 | end 23 | 24 | # Get total estimate. 25 | def total 26 | estimates = iterate 27 | if estimates.empty? 28 | total = 0 29 | else 30 | total = estimates.reduce(0) do |a, e| 31 | Est.log.info "#{e.date}/#{e.author}: #{e.total}" 32 | a + e.total 33 | end / estimates.size 34 | end 35 | total 36 | end 37 | 38 | # Iterate them all 39 | def iterate 40 | unless @iterate 41 | if File.exist?(@dir) && File.directory?(@dir) 42 | @iterate = Dir.entries(@dir) 43 | .reject { |f| f.index('.') == 0 } 44 | .select { |f| f =~ /^.*\.est$/ } 45 | .map { |f| File.join(@dir, f) } 46 | .each { |f| Est.log.info "#{f} found" } 47 | .map { |f| Estimate.new(f) } 48 | .map { |f| Estimate::Const.new(f) } 49 | else 50 | Est.log.info "#{@dir} is absent or is not a directory" 51 | @iterate = [] 52 | end 53 | end 54 | @iterate 55 | end 56 | 57 | # Const estimates. 58 | class Const 59 | attr_reader :total, :iterate 60 | # Ctor. 61 | # +est+:: Original estimates 62 | def initialize(est) 63 | @iterate = est.iterate 64 | @total = est.total 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /assets/est.xsl: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 9 | <!DOCTYPE html> 10 | 11 | 12 | 13 | 14 | 15 | 16 | <xsl:text>Estimate</xsl:text> 17 | 37 | 38 | 39 |
40 |

Estimate

41 |

42 | Total: 43 | 44 |

45 | 46 |
47 | 48 | 49 |
50 | 51 | 52 | : 53 | 54 | hours by 55 | 56 | 57 |
58 | -------------------------------------------------------------------------------- /bin/est: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: utf-8 3 | # 4 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 5 | # SPDX-License-Identifier: MIT 6 | 7 | STDOUT.sync = true 8 | 9 | require 'slop' 10 | require 'nokogiri' 11 | require 'est' 12 | require 'est/version' 13 | 14 | opts = Slop.parse(ARGV, strict: true, help: true) do 15 | banner "Usage (#{Est::VERSION}): est [options]" 16 | on 'v', 'verbose', 'Enable verbose mode' 17 | on 'version', 'Show current version' 18 | on( 19 | 'd', 20 | 'dir', 21 | 'Source directory to parse', 22 | argument: :required 23 | ) 24 | on( 25 | 'f', 26 | 'file', 27 | 'File to save output into', 28 | argument: :required 29 | ) 30 | on( 31 | 't', 32 | 'format', 33 | 'Format to use (xml|html|text)', 34 | argument: :required 35 | ) 36 | end 37 | 38 | fail '-f is mandatory when using -v' if opts.verbose? && !opts.file? 39 | 40 | if opts.help? 41 | puts opts 42 | exit 43 | end 44 | 45 | if opts.version? 46 | puts Est::VERSION 47 | exit 48 | end 49 | 50 | Encoding.default_external = Encoding::UTF_8 51 | Encoding.default_internal = Encoding::UTF_8 52 | if opts.file? 53 | file = File.new(opts[:file], 'w') 54 | Est.log.info "output saving into #{file.path}" 55 | else 56 | file = STDOUT 57 | end 58 | output = Est::Base.new(opts).xml 59 | if opts[:format].nil? || opts[:format] == 'text' 60 | xslt = File.join( 61 | File.dirname(File.dirname(__FILE__)), 62 | 'assets', 'est-text.xsl' 63 | ) 64 | output = Nokogiri::XSLT(File.read(xslt)).apply_to(Nokogiri::XML(output)) 65 | elsif opts[:format] == 'html' 66 | Est.log.info 'using HTML format' 67 | xslt = File.join( 68 | File.dirname(File.dirname(__FILE__)), 69 | 'assets', 'est.xsl' 70 | ) 71 | output = Nokogiri::XSLT(File.read(xslt)).transform(Nokogiri::XML(output)) 72 | elsif opts[:format] == 'xml' 73 | Est.log.info 'using XML format' 74 | else 75 | fail 'invalid format, use html, text, or xml' 76 | end 77 | file << output 78 | -------------------------------------------------------------------------------- /features/cli.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Command Line Processing 4 | As an estimator I want to be able to 5 | call Est as a command line tool 6 | 7 | Scenario: Help can be printed 8 | When I run bin/est with "-h" 9 | Then Exit code is zero 10 | And Stdout contains "-v, --verbose" 11 | 12 | Scenario: Version can be printed 13 | When I run bin/est with "--version" 14 | Then Exit code is zero 15 | 16 | Scenario: Simple estimate calculating, in XML 17 | Given I have a "sample.est" file with content: 18 | """ 19 | date: 19-08-2017 20 | author: Yegor Bugayenko 21 | method: champions.pert 22 | scope: 23 | 1: basic Sinatra scaffolding 24 | 2: front-end HAML files 25 | 3: SASS stylesheet 26 | 4: five model classes with unit tests 27 | 5: PostgreSQL migrations 28 | 6: Cucumber tests for PostgreSQL 29 | 7: Capybara tests for HTML front 30 | 8: CasperJS tests 31 | 9: achieve 80% test coverage 32 | champions: 33 | 7: 34 | worst-case: 40 35 | best-case: 10 36 | most-likely: 18 37 | 4: 38 | worst-case: 30 39 | best-case: 8 40 | most-likely: 16 41 | """ 42 | When I run bin/est with "-v -d . -t xml -f out.xml" 43 | Then Exit code is zero 44 | And Stdout contains "reading ." 45 | And XML file "out.xml" matches "/estimate[total='79']" 46 | 47 | Scenario: Simple estimate calculating, in Text 48 | Given I have a "sample.est" file with content: 49 | """ 50 | date: 19-08-2012 51 | author: Yegor Bugayenko 52 | method: champions.pert 53 | scope: 54 | 1: basic Sinatra scaffolding 55 | 2: front-end HAML files 56 | 3: SASS stylesheet 57 | 4: five model classes with unit tests 58 | 5: PostgreSQL migrations 59 | 6: Cucumber tests for PostgreSQL 60 | 7: Capybara tests for HTML front 61 | 8: CasperJS tests 62 | 9: achieve 80% test coverage 63 | champions: 64 | 7: 65 | worst-case: 40 66 | best-case: 10 67 | most-likely: 18 68 | 4: 69 | worst-case: 30 70 | best-case: 8 71 | most-likely: 16 72 | """ 73 | When I run bin/est with "-d ." 74 | Then Exit code is zero 75 | And Stdout contains "Total: 79" 76 | And Stdout contains "2012-08-19: 79 hours by Yegor Bugayenko" 77 | 78 | Scenario: Rejects unknown options 79 | Given I have a "test.est" file with content: 80 | """ 81 | """ 82 | When I run bin/est with "--some-unknown-option" 83 | Then Exit code is not zero 84 | -------------------------------------------------------------------------------- /features/step_definitions/steps.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'est' 7 | require 'nokogiri' 8 | require 'tmpdir' 9 | require 'slop' 10 | require 'English' 11 | 12 | Before do 13 | @cwd = Dir.pwd 14 | @dir = Dir.mktmpdir('test') 15 | FileUtils.mkdir_p(@dir) unless File.exist?(@dir) 16 | Dir.chdir(@dir) 17 | @opts = Slop.parse ['-v', '-s', @dir] do 18 | on 'v', 'verbose' 19 | on 's', 'source', argument: :required 20 | end 21 | end 22 | 23 | After do 24 | Dir.chdir(@cwd) 25 | FileUtils.rm_rf(@dir) if File.exist?(@dir) 26 | end 27 | 28 | Given(/^I have a "([^"]*)" file with content:$/) do |file, text| 29 | FileUtils.mkdir_p(File.dirname(file)) unless File.exist?(file) 30 | File.open(file, 'w') do |f| 31 | f.write(text) 32 | end 33 | end 34 | 35 | When(/^I run est$/) do 36 | @xml = Nokogiri::XML.parse(Est::Base.new(@opts).xml) 37 | end 38 | 39 | Then(/^XML matches "([^"]+)"$/) do |xpath| 40 | fail "XML doesn't match \"#{xpath}\":\n#{@xml}" if @xml.xpath(xpath).empty? 41 | end 42 | 43 | When(/^I run est it fails with "([^"]*)"$/) do |txt| 44 | begin 45 | Est::Base.new(@opts).xml 46 | passed = true 47 | rescue Est::Error => ex 48 | unless ex.message.include?(txt) 49 | raise "Est failed but exception doesn't contain \"#{txt}\": #{ex.message}" 50 | end 51 | end 52 | fail "Est didn't fail" if passed 53 | end 54 | 55 | When(/^I run bin\/est with "([^"]*)"$/) do |arg| 56 | home = File.join(File.dirname(__FILE__), '../..') 57 | @stdout = `ruby -I#{home}/lib #{home}/bin/est #{arg}` 58 | @exitstatus = $CHILD_STATUS.exitstatus 59 | end 60 | 61 | Then(/^Stdout contains "([^"]*)"$/) do |txt| 62 | unless @stdout.include?(txt) 63 | fail "STDOUT doesn't contain '#{txt}':\n#{@stdout}" 64 | end 65 | end 66 | 67 | Then(/^Stdout is empty$/) do 68 | fail "STDOUT is not empty:\n#{@stdout}" unless @stdout == '' 69 | end 70 | 71 | Then(/^XML file "([^"]+)" matches "([^"]+)"$/) do |file, xpath| 72 | fail "File #{file} doesn't exit" unless File.exist?(file) 73 | xml = Nokogiri::XML.parse(File.read(file)) 74 | xml.remove_namespaces! 75 | if xml.xpath(xpath).empty? 76 | fail "XML file #{file} doesn't match \"#{xpath}\":\n#{xml}" 77 | end 78 | end 79 | 80 | Then(/^Exit code is zero$/) do 81 | fail "Non-zero exit code #{@exitstatus}" unless @exitstatus == 0 82 | end 83 | 84 | Then(/^Exit code is not zero$/) do 85 | fail 'Zero exit code' if @exitstatus == 0 86 | end 87 | 88 | When(/^I run bash with$/) do |text| 89 | FileUtils.copy_entry(@cwd, File.join(@dir, 'est')) 90 | @stdout = `#{text}` 91 | @exitstatus = $CHILD_STATUS.exitstatus 92 | end 93 | -------------------------------------------------------------------------------- /test/test_est.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'minitest/autorun' 7 | require 'nokogiri' 8 | require 'est' 9 | require 'tmpdir' 10 | require 'slop' 11 | 12 | # Est main module test. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 15 | # License:: MIT 16 | class TestEst < Minitest::Test 17 | def test_basic 18 | Dir.mktmpdir 'test' do |dir| 19 | opts = opts(['-v', '-d', dir]) 20 | File.write( 21 | File.join(dir, 'sample.est'), 22 | ''' 23 | date: 12-07-2017 24 | author: Yegor Bugayenko 25 | method: champions.pert 26 | scope: 27 | 1: basic Sinatra scaffolding 28 | 2: front-end HAML files 29 | 3: SASS stylesheet 30 | 4: five model classes with unit tests 31 | 5: PostgreSQL migrations 32 | 6: Cucumber tests for PostgreSQL 33 | 7: Capybara tests for HTML front 34 | 8: CasperJS tests 35 | 9: achieve 80% test coverage 36 | champions: 37 | 7: 38 | worst-case: 40 39 | best-case: 10 40 | most-likely: 18 41 | 4: 42 | worst-case: 30 43 | best-case: 8 44 | most-likely: 16 45 | ''' 46 | ) 47 | matches( 48 | Nokogiri::XML(Est::Base.new(opts).xml), 49 | [ 50 | '/processing-instruction("xml-stylesheet")[contains(.,".xsl")]', 51 | '/estimate/@version', 52 | '/estimate/@date', 53 | '/estimate[total="79"]', 54 | '/estimate/ests[count(est)=1]' 55 | ] 56 | ) 57 | end 58 | end 59 | 60 | def test_empty_dir 61 | Dir.mktmpdir 'test' do |dir| 62 | opts = opts(['-v', '-d', dir]) 63 | matches( 64 | Nokogiri::XML(Est::Base.new(opts).xml), 65 | [ 66 | '/estimate/@version', 67 | '/estimate/@date', 68 | '/estimate[total="0"]', 69 | '/estimate[not(ests)]' 70 | ] 71 | ) 72 | end 73 | end 74 | 75 | def test_empty_dir 76 | Dir.mktmpdir 'test' do |dir| 77 | opts = opts(['-v', '-d', File.join(dir, 'absent')]) 78 | matches( 79 | Nokogiri::XML(Est::Base.new(opts).xml), 80 | [ 81 | '/estimate/@version', 82 | '/estimate/@date', 83 | '/estimate[total="0"]', 84 | '/estimate[not(ests)]' 85 | ] 86 | ) 87 | end 88 | end 89 | 90 | def opts(args) 91 | Slop.parse args do 92 | on 'v', 'verbose' 93 | on 'd', 'dir', argument: :required 94 | end 95 | end 96 | 97 | def matches(xml, xpaths) 98 | xpaths.each do |xpath| 99 | fail "doesn't match '#{xpath}': #{xml}" unless xml.xpath(xpath).size == 1 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/est.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'est/version' 7 | require 'est/estimates' 8 | require 'nokogiri' 9 | require 'logger' 10 | require 'time' 11 | 12 | # Est main module. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 15 | # License:: MIT 16 | module Est 17 | # If it breaks. 18 | class Error < StandardError 19 | end 20 | 21 | # If it violates XSD schema. 22 | class SchemaError < Error 23 | end 24 | 25 | # Get logger. 26 | def self.log 27 | unless @logger 28 | @logger = Logger.new(STDOUT) 29 | @logger.formatter = proc { |severity, _, _, msg| 30 | "#{severity}: #{msg.dump}\n" 31 | } 32 | @logger.level = Logger::ERROR 33 | end 34 | @logger 35 | end 36 | 37 | class << self 38 | attr_writer :logger 39 | end 40 | 41 | # Code base abstraction 42 | class Base 43 | # Ctor. 44 | # +opts+:: Options 45 | def initialize(opts) 46 | @opts = opts 47 | Est.log.level = Logger::INFO if @opts.verbose? 48 | Est.log.info "my version is #{Est::VERSION}" 49 | end 50 | 51 | # Generate XML. 52 | def xml 53 | dir = @opts.dir? ? @opts[:dir] : Dir.pwd 54 | Est.log.info "reading #{dir}" 55 | estimates = Estimates::Const.new(Estimates.new(dir)) 56 | sanitize( 57 | Nokogiri::XML::Builder.new do |xml| 58 | xml << "" 59 | xml.estimate(attrs) do 60 | xml.total estimates.total 61 | unless estimates.iterate.empty? 62 | xml.ests do 63 | estimates.iterate.each do |est| 64 | xml.est do 65 | xml.date est.date 66 | xml.total est.total 67 | xml.author est.author 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end.to_xml 74 | ) 75 | end 76 | 77 | private 78 | 79 | def attrs 80 | { 81 | 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 82 | 'xsi:noNamespaceSchemaLocation' => "#{host('xsd')}/#{Est::VERSION}.xsd", 83 | 'version' => Est::VERSION, 84 | 'date' => Time.now.utc.iso8601 85 | } 86 | end 87 | 88 | def host(suffix) 89 | "http://est-#{suffix}.teamed.io" 90 | end 91 | 92 | def xsl 93 | "#{host('xsl')}/#{Est::VERSION}.xsl" 94 | end 95 | 96 | def sanitize(xml) 97 | xsd = Nokogiri::XML::Schema( 98 | File.read(File.join(File.dirname(__FILE__), '../assets/est.xsd')) 99 | ) 100 | errors = xsd.validate(Nokogiri::XML(xml)).map(&:message) 101 | errors.each { |e| Est.log.error e } 102 | Est.log.error(xml) unless errors.empty? 103 | fail SchemaError, errors.join('; ') unless errors.empty? 104 | xml 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Managed by Zerocracy](http://www.zerocracy.com/badge.svg)](http://www.zerocracy.com) 2 | [![DevOps By Rultor.com](https://www.rultor.com/b/yegor256/est)](https://www.rultor.com/p/yegor256/est) 3 | [![We recommend RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/) 4 | 5 | [![Build Status](https://travis-ci.org/yegor256/est.svg)](https://travis-ci.org/yegor256/est) 6 | [![Gem Version](https://badge.fury.io/rb/est.svg)](https://badge.fury.io/rb/est) 7 | [![Dependency Status](https://gemnasium.com/yegor256/est.svg)](https://gemnasium.com/yegor256/est) 8 | [![Code Climate](https://img.shields.io/codeclimate/github/yegor256/est.svg)](https://codeclimate.com/github/yegor256/est) 9 | [![Coverage Status](https://img.shields.io/coveralls/yegor256/est.svg)](https://coveralls.io/r/yegor256/est) 10 | 11 | Install it first: 12 | 13 | ```bash 14 | $ gem install est 15 | ``` 16 | 17 | Run it locally and read its output: 18 | 19 | ```bash 20 | $ est --help 21 | ``` 22 | 23 | Every estimate should be in its own file, with `.est` extension (YAML format). 24 | Here is an example of an estimate file `simple.est` for a simple web app: 25 | 26 | ```yaml 27 | date: 19-12-2017 28 | author: Yegor Bugayenko 29 | method: champions.pert 30 | scope: 31 | 1: basic Sinatra scaffolding 32 | 2: front-end HAML files 33 | 3: SASS stylesheet 34 | 4: five model classes + unit/integration tests 35 | 5: PostgreSQL migrations 36 | champions: 37 | 2: 38 | worst-case: 40 39 | best-case: 10 40 | most-likely: 18 41 | 5: 42 | worst-case: 30 43 | best-case: 8 44 | most-likely: 16 45 | ``` 46 | 47 | All estimates found in a directory will be combined and a final 48 | project estimate will be produced: 49 | 50 | ```bash 51 | $ est --dir=./est 52 | Total: 27 53 | 2014-12-19: 27 hours by Yegor Bugayenko 54 | ``` 55 | 56 | ## Scope Champions 57 | 58 | Scope Champions estimating method was introduced a 59 | patent application [US 12/193,010](https://www.google.com/patents/US20100042968). 60 | This article explains it in more details: 61 | [Revolutionary Method Of Cost Estimating](http://www.technoparkcorp.com/innovations/scope-champions/). 62 | In a nutshell, there are three steps. 63 | 64 | First, you break down the entire implementation scope into items, like 65 | it's done above, and list them under `scope`. Pay attention, you should list 66 | only technical code-writing tasks. Testing, requirements analysis, thinking 67 | and talking should not go into this list. Imagive, what would you do 68 | if you would be the only programmer working with the product. Imagine, you 69 | have to create the product from scratch, being the only programmer in house. 70 | It is important to keep all work items on the same level of abstraction. This 71 | means that the complexity of all items should be approximately the same. 72 | 73 | Second, select a few items from the list (2-3), which are the most difficult 74 | to implement. They are called "scope champions". List their numbers 75 | under `champions`, as it's done above. 76 | 77 | Third, estimate that champions using [three-point estimating method](https://en.wikipedia.org/wiki/Three-point_estimation). 78 | As in the example above, every scope champion should get three numbers. 79 | Worst case is how many hours you would spend on it, if everything would 80 | appear to be very difficult and most probable risks would happen. Best 81 | case is how many hours would this work take if everything would go easy 82 | and without any risks. Most likely is how much would it take, in a normal 83 | situation, according to your estimate. 84 | 85 | ## Best Practices 86 | 87 | **Don't Look Back**. Try not to look into previous estimates 88 | made in the project. It's tempting, but try to control yourself. Create 89 | your own estimate first and then look at others that already exist in 90 | the project. 91 | 92 | **Coding Time Only**. Estimate code writing time only. Don't estimate 93 | time you would spend on discussions, thinking, modeling, diagramming, 94 | documenting etc. The estimate should count only the time you, as a single 95 | programmer in the project, would spend on code writing. 96 | 97 | **Estimate Regularly**. Re-estimate the entire project from scratch regularly. 98 | In each estimate look at the project as a whole and estimate the entire 99 | scope. Not what's left, but the entire scope, as if you would need to 100 | re-create it all from scretch. Even if the project is close to its end, 101 | don't stop re-estimating it. 102 | 103 | **Change Estimators**. Try to ask everybody in the project to estimate it 104 | time to time (programmers only). Changing estimators will help the project 105 | to keep numbers out of bias. 106 | 107 | **Count On Your Skills**. Estimate the amount of work you would need to 108 | develop the product, not some abstract programmer. Rely on your personal 109 | skills, speed and expertise. 110 | --------------------------------------------------------------------------------