├── 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 | Estimate
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 | [](http://www.zerocracy.com)
2 | [](https://www.rultor.com/p/yegor256/est)
3 | [](https://www.jetbrains.com/ruby/)
4 |
5 | [](https://travis-ci.org/yegor256/est)
6 | [](https://badge.fury.io/rb/est)
7 | [](https://gemnasium.com/yegor256/est)
8 | [](https://codeclimate.com/github/yegor256/est)
9 | [](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 |
--------------------------------------------------------------------------------