Rlib
====
 
Chr. Clemens Lahme
 
2026-02-20
 
Rlib is a small general purpose library to make programming in the Ruby
language a little more convenient (than it already is).
 
Table of Contents
-----------------
  1. System Requirements
    1. Ruby Gems
  2. License
  3. Download
  4. Build
  5. Functional Requirements
  6. Log
  7. Coverage Checker
  8. Coverage Tests Example
  9. Log Coverage Tests
  10. Main Library Class
  11. Rlib Coverage Tests
  12. Timestamp Script
  13. Measure Time Differences In The Terminal
  14. Terminal
    1. Plan
  15. Term
  16. Term Coverage Tests
  17. Input
    1. Example Program
    2. Library
  18. CursesWrapper
  19. Curses Wrapper Coverage Tests
  20. A Stack
    1. Coverage Tests
  21. Screen
  22. Testing Escape Codes in Screen
  23. Screen Coverage Test
  24. Screen Print Example
  25. Menu
  26. Menu Coverage Tests
  27. Ruby Template
  28. Interactive Table Selector
  29. Table Tests
  30. Table Test Coverage
  31. More Ruby Pager
    1. Test Coverage
  32. Release History
 
1. System Requirements
----------------------
 
- Linux.
- Ruby 2 or 3.
- Make.
- SimpleCov library for line and branch coverage testing.
- curses gem.
 
1.1. Ruby Gems
''''''''''''''
 
SimpleCov
 
In order to test for branch coverage in addition to line coverage, we need a
version higher than 0.17.1 but a version lower than 0.22.0, as the latter one
does not support Ruby 2.6.10 anymore.
 
Here is tha sha256sum for the 0.21.2 gem file as well as its dependencies:
 
96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e  docile-1.4.1.gem
bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246  simplecov-html-0.13.2.gem
529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428  simplecov_json_formatter-0.1.4.gem
990db6aedb55086d6bf8874993ff1f796e4830abfa11937468ca502a0d013bc3  simplecov-0.21.2.gem
 
Curses
 
And here is the sha256sum for the last curses gem, that supports Ruby 2.6:
 
1e9c03519f709d76d0cd4a00fe237ba7f2bafe530e5825163e068bdac4648a7b  curses-1.4.7.gem
 
2. License
----------
 
Gossip is licensed under the GNU Public License (GPL) version 2, see:
 
file://COPYING.txt
 
3. Download
-----------
 
The home page of this project is at: http://techinvest.li/rlib/
 
You can download the source code with the following git command:
 
git clone https://techinvest.li/git/rlib.git
 
4. Build
--------
 
In order to extract all Ruby source files from this document, invoke the make
tool in the rlib project directory:
 
make
 
Among others the lib/rlib.rb file should have been extracted:
 
ls -la lib/rlib.rb
 
5. Functional Requirements
--------------------------
 
- No Ruby modules used (except when monkey patching existing libraries).
- Projects that use Rlib will use it directly by copying its source files and
  not via a Ruby Gem file.
- Ruby style:
  - Strings by default are mutable.
  - We use "while true" instead of "loop do".
 
6. Log
------
 
The Rlib 'Log' class aims to provide with a minimum of code a maximum of
convenience. It is a "puts with levels" and an automatically located log file
attached to it. Type 'log "hello"' and be done with it.
 
Comparing it to Ruby's standard 'Logger' class, here are its advantages over
the standard 'Logger' class:
 
1. Zero-boilerplate instantiation.
   'Logger' typically needs instantiation (e.g., 'logger =
   Logger.new(STDOUT)'). You have to pass it around or use a global. 'Log'
   works purely with class methods – 'Log.level = Log::DEBUG' and
   'log("message")' – no 'new', no variable to hold.
 
2. A single, global, ultra-short convenience function.
   The 'log( message )' top-level method lets you write exactly like 'puts' or
   'print'. That is the absolute minimum typing possible.
 
3. Automatic log-directory discovery.
   'Logger' requires you to specify the output destination explicitly. Your
   'Log.dir' method searches a chain of sensible Unix-style locations
   (project/log, rlib/log, ~/.local/var/log, /tmp) and picks the first writable
   one without any configuration. This "just works" for the majority of
   development machines without a second thought.
 
4. Pre-configured, consistent output format.
   Every message is automatically prefixed with 'YYYY-MM-DD HH:MM:SS' and the
   process id. With 'Logger' you'd need to define a custom formatter to match
   this. Your format is embedded with zero extra code to write.
 
5. Built-in 'dump_class' utility
   'Log.dump_class(object)' gives you class name, instance variables, and
   non-inherited methods. It's a handy debugging companion.
 
6. Copy-paste deployment (no Gem, no dependencies).
   Your 'Log' is a single, self-contained file. You can literally copy it into
   any project and it runs.
 
7. Intentionally limited scope = reduced cognitive load.
   'Logger' introduces terminologies like 'progname', 'datetime_format',
   shifting, multiple devices, and different methods for
   'info'/'error'/'warn'. The Log class has only what one normally uses: five
   levels, a straightforward output method, and a clean on/off. Anyone reading
   the code can understand it in a minute and also adapt it to their needs or
   style if necessary.
 
8. Keeps it simple.
   There is no need to reason about log rotation or device management. Logs
   always go to one place (or stdout only). 'Logger' supports multiple outputs,
   rotation, etc., which is powerful but adds complexity. For quick scripting
   and fast startup time for a new project, that simplicity is an advantages
   and IMHO worth having this extra class.
 
Of course, the Log class could change in the future encapsulate a Logger object
already setup, if more scale and mutli-threading support are needed.
 
Potential features to add:
 
1. Let user set the base log file name.
2. Surpress file logging (by setting the base file name to nil).
 
cat > lib/log.rb <<EOT
# Do not edit this file, as it gets generated automatically by lp.
 
# Check if a path is a writable directory or not. Separated in own class
# for easy test mocking.
class DirCheck
  # Returns true if +dir_param+ is an existing, writable directory.
  def ok?( dir_param )
    ret_val = File.directory?( dir_param ) && File.writable?( dir_param )
 
    return ret_val
  end
end
 
# A simple but convenient logging class designed for Unix-like systems,
# providing minimal typing effort for developers. It supports multiple log
# levels, outputs messages to both standard output and a log file (if a
# writable log directory can be found), and includes a helper for inspecting
# object class structures.
 
# The library also provides a top-level 'log()' function for the most common
# usage.
 
# Log Levels
 
# The following log level constants are defined inside the 'Log' class:
 
# | Constant | Integer Value | Description                                      |
# |----------|---------------|--------------------------------------------------|
# | 'SILENT' | 0             | Output nothing at all (not actually used in methods; could be used by setting '@@level = 0') |
# | 'ERROR'  | 1             | Only error messages                              |
# | 'WARN'   | 2             | Warnings and errors (default)                    |
# | 'BRIEF'  | 3             | Brief informational messages                     |
# | 'LOG'    | 4             | Standard log messages (used by the 'log' helper) |
# | 'DEBUG'  | 5             | Debug messages, most verbose                     |
 
# The default log level is 'WARN' (2).
 
# Log Directory
 
# The log file is written to 'rlib.log' inside the first existing and
# user-writable directory found by 'Log.dir'. That method searches in this
# order:
 
# 1. '<project>/log'  – relative to the directory of the script being executed ('$0')
# 2. '<project>/logs'
# 3. 'rlib/log'      – relative to the directory of this source file ('__FILE__')
# 4. 'rlib/logs'
# 5. '$HOME/.local/var/log' – if the 'HOME' environment variable is set
# 6. '/tmp'
 
# If none of these directories exist or are writable, file logging is disabled
#  – messages only appear on standard output.
 
# Using the Log Class
 
# Basic Setup
 
# require 'log'
 
# Log.on        # set level to LOG (4)
# log "hello"   # prints timestamped message to stdout and to log file if available
 
# Adjusting the Log Level
 
# Log.level=( Log::DEBUG )    # most verbose
# Log.level=( Log::ERROR )    # only errors
# Log.level=( Log::WARN  )    # default
 
# Logging Methods
 
# All public methods ('Log.error', 'Log.warn', 'Log.brief', 'Log.log',
# 'Log.debug') follow the same pattern:
 
# Log.debug "This is a debug message"
# Log.log   "Standard operational message"
# Log.warn  "Something might be wrong"
# Log.error "An error occurred!"
 
# Each method checks '@@level' before outputting. The output consists of:
 
# - ISO date and time ('YYYY-MM-DD HH:MM:SS')
# - Process ID ('Process.pid')
# - The supplied message
 
# Top-Level Convenience Function
 
# 'log( message )' is a shortcut for 'Log.log( message )'. This makes logging
# extremely terse:
 
# log "Starting process..."
# equivalent to:
# Log.log "Starting process..."
 
# Disabling Logging
 
# Log.off   # sets level back to WARN, effectively hiding LOG and DEBUG messages
 
# Accessing the Log Level
 
# current_level = Log.level()
 
# Debugging Class Structures
 
# 'Log.dump_class( object )' returns a string containing:
 
# - The class name of 'object'
# - Instance variables of its class
# - Non-inherited methods (sorted alphabetically)
 
# Example:
 
# class Foo
#   attr_accessor :bar
#   def baz; end
# end
 
# f = Foo.new
# puts Log.dump_class(f)
# Output:
# Class:       Foo
#   Variables:  [:@bar]
#   Methods:    [:bar, :bar=, :baz]
 
# Complete Example
 
# require 'log'
 
# Log.level=( Log::DEBUG )
 
# log "Application started"
# Log.debug "Debug information: variables loaded"
# Log.warn  "Disk space is running low"
# Log.error "Failed to connect to database"
 
# Log.off
# log "This will NOT appear because Log level is now WARN"
 
# This file is part of the Rlib library. It is intended to be copied directly into projects rather than used as a Gem.
 
# 2021-04-16 13:31:01
 
class Log
  SILENT = 0
  ERROR  = SILENT + 1
  WARN   = ERROR + 1
  BRIEF  = WARN + 1
  LOG    = BRIEF + 1
  DEBUG  = LOG + 1
 
  @@level = WARN
 
  @@dir = nil
 
  @@log_filename = nil
  @@file_only    = false
 
  # Returns the path of the first writable log directory found in the
  # predefined search order (project/log, project/logs, rlib/log,
  # rlib/logs, $HOME/.local/var/log, /tmp). Caches the result.
  # If no suitable directory is found, returns nil and file logging is disabled.
  def self.dir
    if @@dir != nil
      return @@dir
    end
 
    # If for checking purposes a mock checking object has been already set,
    # then we don't create the default checking object.
    if $dir_check == nil
      #@@log_filename = "#{@@dir}/rlib.log"
      $dir_check = DirCheck.new
    end
 
    #@@dir = File.absolute_path( File.dirname( __FILE__ ) + '/../log' )
    @@dir = File.absolute_path( File.dirname( $0 ) + '/../log' )
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
    @@dir << 's'
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
 
    @@dir = File.absolute_path( File.dirname( __FILE__ ) + '/../log' )
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
    @@dir << 's'
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
 
    @@dir = "#{ENV[ 'HOME' ]}/.local/var/log"
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
 
    @@dir = '/tmp'
    if $dir_check.ok?( @@dir )
      @@log_filename = "#{@@dir}/rlib.log"
      return @@dir
    end
 
    # Nothing found. Clear the state again.
    @@dir = nil
 
    return @@dir
  end
 
  @@dir = Log.dir()
 
  # Allows to clear or to set the location to save the log files to.
  def self.dir=( dir_param )
    @@log_filename = nil
    @@dir = dir_param
    if @@dir != nil
      @@log_filename = "#{@@dir}/rlib.log"
    end
  end
 
  # Turn da damn thing on.
  # Default is off.
  def self.on
    @@level = LOG
  end
 
  # Off is off.
  # Resets the log level to WARN (2). This effectively hides LOG and DEBUG
  # messages.
  def self.off
    @@level = WARN
  end
 
  # Suppresses stdout output while keeping file logging alive.
  # Default behavior for the Log class is to print to stdout. When this method
  # is used without a parameter, the assumption is to suppress output.
  def self.file_only=( file_only_param = true )
    @@file_only = file_only_param
  end
 
  # Returns a timestamp string in ISO-like format "YYYY-MM-DD HH:MM:SS".
  # Used internally by all logging methods.
  def self.date_time
    #local_date_time = DateTime.now.to_s.sub( /T/, ' ' ).gsub( /\+.*$/, '' )
    #return local_date_time
    return Time.now.strftime('%Y-%m-%d %H:%M:%S')
  end
 
  # Prints the a message with the date and time stamp and process id to
  # standard output and appends it to the log file if a writable directory was
  # found.
  def self.private_puts( message )
    local_message = "#{date_time()} #{Process.pid} #{message}"
    if @@file_only == false
      puts( "#{local_message}" )
    end
    if @@log_filename
      open( @@log_filename, 'a' ) { |file|
        file.puts "#{local_message}"
      }
    end
  end
 
  # Returns the current log level (integer constant).
  def self.level
    return @@level
  end
 
  # Sets the log level. Accepts one of the class constants:
  # SILENT, ERROR, WARN, BRIEF, LOG, DEBUG.
  def self.level=( level_param )
    @@level = level_param
  end
 
  # Logs an error message (level >= ERROR).
  def self.error( message )
    if @@level >= ERROR
      private_puts( "ERROR: #{message}" )
    end
  end
 
  # Logs a warning message (level >= WARN).
  def self.warn( message )
    if @@level >= WARN
      private_puts( "WARN: #{message}" )
    end
  end
 
  # Logs a brief informational message (level >= BRIEF).
  def self.brief( message )
    private_puts( message ) if @@level >= BRIEF
  end
 
  # Logs a standard log message (level >= LOG). This is the method used
  # by the top-level convenience 'log' function, which can be used instead.
  def self.log( message )
    private_puts( message ) if @@level >= LOG
  end
 
  # Logs a debug message (level >= DEBUG).
  def self.debug( message )
    private_puts( message ) if @@level >= DEBUG
  end
 
  # Returns a formatted string describing the class structure of the
  # given object: class name, instance variables, and non-inherited
  # instance methods (sorted).
  def self.dump_class( object_param )
    result =  "Class:\t#{object_param.class.name}\n"
    result << "\tVariables:\t#{object_param.class.instance_variables.to_s}\n"
    result << "\tMethods:\t#{((object_param.methods - Object.methods).sort { |a, b| a.to_s <=> b.to_s }).inspect}\n"
 
    return result
  end
end
 
# Convenience method, who doesn't need a logger all the time, anywhere, anyway?!
def log( message )
  Log.log( message )
end
 
# End of: log.rb
EOT
 
7. Coverage Checker
-------------------
 
For the Log class and potentially all following classes and code we want to do
100 percent line and branch coverage with SimpleCov. For this we create some
reusable code.
 
cat > ./lib/coverage_checker.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
# This file provides reusable SimpleCov setup and verification helpers.
 
require 'simplecov'
 
# ----------------------------------------------------------------------
# Ruby 2 compatibility patch for SimpleCov
# (The patch is harmless on Ruby 3, so it can stay unconditionally.)
# ----------------------------------------------------------------------
module SimpleCov
  def self.result_exit_status(result, dummy = 100.0)
  end
end
 
# ----------------------------------------------------------------------
# CoverageChecker
#   A static helper to reduce duplication in test files.
# ----------------------------------------------------------------------
class CoverageChecker
  # Enables line and branch coverage, prints an optional start message,
  # and calls SimpleCov.start.
  def self.start(test_name = nil)
    SimpleCov.enable_coverage :line
    SimpleCov.enable_coverage :branch
    puts "Starting #{test_name.to_s} coverage testing ..." if test_name
    SimpleCov.start
  end
 
  # After all tests have run, call this to verify the coverage of a
  # specific source file.
  #
  # Parameters:
  #   filename_in_lib   : e.g. 'log.rb', 'term.rb'
  #   test_file         : __FILE__ from the calling test (used in success message)
  #   source_dir        : directory containing the source file (default: 'lib')
  #   print_line_map    : if true, prints an annotated source listing when
  #                       line coverage is incomplete (default: true)
  def self.verify(filename_in_lib, test_file = nil,
                  source_dir = 'lib', print_line_map = true)
    puts "All tests in #{File.basename( test_file )} passed successfully!"
 
    coverage_result = SimpleCov.result
    source_path = File.join(source_dir, filename_in_lib)
    file = coverage_result.files.find { |f| f.filename.end_with?(filename_in_lib) }
 
    if file.nil?
      puts "ERROR: Could not locate #{filename_in_lib} in SimpleCov coverage results."
      exit 1
    end
 
    if file.covered_percent < 100.0
      puts "COVERAGE CHECK FAILED: Expected 100% for #{filename_in_lib}, got #{file.covered_percent}%"
 
      if print_line_map
        begin
          lines_coverage = file.coverage_data["lines"]
          content = File.read(source_path)
          lines_source = content.split(/\n/)
          if lines_coverage.length == lines_source.length
            num_length = lines_source.length.to_s.length
            lines_source.each_with_index do |line, idx|
              marker = case lines_coverage[idx]
                       when nil then "|"
                       when 0   then "-"
                       else          "+"
              end
              printf("%#{num_length}i. %s\n", idx + 1, "#{marker} #{line}")
            end
          else
            puts "Warning: Line coverage array length mismatch – skipping line map."
          end
        rescue => e
          puts "Could not print line coverage map: #{e.message}"
        end
      end
 
      exit 1
    end
 
    # 100% line coverage reached. Now check for missed branches.
    puts "Coverage check passed: 100% of lines    of #{filename_in_lib} source code covered."
    if file.missed_branches.length > 0
      begin
        content = File.read(source_path)
        lines = content.split(/\n/)
        file.missed_branches.each_with_index do |missed_branch, idx|
          puts "#{idx + 1}. #{missed_branch.inspect}"
          puts lines[missed_branch.start_line - 1] if missed_branch.start_line
        end
      rescue => e
        puts "Could not read source for branch reporting: #{e.message}"
      end
      puts "COVERAGE CHECK FAILED: There are #{file.missed_branches.length} missed branches."
      exit 1
    else
      puts "Coverage check passed: 100% of branches of #{filename_in_lib} source code covered."
    end
 
    puts "SUCCESS: #{test_file || __FILE__} - 0."
    exit 0
  end
end
 
# End of: coverage_checker.rb
EOT
 
8. Coverage Tests Example
-------------------------
 
The following can be used as a template to test newly developed classes:
 
Substitute the example 'template' string everywhere in this code with the class
name you want to have coverage tests for.
 
cat > ./test/test_template_coverage.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
# Copy it some place else, extend it, and remove these two lines.
 
$: << File.dirname( __FILE__ ) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "template.rb" )
 
require 'template'
require 'rlib'
 
# Tests using Rlib.assert( ... ) should follow here.
 
Rlib.assert( true )
 
CoverageChecker.verify( "template.rb", __FILE__ )
 
# End of: test_template_coverage.rb
EOT
 
9. Log Coverage Tests
---------------------
 
cat > ./test/test_log_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "log.rb" )
 
require 'log'
require 'rlib'
 
log_dir = Log.dir
Rlib.assert( log_dir != nil )
Rlib.assert( File.exist?( log_dir ) )
Rlib.assert( File.directory?( log_dir ) )
Rlib.assert( File.writable?( log_dir ) )
 
class DirCheckMock
  def ok?( dir_param )
    ret_val = false
 
    return ret_val
  end
end
 
$dir_check = DirCheckMock.new
 
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir == nil )
 
class DirCheckCountMock
  @counter = 0
  @threshold = 0
 
  def initialize( threshold_param )
    @counter = 0
    @threshold = threshold_param
  end
 
  def ok?( dir_param )
    @counter += 1
    if @counter >= @threshold
      return true
    end
 
    return false
  end
end
 
threshold = 2
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir =~ /\/logs$/ )
 
threshold += 1
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir =~ /\/log$/ )
 
threshold += 1
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir =~ /\/logs$/ )
 
threshold += 1
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir =~ /\/\.local\/var\/log$/, "wrong log dir result: #{log_dir.inspect}" )
 
threshold += 1
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir == "/tmp" )
 
threshold += 1
$dir_check = DirCheckCountMock.new( threshold )
Log.dir=( nil )
log_dir = Log.dir
Rlib.assert( log_dir == nil )
 
# We continue some tests without writing into any log file.
 
message_void = "this log message should not appear"
output = Rlib.capture_stdout do
  log( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
 
level_default = Log.level()
Rlib.assert( level_default >= 0 )
 
Log.on
message = "this log message should appear"
output = Rlib.capture_stdout do
  log( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / #{message}\n/m )
 
level_on = Log.level()
Rlib.assert( level_on > level_default )
 
Log.off
output = Rlib.capture_stdout do
  log( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
 
Rlib.assert( Log.level() == level_default )
 
Log.level=( level_on )
output = Rlib.capture_stdout do
  log( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / #{message}\n/m )
 
Log.off
output = Rlib.capture_stdout do
  Log.error( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / ERROR: #{message}\n/m )
 
output = Rlib.capture_stdout do
  Log.warn( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / WARN: #{message}\n/m )
 
output = Rlib.capture_stdout do
  Log.brief( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
 
Log.on
output = Rlib.capture_stdout do
  Log.brief( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / #{message}\n/m )
 
output = Rlib.capture_stdout do
  Log.debug( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
 
Log.level=( Log::DEBUG )
output = Rlib.capture_stdout do
  Log.debug( message )
end
#puts "output=#{output.inspect}"
Rlib.assert( output =~ / #{message}\n/m )
 
output = Log.dump_class( $dir_check )
#puts "output=#{output.inspect}"
Rlib.assert( output =~ /@threshold/ )
 
Log.level=( Log::SILENT )
output = Rlib.capture_stdout do
  Log.error( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
output = Rlib.capture_stdout do
  Log.warn( message_void )
end
#puts "output=#{output.inspect}"
Rlib.assert( output == "" )
 
# Now testing log file writing.
 
Log.dir=( nil )
$dir_check = DirCheck.new
log_dir = Log.dir()
Rlib.assert( log_dir != nil )
Rlib.assert( File.exist?( log_dir ) )
Rlib.assert( File.directory?( log_dir ) )
Rlib.assert( File.writable?( log_dir ) )
log_dir2 = Log.dir()
Rlib.assert( log_dir == log_dir2 )
 
test_dir = File.dirname( __FILE__ )
Log.dir=( test_dir )
test_filename = "#{test_dir}/rlib.log"
if File.exist?( test_filename )
  File.delete( test_filename )
end
Log.on
output = Rlib.capture_stdout do
  log( message )
end
output2 = Rlib.readfile( test_filename )
#puts "output=#{output.inspect}"
#puts "output2=#{output2.inspect}"
Rlib.assert( output == output2 )
 
Log.file_only=()
File.delete( test_filename )
output = Rlib.capture_stdout do
  log( message )
end
output2 = Rlib.readfile( test_filename )
#puts "output=#{output.inspect}"
#puts "output2=#{output2.inspect}"
Rlib.assert( output != output2 )
Rlib.assert( output == "" )
Rlib.assert( output2 =~ /#{message}/ )
 
CoverageChecker.verify( "log.rb", __FILE__ )
 
# End of: test_log_coverage.rb
EOT
 
Mocking the Directory Check
 
The 'Log.dir' method uses a global 'DirCheck' object stored in
'$dir_check'. You can inject a mock to control which directories are considered
writable in tests.
 
'Log' will use your mock instead of the default 'DirCheck'. Ensure the mock
returns 'true' or 'false' based on the directory path argument.
 
10. Main Library Class
----------------------
 
Now we come to the main library class with methods that are useful in almost
each and every Ruby program to provide some convenience for basic tasks.
 
cat > lib/rlib.rb <<EOT
# Do not edit this file, as it gets generated automatically by lp.
 
require 'stringio'
require 'date'
 
# Small general purpose library providing convenience methods for common
# programming tasks in Ruby.
#
# This class collects utility methods useful in almost every Ruby program,
# such as formatting numbers, executing external commands, asserting
# conditions, capturing output streams, and reading files.
class Rlib
  # Executes an external command and returns its standard output.
  # If the command failed, nil is returned.
  # Note that stderr is not captured and is instead inherited by the parent
  # process. Append " 2>&1" to the command if necessary and you need the
  # standard error output as well (as a work around).
  def self.output( command )
    ret_val = `#{command}`
    if $?.exitstatus != 0
      ret_val = nil
    end
 
    return ret_val
  end
 
  # Asserts that the given condition is true.
  #
  # If the assertion fails, prints an error message including the provided
  # message and a reversed stack trace, then exits the program with status 1.
  #
  # @param assertion [Object] The condition to evaluate. In Ruby, only +nil+
  #   and +false+ are considered false; all other values pass the assertion.
  #
  # @param message [String] The error message to display if the assertion fails.
  #   Defaults to "no message provided". The error message has automatically
  #   added a leading "ERROR: " and a trailing . to the given message.
  #
  # @return [nil] Returns nil if the assertion passes (does not return on failure).
  def self.assert( assertion, message = "no message provided" )
    if ! assertion
      puts "\nERROR: assertion failed: #{message}."
      stack = caller
      stack.each_with_index do |s, index|
        puts "       #{stack[ stack.length - index - 1 ]}"
      end
 
      exit 1
    end
  end
 
  # Executes an external command and asserts that it succeeds.
  # Combines the output and assert methods. Executes the given command,
  # verifies that it returned a non-nil result (exit status 0), and returns the
  # output.
  # If the command fails, prints the stack trace, an error and exits with
  # status 1.
  # Returns the command's standard output.
  def self.output_assert( command )
    local_output = output( command )
    assert( local_output != nil, "command '#{command}' failed" )
 
    return local_output
  end
 
  # Asserts that the given code block raises an exception.
  #
  # If no exception is raised, prints an error message including the provided
  # message and a reversed stack trace, then exits the program with status 1.
  #
  # @param message [String] The error message to display if no exception is raised.
  #   Defaults to "no message provided". The error message has automatically
  #   added a leading "ERROR: " and a trailing . to the given message.
  #
  # @return [nil] Returns nil if an exception is raised (does not return on failure).
  def self.assert_exception( message = "no message provided" )
    exception_raised = false
    begin
      yield
    rescue Exception
      exception_raised = true
    end
 
    assert( exception_raised, message )
  end
 
  # Captures all output written to $stdout during the execution of a block.
  #
  # Temporarily redirects $stdout to a StringIO buffer, yields to the block,
  # then restores the original stdout.
  #
  # @yield The block whose stdout output is to be captured.
  # @return [String] The content written to stdout during block execution.
  # @return [nil] If the block raises an exception, nil is returned (the
  #   exception is still propagated after stdout is restored).
  # @note This method is not thread-safe as it modifies the global $stdout.
  def self.capture_stdout
    orig_stdout, $stdout = $stdout, StringIO.new
    output = nil
    begin
      yield
      output = $stdout.string
    ensure
      $stdout = orig_stdout
    end
 
    return output
  end
 
  # Captures all output written to $stderr during the execution of a block.
  #
  # Functions identically to {capture_stdout}, but for the standard error stream.
  #
  # @yield The block whose stderr output is to be captured.
  # @return [String] The content written to stderr during block execution.
  # @return [nil] If the block raises an exception.
  # @note This method is not thread-safe as it modifies the global $stderr.
  def self.capture_stderr
    orig_stderr, $stderr = $stderr, StringIO.new
    output = nil
    begin
      yield
      output = $stderr.string
    ensure
      $stderr = orig_stderr
    end
 
    return output
  end
 
  # Mocks standard input by replacing $stdin with a StringIO containing the
  # provided input string for the duration of the block.
  #
  # @param user_input [String] The content to be made available on the mocked stdin.
  # @yield The block that will read from the mocked stdin.
  # @return [Object] The return value of the yielded block.
  # @note This method is not thread-safe as it modifies the global $stdin.
  def self.mock_stdin( user_input )
    orig_stdin = $stdin
    begin
      mock = StringIO.new( user_input )
      $stdin = mock
      ret_val = yield
      mock.read
      return ret_val
    ensure
      $stdin = orig_stdin
    end
  end
 
  # Reads the contents of filename and returns it as a text string.
  # Returns 'nil' if the file cannot be read (e.g. missing, permission error).
  def self.readfile( filename )
    ret_val = nil
    begin
      ret_val = File.read(filename)
    rescue => e
      # If anything goes wrong we silently return nil, matching the spec.
      ret_val = nil
    end
 
    return ret_val
  end
 
  # Reads the file and returns a text string and asserts that
  # nothing went wrong.
  def self.readfile_assert( filename )
    content = readfile(filename)
    assert(content != nil, "readfile '#{filename}' failed")
 
    return content
  end
 
  # Writes the given content string to a file.
  #
  # Opens the file in binary mode, truncates any existing content, and writes
  # +content+ exactly as provided. If any error occurs (e.g. missing parent
  # directory, permission denied), the method silently returns +nil+ without
  # raising an exception.
  #
  # @param filename [String] Path to the target file.
  # @param content  [String] Exact content to write.
  #
  # @return [Boolean] +true+ if the file was written successfully.
  # @return [nil] +nil+ if writing failed.
  def self.writefile( filename, content )
    begin
      File.open( filename, 'wb' ) { |f| f.write( content ) }
 
      return true
    rescue => e
      return nil
    end
  end
 
  # Writes the given content string to a file and asserts that the write succeeded.
  #
  # Delegates to {writefile} and uses {assert} to verify that the operation
  # was successful (i.e., that {writefile} returned a truthy value). If the
  # write fails (e.g., missing parent directory, permission denied), the
  # program exits with status 1 and an error message.
  #
  # @param filename [String] Path to the target file.
  # @param content  [String] Exact content to write.
  #
  # @return [true] Returns +true+ when the file is written successfully.
  #   Does not return on failure.
  def self.writefile_assert( filename, content )
    assert( writefile( filename, content ), "writefile '#{filename}' failed" )
 
    return true
  end
 
  # Formats a floating point number with fixed number of decimal digits while
  # grouping thousands with a "'" separator.
  #
  # The integer part is grouped in blocks of three digits separated by
  # single quotes. The sign is preserved for negative values. When
  # +right_digits+ is zero, no decimal point is included.
  #
  # @param float [Float] The number to format.
  #
  # @param right_digits [Integer] The number of digits to display after
  #   the decimal point. Defaults to 2. Must be non-negative.
  #
  # @param rounding [Boolean] If +true+ (the default), the value is rounded
  #   to the requested number of decimal places. If +false+, the value is
  #   truncated toward zero.
  #
  # @return [String] The formatted number.
  #
  # @raise [RuntimeError] If +right_digits+ is negative.
  def self.format( float, right_digits = 2, rounding = true )
    if right_digits < 0
      raise "ERROR: format does not support negative right_digits: #{right_digits}"
    end
 
    value = float
 
    if ! rounding
      if right_digits > 0
        factor = 10.0 ** right_digits
        value = ( value * factor ).truncate / factor
      else
        value = value.truncate
      end
    end
 
    if right_digits == 0
      string = sprintf( "%.0f", value )
    else
      string = sprintf( "%.#{right_digits}f", value )
    end
 
    if string.include?( "." )
      int_part, frac_part = string.split( "." )
    else
      int_part = string
      frac_part = nil
    end
 
    sign = ""
    if int_part[ 0 ] == "-"
      sign = "-"
      int_part = int_part[ 1..-1 ]
    end
 
    int_part = int_part.reverse
    int_part.gsub!( /(\d{3})(?=\d)/, "\\1'" )
    int_part.reverse!
 
    if frac_part
      return "#{sign}#{int_part}.#{frac_part}"
    else
      return "#{sign}#{int_part}"
    end
  end
 
  # Returns today's date in ISO format.
  def self.today
    return Time.now.strftime('%F')
  end
 
  # An alias for the today method.
  # Returns today's date in ISO format.
  def self.date
    return today()
  end
 
  # Returns a string with the ISO date and 24 hour time, e.g.:
  # "2026-02-20 14:30:00"
  def self.date_time
    Time.now.strftime('%F %T')
  end
 
  # Returns the timestamp in 'YYYYMMDD_HHMMSS' format for e.g. usage in file
  # names. With no argument, uses the current time.
  # With a string argument, expects an ISO date time format (e.g.
  # "2026-02-20 14:30:00") and then converts it to "20260220_143000".
  def self.timestamp( str = nil )
    if str
      # Remove hyphens, replace space with underscore, remove colons
      str.gsub( '-', '' ).gsub( ' ', '_' ).gsub( ':', '' )
    else
      Time.now.strftime( '%Y%m%d_%H%M%S' )
    end
  end
 
  # Returns the difference between two timestamps in a human-readable string.
  #
  # Timestamps must be in the format 'YYYYMMDD_HHMMSS' as produced by
  # {Rlib.timestamp}. The difference is calculated and returned as a
  # string like "2 min 30 sec", "45 sec", or "0 sec".
  #
  # @param timestamp_start [String] The earlier timestamp.
  # @param timestamp_end   [String] The later timestamp.
  # @return [String] The formatted duration.
  def self.duration( timestamp_start, timestamp_end )
    dt_start = DateTime.strptime( timestamp_start, '%Y%m%d_%H%M%S' )
    dt_end   = DateTime.strptime( timestamp_end,   '%Y%m%d_%H%M%S' )
 
    diff_seconds = ( ( dt_end - dt_start ) * 24 * 60 * 60 ).to_i
 
    if diff_seconds == 0
      return "0 sec"
    end
 
    Rlib.assert( diff_seconds > 0,
                 "ERROR: negative time difference between: " +
                 "#{timestamp_start} and #{timestamp_end}"
               )
 
    minutes = diff_seconds / 60
    seconds = diff_seconds % 60
    hours   = minutes      / 60
    minutes = minutes      % 60
    days    = hours        / 24
    hours   = hours        % 24
    weeks   = days         /  7
    days    = days         %  7
 
    parts = []
    parts << "#{weeks} week"  if weeks   > 0
    parts[ -1 ] << "s"        if weeks   > 1
    parts << "#{days} day"    if days    > 0
    parts[ -1 ] << "s"        if days    > 1
    parts << "#{hours} hr"    if hours   > 0
    parts[ -1 ] << "s"        if hours   > 1
    parts << "#{minutes} min" if minutes > 0
    parts << "#{seconds} sec" if seconds > 0
 
    return parts.join( ' ' )
  end
 
  # Checks whether the running Ruby version matches a given major and minor
  # version exactly.
  #
  # Extracts the major and minor numbers from 'RUBY_VERSION' and compares them
  # to the supplied arguments.  Patch level and pre-release suffixes are ignored.
  #
  # @param major_param [Integer or String] The expected major version number.
  # @param minor_param [Integer or String] The expected minor version number.
  #
  # @return [Boolean] 'true' if 'RUBY_VERSION' has the exact 'major_param.minor_param'
  #   prefix; 'false' otherwise.
  #
  # @example
  #   # Suppose RUBY_VERSION is "3.3.6"
  #   Rlib.ruby_version_check(3, 3)   # => true
  #   Rlib.ruby_version_check(3, 2)   # => false
  def self.ruby_version_check( major_param, minor_param )
    major, minor, _ = RUBY_VERSION.split( '.' ).map( &:to_i )
    ret_val = ((major == major_param.to_i) && (minor == minor_param.to_i))
    return ret_val
  end
 
  # Aborts execution if the current Ruby version's major.minor is not among a
  # list of allowed versions.
  #
  # Uses {ruby_version_check} to test each candidate and {assert} to produce a
  # consistent error message and stack trace when no match is found.
  # The program is terminated with exit status 1 on failure.
  #
  # @param allowed_versions [Array<String>] One or more version strings of the
  #   form '"major.minor"' (e.g. '"2.7"', '"3.3"').
  #
  # @return [nil] Returns 'nil' if the check passes; does not return on failure.
  #
  # @example
  #   Rlib.ruby_version_assert( "2.6", "2.7", "3.3", "3.4" )
  #   # If RUBY_VERSION is "3.3.6" -> passes
  #   # If RUBY_VERSION is "3.2.1" -> exits with error
  def self.ruby_version_assert( *allowed_versions )
    match = allowed_versions.any? do |v|
      major, minor = v.split( '.' ).map( &:to_i )
      ruby_version_check(major, minor)
    end
 
    assert( match, "Ruby version must be one of #{allowed_versions.join(', ')} but got: #{RUBY_VERSION}" )
  end
end
 
# End of: rlib.rb
EOT
 
11. Rlib Coverage Tests
-----------------------
 
cat > ./test/test_rlib_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "rlib.rb" )
 
require 'rlib'
 
# ============================================================================
# Tests for Rlib.output
# ============================================================================
result = Rlib.output("echo hello")
Rlib.assert( result == "hello\n", "Unexpected output: #{result.inspect}" )
 
result = Rlib.output("false")  # 'false' command exits with 1
Rlib.assert( result.nil?, "Expected nil for failed command, got #{result.inspect}" )
 
result = Rlib.output("true")
Rlib.assert( result == "", "Expected empty string, got #{result.inspect}" )
 
# ============================================================================
# Tests for Rlib.assert
# ============================================================================
output = Rlib.capture_stdout do
  Rlib.assert(true, "should not fail")
end
Rlib.assert( output == "", "Expected no output for passing assert" )
 
# assert(false) should exit with status 1
exit_status = nil
begin
  Rlib.capture_stdout do
  Rlib.assert(false, "custom failure message")
  end
rescue SystemExit => e
  exit_status = e.status
end
Rlib.assert( exit_status == 1, "Expected exit status 1, got #{exit_status.inspect}" )
 
# Truthy values (not exactly true) should pass
[1, "string", [], {}, :symbol, "true"].each do |val|
  exit_status = nil
  begin
  Rlib.capture_stdout { Rlib.assert(val, "testing #{val.inspect}") }
  rescue SystemExit => e
  exit_status = e.status
  end
  Rlib.assert( exit_status.nil?, "Expected no exit for value: #{val.inspect}" )
end
 
# nil should fail
exit_status = nil
begin
  Rlib.capture_stdout { Rlib.assert(nil) }
rescue SystemExit => e
  exit_status = e.status
end
Rlib.assert( exit_status == 1, "Expected exit status 1 for nil assertion" )
 
# Default message (SystemExit is raised, we just check that it happened)
system_exit_raised = false
begin
  Rlib.capture_stdout { Rlib.assert(false) }
rescue SystemExit
  system_exit_raised = true
end
Rlib.assert( system_exit_raised, "Expected SystemExit for default message test" )
 
# ============================================================================
# Tests for Rlib.output_assert
# ============================================================================
result = Rlib.output_assert("echo expected_output")
Rlib.assert( result == "expected_output\n", "Unexpected output: #{result.inspect}" )
 
exit_status = nil
begin
  Rlib.capture_stdout do
  Rlib.output_assert("false")
  end
rescue SystemExit => e
  exit_status = e.status
end
Rlib.assert( exit_status == 1, "Expected exit status 1 for failed command" )
 
result = Rlib.output_assert("true")
Rlib.assert( result == "", "Expected empty output, got #{result.inspect}" )
 
# ============================================================================
# Tests for Rlib.capture_stdout
# ============================================================================
output = Rlib.capture_stdout do
  puts "Line 1"
  print "Line 2"
  puts " continued"
end
Rlib.assert( output == "Line 1\nLine 2 continued\n", "Unexpected capture: #{output.inspect}" )
 
original = $stdout
Rlib.capture_stdout { puts "inside" }
Rlib.assert( original.equal?($stdout), "$stdout was not restored" )
 
original = $stdout
raised = false
begin
  Rlib.capture_stdout do
  puts "before"
  raise "Test exception"
  end
rescue RuntimeError
  raised = true
end
Rlib.assert( raised, "Expected RuntimeError to be raised" )
Rlib.assert( original.equal?($stdout), "$stdout not restored after exception" )
 
# ============================================================================
# Tests for Rlib.capture_stderr
# ============================================================================
output = Rlib.capture_stderr do
  $stderr.puts "Error 1"
  $stderr.print "Error 2"
end
Rlib.assert( output == "Error 1\nError 2", "Unexpected stderr: #{output.inspect}" )
 
original = $stderr
Rlib.capture_stderr { $stderr.puts "test" }
Rlib.assert( original.equal?($stderr), "$stderr not restored" )
 
original = $stderr
raised = false
begin
  Rlib.capture_stderr do
    $stderr.puts "before"
    raise "Test exception"
  end
rescue RuntimeError
  raised = true
end
Rlib.assert( raised, "Expected RuntimeError" )
Rlib.assert( original.equal?($stderr), "$stderr not restored after exception" )
 
# ============================================================================
# Tests for Rlib.mock_stdin
# ============================================================================
result = nil
Rlib.mock_stdin("test input\n") { result = gets }
Rlib.assert( result == "test input\n", "Unexpected input: #{result.inspect}" )
 
results = []
Rlib.mock_stdin("line1\nline2\nline3") do
  results << gets
  results << gets
  results << gets
end
Rlib.assert( results == ["line1\n", "line2\n", "line3"], "Unexpected multi-line input: #{results.inspect}" )
 
std_input = "first\nsecond\nthird"
ret_val = Rlib.mock_stdin( std_input ) do
  gets  # consumes "first\n"
end
Rlib.assert( ret_val == std_input[ 0...(ret_val.length) ], "Assignment mismatch" )
Rlib.assert( std_input[ ret_val.length..-1 ] == "second\nthird", "Unread buffer mismatch" )
 
original = $stdin
Rlib.mock_stdin("test") { gets }
Rlib.assert( original == $stdin, "$stdin not restored" )
 
result = "not nil"
Rlib.mock_stdin("") { result = gets }
Rlib.assert( result.nil?, "Expected nil at EOF, got #{result.inspect}" )
 
# ============================================================================
# Test for assert stack trace
# ============================================================================
output = nil
begin
  output = Rlib.capture_stdout { Rlib.assert( false, "test" ) }
rescue SystemExit
  # expected
  #puts "SystemExit"
end
Rlib.assert( output == nil, "Stack trace should contain test file name: #{output.inspect}" )
 
# ============================================================================
# Tests for readfile / readfile_assert
# ============================================================================
content = Rlib.readfile( __FILE__ )
Rlib.assert( content =~ /test_readfile/, "Expected to find 'test_readfile' in own source" )
 
content = Rlib.readfile( __FILE__ + ".X_does_not_exist_X" )
Rlib.assert( content.nil?, "Expected nil for nonexistent file" )
 
# readfile_assert should succeed and not raise
Rlib.readfile_assert( __FILE__ )
Rlib.assert( true, "Unreachable?")  # just to mark success
 
# ============================================================================
# Tests for Rlib.writefile
# ============================================================================
test_filename = "test/test_writefile.tmp"
begin
  # Successful write to a new file
  result = Rlib.writefile( test_filename, "Hello, world!\n" )
  Rlib.assert(result == true, "Expected true on successful write, got #{result.inspect}")
  content = Rlib.readfile(test_filename)
  Rlib.assert(content == "Hello, world!\n",
            "Content mismatch: #{content.inspect}")
 
  # Overwrite existing file with different content
  result = Rlib.writefile(test_filename, "Line 1\nLine 2")
  Rlib.assert(result == true, "Expected true on overwrite, got #{result.inspect}")
  content = Rlib.readfile(test_filename)
  Rlib.assert(content == "Line 1\nLine 2",
            "Overwritten content mismatch: #{content.inspect}")
 
  # Write empty string
  result = Rlib.writefile(test_filename, "")
  Rlib.assert(result == true, "Expected true for empty string write")
  content = Rlib.readfile(test_filename)
  Rlib.assert(content == "", "Expected empty file content, got #{content.inspect}")
 
  # Write binary data / special characters
  binary_data = "\x00\x01\x02\xFF" + "é€"
  result = Rlib.writefile(test_filename, binary_data)
  Rlib.assert(result == true, "Binary write failed")
  content = Rlib.readfile(test_filename)
  Rlib.assert(content == binary_data,
            "Binary content mismatch: #{content.inspect}")
 
  # Attempt to write to a path where a directory component does not exist
  # Should return nil without raising an exception.
  bad_path = "test/nonexistent_dirXYZ/subdir/file.txt"
  error_occurred = false
  begin
    result = Rlib.writefile(bad_path, "data")
  rescue Exception => e
    error_occurred = true
  end
  Rlib.assert(error_occurred == false, "writefile raised an exception for bad path")
  Rlib.assert(result.nil?, "Expected nil for unwritable path, got #{result.inspect}")
ensure
  # Clean up the test file if it exists
  File.delete(test_filename) if File.exist?(test_filename)
end
 
#
# Testing of Rlib.assert_exception.
#
 
Rlib.assert_exception() { 1/0 }
Rlib.assert_exception() { exit 3 }
 
#
# Testing of Rlib.format.
#
 
f = 1000.0
d = 2
e = "1'000.00"
s = Rlib.format( f, d )
Rlib.assert( s == e )
d = 0
e = "1'000"
s = Rlib.format( f, d )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
f = 1234.5678
d = 3
e = "1'234.568"
r = true
s = Rlib.format( f, d, r )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
r = false
e = "1'234.567"
s = Rlib.format( f, d, r )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
d = -1
Rlib.assert_exception() { Rlib.format( f, d, r ) }
d = 0
e = "1'234"
s = Rlib.format( f, d, r )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
f = -1234.5678
e = "-1'234"
s = Rlib.format( f, d, r )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
r = true
e = "-1'235"
s = Rlib.format( f, d, r )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
s = Rlib.format( f, d )
Rlib.assert( s == e, "ERROR: result #{s.inspect} is not as expected: #{e.inspect}" )
 
# Test date methods.
Rlib.assert( Rlib.today()      =~ /^\d\d\d\d\-\d\d\-\d\d$/                )
Rlib.assert( Rlib.date()       =~ /^\d\d\d\d\-\d\d\-\d\d$/                )
Rlib.assert( Rlib.date_time()  =~ /^\d\d\d\d\-\d\d\-\d\d \d\d:\d\d:\d\d$/ )
date_time1 = Rlib.date_time()
date_time2 = Rlib.timestamp( date_time1 )
date_time3 = Rlib.timestamp()
Rlib.assert( date_time1.length > date_time3.length )
Rlib.assert( date_time2 <= date_time3 )
Rlib.assert( Rlib.duration( date_time2, date_time2 ) == "0 sec" )
sleep 2
date_time4 = Rlib.timestamp()
Rlib.assert( Rlib.duration( date_time2, date_time4 ) != "0 sec" )
Rlib.capture_stdout do
  Rlib.assert_exception() { Rlib.duration( date_time4, date_time2 ) }
end
date_time5 = Rlib.timestamp( "1952-01-28 15:22:33" )
date_time6 = Rlib.timestamp( "2026-06-03 06:57:50" )
duration = Rlib.duration( date_time5, date_time6 )
Rlib.assert( duration =~ / 17 sec$/ )
date_time6 = Rlib.timestamp( "2026-06-03 06:57:33" )
duration = Rlib.duration( date_time5, date_time6 )
Rlib.assert( duration =~ / min$/ )
date_time7 = Rlib.timestamp( "1952-01-31 15:22:33" )
duration = Rlib.duration( date_time5, date_time7 )
Rlib.assert( duration == "3 days" )
 
# Test ruby version.
Rlib.assert( Rlib.ruby_version_check( 0, 1 ) == false )
major, minor, _ = RUBY_VERSION.split( /\./ )
Rlib.assert( Rlib.ruby_version_check( major, minor ) == true,
             "ERROR: Ruby version check failed for: #{major}.#{minor} vs. #{RUBY_VERSION}" )
Rlib.ruby_version_assert( "0.1", "1.0", "1.8.7", "#{major}.#{minor}" )
Rlib.capture_stdout do
  Rlib.assert_exception() do
    Rlib.ruby_version_assert( "#{major}.#{minor.to_i + 1}",  "#{major.to_i + 1}.#{minor}" )
  end
end
 
# ============================================================================
# Tests for Rlib.writefile_assert
# ============================================================================
test_filename = "test/test_writefile_assert.tmp"
 
# Successful write and read-back verification
begin
  test_content = "Hello, assert!\
"
  Rlib.writefile_assert(test_filename, test_content)
  read_back = Rlib.readfile(test_filename)
  Rlib.assert(read_back == test_content,
            "Content mismatch after writefile_assert: #{read_back.inspect}")
 
  # Overwrite with binary data
  binary_data = "\\x00\\xFF\\xEE" + "€"
  Rlib.writefile_assert(test_filename, binary_data)
  read_back = Rlib.readfile(test_filename)
  Rlib.assert(read_back == binary_data, "Binary content mismatch")
 
  # Overwrite with empty string
  Rlib.writefile_assert(test_filename, "")
  read_back = Rlib.readfile(test_filename)
  Rlib.assert(read_back == "", "Empty string mismatch")
ensure
  File.delete(test_filename) if File.exist?(test_filename)
end
 
# Attempt to write to a non-existent directory – must trigger an assertion failure
exit_status = nil
bad_path = "test/nonexistent_dirXYZ/write_assert/file.txt"
begin
  Rlib.capture_stdout do
  Rlib.writefile_assert(bad_path, "data")
  end
rescue SystemExit => e
  exit_status = e.status
end
Rlib.assert(exit_status == 1, "writefile_assert did not abort on bad path")
 
CoverageChecker.verify( "rlib.rb", __FILE__ )
 
# End of: test_rlib_coverage.rb
EOT
 
12. Timestamp Script
--------------------
 
The output of the 'timestamp' script can be used from the command line date
time identifier, as it has no space or other punctuation besides a single
underscore character. The output of this script is identical to Rlib.timestamp,
just this time using the date command.
 
cat > ./bin/timestamp <<EOT
#! /bin/sh
# Do not edit this file, as it gets automatically generated by lp.
 
date '+%Y%m%d_%H%M%S'
 
# End of: timestamp
EOT
 
13. Measure Time Differences In The Terminal
--------------------------------------------
 
For use in a terminal or in other scripts here is a Ruby script that uses the
Rlib.duration method for timing work in batch jobs. The input are two time
stamps that can be created with the timestamp method.
 
cat > ./bin/duration <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
require 'rlib'
 
timestamp_start = ARGV[0]
timestamp_end   = ARGV[1]
 
Rlib.assert( timestamp_start != nil, "Usage: duration START_TIMESTAMP
END_TIMESTAMP" )
Rlib.assert( timestamp_end   != nil, "Usage: duration START_TIMESTAMP
END_TIMESTAMP" )
 
puts Rlib.duration( timestamp_start, timestamp_end )
 
# End of: duration
EOT
 
14. Terminal
------------
 
The goal is to create a terminal class in lib/term.rb, which encapsulates the
Linux terminal details and can be used for testing by turning the output off
and on. In normal usage instead of puts, print, or printf commands go to the
corresponding term.print methods instead.
 
The term class has also a logging feature, that can be turned on or off. By
default it is off. E.g. for testing this is especially usefull together with
the mute feature.
 
Standard error is not supported, as the terminal will be used for TUI as well
as for command line stuff. The Term class is only for standard output and also
input.
 
14.1. Plan
''''''''''
 
Here is a comprehensive plan for the 'Term' class within the 'rlib'
library. This design prioritizes the "convenience" aspect while strictly
adhering to the requirements of Linux compatibility, Ruby 3, and testability.
 
Plan for class Term.
 
1. Class Architecture & Initialization
 
The class will act as a wrapper around the standard IO ('$stdout'/'$stdin'). It
will manage the state of the terminal, allowing the output stream to be toggled
for testing purposes.
 
State Management:
- Instance-based design (as proposed) to support dependency injection during
  testing.
- Internal state:
  - '@mute' (Boolean): Suppresses STDOUT output when true.
  - '@log' (Boolean): Enables accumulation to internal buffer when true.
  - '@buffer' (String): Accumulates all output strings when logging is enabled.
 
Constructor:
 
def initialize( mute = false, log = false )
 
- Singleton vs. Instance: An instance-based approach is preferred. This allows
  for dependency injection (passing a mock object) during testing, which is
  cleaner than manipulating global state in a Singleton.
- Initialization:
  - Accepting an output stream is not needed, we always use standard output.
  - Initialize the 'muted' state to false.
  - Initialize the 'logging' state to false.
 
2. Output Behavior
 
The core requirement is replacing standard Kernel methods with Term methods.
 
Return Values: Just like standard Ruby IO methods (which return 'nil'), all
regular output methods ('puts', 'print', 'printf', 'println') will return nil
as well. We don't care to receive the formatted text for testing purposes. We
use Rlib.capture_stdout or the muted and log state of a terminal for testing.
 
Processing Flow:
1. Format arguments into output string (handling newlines appropriately per
   method).
2. If '@log' is true: append to '@buffer'.
3. If not '@mute': write to '$stdout' (or '$>' to respect potential redirection).
4. Return nil.
 
Method Specifics:
- puts(*args): Joins with newlines, adds trailing newline unless args are
  empty. When there is no input (or nil) then "" is assumed and also a newline
  is printed.
- print(*args): Joins without modification.
- printf(fmt, args): Formatted output.
- println(*args): Explicitly ensures single trailing newline (convenience
  wrapper).
 
3. Muting Methods
 
To support the requirement of "turning output off and on," the class will
implement a simple state toggle.
 
- Methods:
  - 'mute!':                      Sets the internal state to silence all output.
  - 'unmute!':                    Restores output functionality.
  - 'muted?':                     Reader method to check current state.
  - 'set_mute!( true_or_false )': Sets the mute state with a boolean. Is
                                  usefull for toggling the state.
 
4. Logging API
 
# Enable logging.
def log!
# Set logging state explicitly.
def set_log!(bool)
# Check logging state.
def log?
# Clear the internal buffer (@buffer = "").
# Does not return the old buffer state.
def truncate!
# Return accumulated buffer content.
def output
 
Behavior: Logging captures output regardless of the mute state. This allows
tests to verify what would have been printed even when muted.
 
5. Logic Flow
 
1. Check if the instance is 'muted?'.
2. Check if the output should be logged.
3. Do nothing if the term object is muted or logging is off.
4. Create the output string.
5. Print the output string if not muted.
6. Append the output string to the log buffer if logging is enabled.
 
6. Terminal Control (Linux-Specific)
 
Screen Operations:
- clear:      Uses ANSI escape sequence '\e[H\e[2J' (Home cursor + Clear).
- rows, cols: Individual accessors.
- size:       Returns '[rows, cols]' using 'IO.console.winsize' (avoids
              curses dependency).
 
Input Handling:
- getch: Character-by-character input uses STDIN.getch from the 'io-console'
         standard library.
- gets:  Line-based input via STDIN.gets.
 
Testing of standard input handling: for now we do not provide a way to fill the
input buffer for testing purposes with data from a string. This will be later
implemented if needed.
 
Raw/Cooked Mode:
- raw!:    Uses escape codes to manipulate the terminal state. It sets
           'cbreak', 'noecho', and disables line buffering.
- cooked!: Restores standard terminal settings again via manipulating the
           terminal with output codes.
- close!:  Ensures terminal is reset to cooked mode; registered via 'at_exit'
           hook to prevent broken shell on crash.
- close?:  To check if the terminal can still be used to send output to.
- raw?:    Returns true if in raw mode.
 
7. Error Handling & Safety
 
- State restoration: All mode changes use 'ensure' blocks to prevent leaving
  terminal in broken state.
- Idempotent 'close!': Safe to call multiple times. If raw state is on toggle
  back to cooked state.
- Buffer thread-safety: State toggles ('mute!', 'log!') are not thread-safe
  (consistent with 'Rlib.capture_stdout' documentation). That is fine and a
  conscious design decision.
 
8. Usage Examples
 
Standard Usage:
 
term = Term.new
term.clear                  # Clears screen.
term.puts "Hello"           # Prints to STDOUT, returns nil.
 
Testing Pattern:
 
term = Term.new( mute = true, log = true )
term.puts "Hidden"                     # Returns nil", adds to buffer, no
                                       # STDOUT.
term.output                            # => "Hidden\n"
 
TUI Mode:
 
term = Term.new
term.raw!
char = term.getch           # Single character, no echo.
term.close!
term.raw?                   # => false, has been switched back by the close!.
 
9. Implementation Details
 
The 'output' method returns a string when logging was enabled with all strings and
escape codes that were send to the real standard output terminal (or just
having been logged).
 
8. Testing
 
Normal Usage:
 
cat > ./test/test_term.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'term'
require 'rlib'
 
output = Rlib.capture_stdout do
  term = Term.new
  term.puts "Hello, Linux!"
end
# Output: Hello, Linux!
Rlib.assert( output =~ /Hello, Linux!/, "text was not visible" )
 
# End of: test_term.rb
EOT
 
Testing Usage:
 
cat > ./test/test_term_mute.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
require 'term'
 
mute = true
visible = !mute
term = Term.new( visible )
 
output = Rlib.capture_stdout do
  term.puts "This will not appear on screen"
end
Rlib.assert( output !~ /screen/, "screen must appear in the output but did not" )
# Visible output: (None)
 
term.unmute!
output = Rlib.capture_stdout do
  term.puts "Back to normal"
end
Rlib.assert( output =~ /Back to normal/, "text was not visible" )
 
# End of: test_term_mute.rb
EOT
 
15. Term
--------
 
cat > lib/term.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
require 'io/console'
 
#
# The Term class provides a convenient, testable wrapper around Linux terminal
# I/O operations. It encapsulates standard output and input handling while
# adding capabilities for output suppression (muting), capture (logging), and
# TUI (Text User Interface) mode management.
#
# This class is designed to replace direct use of Kernel methods like puts,
# print, and gets in applications that require testability or terminal
# manipulation. All output methods return nil to match standard Ruby IO
# behavior.
#
# Key Features
# ------------
#
# * Testable Output: Output can be muted (suppressed from STDOUT) while
#   still being captured to an internal buffer for verification.
# * Automatic Cleanup: When raw mode is entered, an at_exit hook is
#   registered to ensure the terminal is restored to a usable state even
#   if the program crashes.
# * State Management: Tracks whether the terminal is closed, muted, or
#   in raw mode.
#
# Usage Examples
# --------------
#
# Basic Output
# ''''''''''''
#
#   term = Term.new
#   term.puts "Hello, World!"
#   term.print "Progress..."
#   term.printf "%.1f%% complete\n", 85.5
#
# Testing Pattern (Mute and Log)
# ''''''''''''''''''''''''''''''
#
#   term = Term.new(mute: true, log: true)
#   term.puts "Debug information"
#   term.print "More data"
#
#   # Verify output without seeing it on screen
#   term.output  # => "Debug information\nMore data"
#   term.truncate!  # Clear the buffer
#
# Interactive TUI Mode
# --------------------
#
#   term = Term.new
#   term.raw!           # Enter character-at-a-time mode
#   term.clear          # Clear the screen (uses curses)
#
#   puts "Press a key (q to quit):"
#   loop do
#     char = term.getch
#     break if char == 'q'
#     puts "You pressed: #{char.inspect}"
#   end
#
#   term.close!         # Essential: restores terminal settings
#
# Thread Safety
# '''''''''''''
#
# State toggles (mute!, log!, set_mute!, etc.) are intentionally not
# thread-safe. This class is optimized for convenience in single-threaded
# scripts and tests. If thread safety is required, external synchronization
# is necessary.
#
# Method Return Values
# ''''''''''''''''''''
#
# All output methods (puts, print, printf, println, clear) return
# nil to remain compatible with standard Ruby IO conventions.
#
# Dependencies
# ''''''''''''
#
# * Linux operating system
# * Ruby 3.0 or higher
# * io/console (standard library)
#
class Term
  # --------------------------------------------------------------
  # Key escape sequences for terminal input
  # --------------------------------------------------------------
 
  # Returns the ANSI escape sequence for inverted (reverse) video mode.
  #
  # When printed to the terminal, subsequent text will be displayed with
  # foreground and background colors swapped. Use Term.normal to reset
  # to default rendering.
  #
  # @return [String] The escape sequence "e[7m"
  #
  def self.invert
    return "\e[7m"
  end
 
  # Returns the ANSI escape sequence to reset all text attributes to normal.
  #
  # When printed to the terminal, clears all active text attributes
  # (inversion, bold, underline, etc.) and returns to default rendering.
  #
  # @return [String] The escape sequence "e[0m"
  #
  def self.normal
    return "\e[0m"
  end
 
  # Returns the ANSI escape sequence sent by the Up arrow key.
  #
  # Useful for comparing against input received via #getch to detect
  # Up arrow key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[A"
  #
  def self.up
    "\e[A"
  end
 
  # Returns the ANSI escape sequence sent by the Down arrow key.
  #
  # Useful for comparing against input received via #getch to detect
  # Down arrow key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[B"
  #
  def self.down
    "\e[B"
  end
 
  # Returns the ANSI escape sequence sent by the Right arrow key.
  #
  # Useful for comparing against input received via #getch to detect
  # Right arrow key presses in TUI applications.
  #
  # @return [String] The escape sequence "\e[C"
  #
  def self.right
    "\e[C"
  end
 
  # Returns the ANSI escape sequence sent by the Left arrow key.
  #
  # Useful for comparing against input received via #getch to detect
  # Left arrow key presses in TUI applications.
  #
  # @return [String] The escape sequence "\e[D"
  #
  def self.left
    "\e[D"
  end
 
  # Returns the ANSI escape sequence sent by the Page Up key.
  #
  # Useful for comparing against input received via #getch to detect
  # Page Up key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[5~"
  #
  def self.pgup
    "\e[5~"
  end
 
  # Returns the ANSI escape sequence sent by the Page Down key.
  #
  # Useful for comparing against input received via #getch to detect
  # Page Down key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[6~"
  #
  def self.pgdn
    "\e[6~"
  end
 
  # Returns the ANSI escape sequence sent by the Home key.
  #
  # Useful for comparing against input received via #getch to detect
  # Home key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[1~"
  #
  def self.home
    "\e[1~"
  end
 
  # Returns the ANSI escape sequence sent by the End key.
  #
  # Useful for comparing against input received via #getch to detect
  # End key presses in TUI applications.
  #
  # @return [String] The escape sequence "e[4~"
  #
  def self.endkey
    "\e[4~"
  end
 
  # Returns the ANSI escape sequence for the F1 key.
  # @return [String] "\eOP"
  def self.f1
    "\eOP"
  end
 
  # Returns the ANSI escape sequence for the F2 key.
  # @return [String] "\eOQ"
  def self.f2
    "\eOQ"
  end
 
  # Returns the ANSI escape sequence for the F3 key.
  # @return [String] "\eOR"
  def self.f3
    "\eOR"
  end
 
  # Returns the ANSI escape sequence for the F4 key.
  # @return [String] "\eOS"
  def self.f4
    "\eOS"
  end
 
  # Returns the ANSI escape sequence for the F5 key.
  # @return [String] "\e[15~"
  def self.f5
    "\e[15~"
  end
 
  # Returns the ANSI escape sequence for the F6 key.
  # @return [String] "\e[17~"
  def self.f6
    "\e[17~"
  end
 
  # Returns the ANSI escape sequence for the F7 key.
  # @return [String] "\e[18~"
  def self.f7
    "\e[18~"
  end
 
  # Returns the ANSI escape sequence for the F8 key.
  # @return [String] "\e[19~"
  def self.f8
    "\e[19~"
  end
 
  # Returns the ANSI escape sequence for the F9 key.
  # @return [String] "\e[20~"
  def self.f9
    "\e[20~"
  end
 
  # Returns the ANSI escape sequence for the F10 key.
  # @return [String] "\e[21~"
  def self.f10
    "\e[21~"
  end
 
  # Returns the ANSI escape sequence for the F11 key.
  # @return [String] "\e[23~"
  def self.f11
    "\e[23~"
  end
 
  # Returns the ANSI escape sequence for the F12 key.
  # @return [String] "\e[24~"
  def self.f12
    "\e[24~"
  end
 
  # Returns the ANSI escape sequence for the F13 key.
  # Based on xterm standard (CSI 25~). May vary across terminals.
  # @return [String] "\e[25~"
  def self.f13
    "\e[25~"
  end
 
  # Returns the ANSI escape sequence for the F14 key.
  # Based on xterm standard (CSI 26~). May vary across terminals.
  # @return [String] "\e[26~"
  def self.f14
    "\e[26~"
  end
 
  # Returns the ANSI escape sequence for the F15 key.
  # Based on xterm standard (CSI 27~). May vary across terminals.
  # @return [String] "\e[27~"
  def self.f15
    "\e[27~"
  end
 
  # Returns the ANSI escape sequence for the F16 key.
  # Based on xterm standard (CSI 28~). May vary across terminals.
  # @return [String] "\e[28~"
  def self.f16
    "\e[28~"
  end
 
  @raw = false
  @raw_real = false
 
  # Creates a new Term instance.
  #
  # visible is true by default, if false output is suppressed from $stdout (but
  # still logged if log is true).
  # if log is true, all output is accumulated in an internal buffer accessible
  # via #output. Default is false.
  # input can contains test input, use "" in normal mode.
  # For testing it might be useful to provide the screen dimensions, if not the
  # lines and columns are retrieved from the underlying terminal.
  #
  # Here is an example how to create a muted, logging terminal for testing:
  # term = Term.new( visible = fals, log = true )
  #
  def initialize( visible = true, log = false, input = "", lines = nil, cols = nil )
    @raw      = false
    @raw_real = false
    @input = input
    #$stdout.puts "visible=#{visible}"
    @mute = !visible
    #$stdout.puts "mute=#{@mute}"
    @log = log
    @buffer = ""
    @curses_active = false
    @closed = false
    @at_exit_registered = false
    @lines = lines
    @cols  = cols
    @saved_stty = nil
    @cnorm      = nil
  end
 
  # Outputs objects to the terminal, followed by a newline.
  #
  # Works like Kernel#puts: joins arguments with newlines and appends a
  # trailing newline. If called with no arguments, outputs a single newline.
  # Nil arguments are treated as empty strings.
  #
  # Respects mute and log state. Returns nil.
  #
  # @param args [Object] Objects to output (converted via to_s)
  # @return [nil]
  #
  def puts(*args)
    #$stdout.puts "term.enter"
    #$stdout.puts "closed=#{@closed}"
    return nil if @closed
    #$stdout.puts "mute=#{@mute}"
    return nil if @mute && !@log
 
    str = if args.empty?
      "\n"
    else
      args.map { |arg|
        val = arg.nil? ? "" : arg.to_s
        val.end_with?("\n") ? val : val + "\n"
      }.join
    end
 
    @buffer << str if @log
    $stdout.write(str) unless @mute
    #$stdout.puts "term.exit"
 
    return nil
  end
 
  # Outputs objects to the terminal without adding newlines.
  #
  # Works like Kernel#print. Joins arguments without modification.
  # Nil arguments are treated as empty strings.
  #
  # Respects mute and log state. Returns nil.
  #
  # @param args [Object] Objects to output (converted via to_s)
  # @return [nil]
  #
  def print(*args)
    return nil if @closed
    return nil if @mute && !@log
 
    str = args.map { |arg| arg.nil? ? "" : arg.to_s }.join
 
    @buffer << str if @log
    $stdout.write(str) unless @mute
 
    return nil
  end
 
  # Outputs a formatted string to the terminal.
  #
  # Works like Kernel#printf. Formats the string using sprintf semantics.
  #
  # Respects mute and log state. Returns nil.
  #
  # @param fmt [String] Format string
  # @param args [Object] Arguments to format
  # @return [nil]
  #
  def printf(fmt, *args)
    return nil if @closed
    return nil if @mute && !@log
 
    str = sprintf(fmt, *args)
 
    @buffer << str if @log
    $stdout.write(str) unless @mute
 
    return nil
  end
 
  # Outputs objects with exactly one trailing newline.
  #
  # Convenience method that joins arguments and ensures a single trailing
  # newline (unlike puts which adds a newline per argument).
  #
  # Respects mute and log state. Returns nil.
  #
  # @param args [Object] Objects to output (converted via to_s)
  # @return [nil]
  #
  def println(*args)
    return nil if @closed
    return nil if @mute && !@log
 
    str = args.map { |arg| arg.nil? ? "" : arg.to_s }.join
    str << "\n"
 
    @buffer << str if @log
    $stdout.write(str) unless @mute
 
    return nil
  end
 
  # Suppresses all output to $stdout.
  #
  # When muted, output methods still accumulate to the log buffer if logging
  # is enabled, but nothing is written to the terminal.
  #
  # @return [true]
  #
  def mute!
    @mute = true
  end
 
  # The opposite of mute!.
  def visible!
    @mute = false
  end
 
  # Restores output to $stdout.
  #
  # @return [false]
  #
  def unmute!
    @mute = false
  end
 
  # Checks if output is currently muted.
  #
  # @return [Boolean] true if muted, false otherwise
  #
  def muted?
    return @mute
  end
 
  # The opposite of muted?
  def visible?
    return !@mute
  end
 
  # Explicitly sets the mute state.
  #
  # @param bool [Boolean] true to mute, false to unmute
  # @return [Boolean] the new mute state
  #
  def set_mute!(bool)
    @mute = !!bool
  end
 
  # The opposite of set_mute!.
  def set_visible!(bool)
    @mute = !bool
  end
 
  # Enables logging to the internal buffer.
  #
  # When logging is enabled, all output strings are appended to an internal
  # buffer accessible via #output. This works independently of the mute
  # state, allowing capture of output even when it is suppressed from the
  # terminal.
  #
  # @return [true]
  #
  def log!
    @log = true
  end
 
  # Explicitly sets the logging state.
  #
  # @param bool [Boolean] true to enable logging, false to disable
  # @return [Boolean] the new logging state
  #
  def set_log!(bool)
    @log = !!bool
  end
 
  # Checks if logging is currently enabled.
  #
  # @return [Boolean] true if logging, false otherwise
  #
  def log?
    return @log
  end
 
  # Clears the internal log buffer.
  #
  # Discards all accumulated output in the buffer. Does not return the
  # previous contents (use #output before truncating if needed).
  #
  # @return [nil]
  #
  def truncate!
    @buffer = ""
 
    return nil
  end
 
  # Returns the accumulated log buffer contents.
  #
  # Returns a duplicate of the internal buffer, so modifications to the
  # returned string do not affect the buffer.
  #
  # @return [String] Accumulated output since initialization or last truncate!
  #
  def output
    return @buffer.dup
  end
 
  # Clears the terminal screen.
  #
  # It uses ANSI escape sequences (\\e[H\\e[2J). Respects mute and log state.
  #
  # @return [nil]
  #
  def clear
    return nil if @closed
    return nil if @mute && !@log
 
    str = "\e[H\e[2J"
    @buffer << str if @log
    $stdout.write(str) unless @mute
    #$clear = Rlib.output( "clear" ) if ! $clear
    #print $clear
=begin
    if @curses_active
      require_curses
      Curses.clear
      Curses.refresh
    else
      str = "\\e[H\\e[2J"
      @buffer << str if @log
      $stdout.write(str) if ! @mute
    end
=end
 
    return nil
  end
 
  # Returns the number of lines (rows) in the terminal.
  #
  # Uses IO.console.winsize. Available even when not in raw mode.
  #
  # @return [Integer] Number of rows
  #
  def lines
    if @lines != nil
      return @lines
    end
    return IO.console.winsize[ 0 ]
  end
 
  # Returns the number of columns in the terminal.
  #
  # Uses IO.console.winsize. Available even when not in raw mode.
  #
  # @return [Integer] Number of columns
  #
  def cols
    if @cols != nil
      return @cols
    end
    return IO.console.winsize[ 1 ]
  end
 
  # Returns the terminal dimensions.
  #
  # @return [Array<Integer>] [columns, lines] (note: cols first, then lines)
  #
  def size
    return [ cols(), lines() ]
  end
 
  # Reads a single character from input.
  #
  # If in raw mode (after calling #raw!), uses Curses.getch which provides
  # immediate, unbuffered, no-echo input. Otherwise uses STDIN.getch from
  # io/console.
  #
  # Returns nil if the terminal has been closed.
  #
  # @return [String, nil] The character read, or nil if closed
  #
  def getch
    return nil if @closed
 
    if @input.length > 0
      ret_val = @input[ 0 ... 1 ]
      @input = @input[ 1..-1 ]
      return ret_val
    else
      return STDIN.getch
    end
  end
 
  # Reads a line from input.
  #
  # Uses STDIN.gets. This is line-buffered and echo-enabled regardless of
  # raw mode state. Returns nil if the terminal has been closed or at EOF.
  #
  # @return [String, nil] The line read (including newline), or nil
  #
  def gets
    return nil if @closed
    return STDIN.gets
  end
 
  # This should be named noecho!. For real raw mode see raw2! and cooked2!.
  #
  # Puts the terminal into a kind of no echo raw (curses) mode.
  #
  # Initializes the curses library (loading it if necessary), enters cbreak
  # mode (unbuffered input), disables echo, and enables keypad mode.
  #
  # Registers an at_exit hook to automatically call #close! to prevent
  # leaving the terminal in an unusable state on program exit.
  #
  # The logging is not complete.
  #
  # Returns nil if already in raw mode or if closed.
  #
  # @return [nil]
  #
  def raw!
    return nil if @closed
 
    if @mute == false
      $stty_orig = Rlib.output_assert( "stty -g" )
      Rlib.output_assert( "stty -echo" )
      #Rlib.output_assert( "stty raw -echo" )
      $cnorm = Rlib.output_assert( "tput cnorm" )
      civis = Rlib.output_assert( "tput civis" )
      print civis
    end
    @raw = true
 
    unless @at_exit_registered
      at_exit { close! }
      @at_exit_registered = true
    end
 
    return nil
  end
 
  # Restores the terminal to cooked (normal) mode.
  #
  # Re-enables line buffering and echo. Uses Curses.nocbreak and
  # Curses.endwin. If curses cleanup fails, falls back to 'stty sane'.
  #
  # Idempotent: safe to call when not in raw mode.
  #
  # The logging is not complete.
  #
  # @return [nil]
  #
  def cooked!
    return nil if @closed
 
    if @mute == false
      print $cnorm
      command = "stty #{$stty_orig}"
      Rlib.output( command )
      #system( command )
      #$stty_orig = nil
    end
    @raw = false
=begin
    system('stty sane') rescue nil
=end
    return nil
  end
 
  # Closes the terminal and restores normal settings.
  #
  # If in raw mode, first calls #cooked! to restore terminal settings.
  # Marks the terminal as closed; subsequent I/O operations will return nil.
  #
  # Idempotent: safe to call multiple times.
  #
  # @return [nil]
  #
  def close!
    cooked2! if @raw_real
    cooked! if @raw
    @closed = true
    return nil
  end
 
  # Checks if the terminal has been closed.
  #
  # @return [Boolean] true if #close! has been called, false otherwise
  #
  def close?
    return @closed
  end
 
  # Checks if the terminal is in raw (curses) mode.
  #
  # @return [Boolean] true if #raw! has been called without a corresponding
  #   #cooked! or #close!
  #
  def raw?
    #@curses_active
    return @raw
  end
 
  # Puts the terminal into a true raw mode using the 'stty' command.
  #
  # Unlike #raw!, which uses the curses library, this method directly
  # manipulates the terminal driver with 'stty raw -echo', providing
  # the most direct and low-level control over terminal I/O. It also
  # hides the cursor using 'tput civis'. This mode is typically needed
  # when curses is unavailable or when a minimal overhead solution is
  # desired.
  #
  # The method:
  # * Captures the current terminal settings with 'stty -g'.
  # * Switches the terminal to raw mode (no canonical processing,
  #   no echo) via 'stty raw -echo'.
  # * Saves the sequence to restore the cursor (usually '\e[?25h').
  # * Outputs the sequence to hide the cursor.
  # * Sets the '@raw_real' flag to 'true'.
  # * Registers an 'at_exit' hook to automatically call #close!,
  #   preventing the terminal from being left in a broken state.
  #
  # If the terminal is muted, the actual 'stty' and 'tput' commands are
  # not executed, but the raw state is still tracked internally.
  #
  # This method is idempotent in the sense that calling it multiple
  # times while already in this raw mode does not re-execute the
  # system commands (the '@raw_real' flag is simply set again).
  #
  # @return [nil] Always returns nil.
  #
  def raw2!
    return nil if @closed
 
    if @mute == false
    @saved_stty = `stty -g`
    system("stty raw -echo")
 
    @cnorm = `tput cnorm`
    print `tput civis`
    end
 
    @raw_real = true
 
    unless @at_exit_registered
      at_exit { close! }
      @at_exit_registered = true
    end
 
    nil
  end
 
  # Restores the terminal from the raw mode set by #raw2!.
  #
  # This method undoes the changes made by #raw2!:
  # * Restores the original terminal settings using 'stty' with the
  #   saved settings string.
  # * Re-displays the cursor using the saved cursor-normal sequence.
  # * Clears the saved 'stty' settings and cursor sequence.
  # * Sets the '@raw_real' flag to 'false'.
  #
  # If the terminal is muted, the actual restoration commands are
  # skipped, but the internal state is still updated.
  #
  # It is safe to call this method even if #raw2! was never invoked
  # or if the terminal is already closed (returns 'nil' in that case).
  #
  # @return [nil] Always returns nil.
  #
  def cooked2!
    return nil if @closed
 
    if @mute == false
      system("stty #{@saved_stty}") if @saved_stty
      print @cnorm if @cnorm
 
      @saved_stty = nil
      @cnorm = nil
    end
 
    @raw_real = false
 
    nil
  end
 
  # Returns true if the terminal is real raw mode induced through the raw2!
  # method. This mode gets undone through the cooked2! method.
  def raw_real?
    @raw_real
  end
 
  # input_ready? is a non-blocking probe that checks if there at least one byte
  # of input available to read right now (or within 'timeout' seconds)?
  #
  # It is typically used by a read-eval-loop or a TUI driver that needs to
  # decide whether to call `getch` (and risk blocking) or to do other work
  # first - redraw the screen, run a tick handler, etc.
  #
  # timeout - A float, number, or nil: maximum number of seconds to wait. Use 0
  #           for a poll (do not wait at all), nil to block indefinitely.
  #
  # Returns:
  # true  - input is available (or a buffered test string is non-empty).
  # false - nothing arrived within 'timeout', the stream was closed, or an
  #         IOError was raised.
  def input_ready?( timeout )
    # If test input was provided in the constructor, do not wait.
    return true if @input && !@input.empty?
 
    # If STDIN becomes readable within the timeout an array like `[[STDIN], [],
    # []]` (the IOs that are ready for each category) is returned. Otherwise
    # if the timeout expires first, then nil is returned.
    # The first value of that result is therefore either an Array or
    # The !! (double negation) coerces a value to a strict boolean.
    return !!(IO.select( [ STDIN ], nil, nil, timeout ))
 
    # IOError is raised by IO.select when STDIN has been closed
    # (e.g. the parent process detached, or the user piped `</dev/null`
    # and the read end hit EOF on a tty).
  rescue IOError
    return false
  end
 
  CSI_FINAL_BYTES = (0x40..0x7E).freeze
 
  # Reads and returns the payload of a CSI escape sequence from #getch.
  #
  # CS stands for control sequence and I stands for introducer, so for ANSI
  # escape codes CSI is basically the \e ESC [ key symbol combination.
  # Private helper for CSI sequences.
  #
  # This method assumes that the leading 'ESC [' bytes have already been
  # consumed by the caller, typically the 'key' method. It repeatedly reads one
  # byte/character from input and appends it to the payload until either:
  #
  # 1. The byte is a CSI final byte, whose ordinal is in the range
  #    0x40..0x7E, or
  # 2. #getch returns nil.
  #
  # The returned payload includes the final byte but does **not** include the
  # '"\e["' prefix.
  #
  # @example
  #   # If input is "\e[A"
  #   term.key
  #   # read_csi_payload returns "A"
  #   # key returns "\e[A"
  #
  # @return [String] The CSI payload including the final byte.
  #   Example payloads: "A", "B", "5~", "1;2P".
  #
  # @note This method may block indefinitely if the terminal does not send
  #   a complete CSI sequence. Use caution when calling it directly.
  def read_csi_payload
    payload = ""
 
    loop do
      char = getch
      break if char.nil?
 
      payload << char
      break if CSI_FINAL_BYTES.include?(char.ord)
    end
 
    return payload
  end
 
  # key is a higher-level 'one logical key press' method built on top of getch.
  # The current getch method is useful for low-level control, but for arrow
  # keys, function keys, Home/End/PageUp/etc., a caller would otherwise have to
  # implement the logic of combining several characters together.
  #
  # So this method returns a complete key string, for example:
  #
  #   "a"        ordinary key
  #   "\e"       Escape alone
  #   "\e[A"     Up arrow
  #   "\e[B"     Down arrow
  #   "\e[5~"    Page Up
  #   "\eOP"     F1
  #
  # Returns nil if the terminal is closed.
  #
  # This method reads one input unit from #getch. If the input is not an
  # Escape character, it is returned immediately.
  #
  # If the first input is Escape ('"\e"'), the method waits up to 'timeout'
  # seconds for more input to decide whether the Escape is a standalone key
  # or the prefix of a longer sequence.
  #
  # Escape sequence handling:
  #
  # * '"\e["' followed by a CSI payload uses #read_csi_payload and returns
  #   the full CSI sequence, for example '"\e[A"' for Up or '"\e[5~"' for
  #   Page Up.
  # * '"\eO"' followed by one more byte returns an SS3 sequence, for example
  #   '"\eOP"' for F1.
  # * Any other Escape-prefixed input is returned as a two-character string,
  #   for example '"\eX"'.
  #
  # @return [String, nil] The complete key string, such as '"a"', '"\e"',
  #   '"\e[A"', '"\e[5~"', or '"\eOP"'. Returns 'nil' if the terminal has
  #   been closed or if #getch returns 'nil'.
  #
  # @example Basic key loop
  #   term = Term.new
  #   loop do
  #     key = term.key
  #     break if key.nil? || key == "q"
  #     puts "Pressed: #{key.inspect}"
  #   end
  def key()
    return nil if @closed
 
    first = getch
    return first if first.nil? || first != "\e"
 
    # Distinguish standalone Escape from Escape-prefixed key sequences.
    if input_ready?( 0.1 ) == false
      return "\e"
    end
 
    second = getch
 
    case second
    when "["
      "\e[" + read_csi_payload
    when "O"
      third = getch
      third ? "\eO#{third}" : "\eO"
    else
      "\e#{second}"
    end
  end
end
 
# End of: term.rb
EOT
 
16. Term Coverage Tests
-----------------------
 
cat > ./test/test_term_coverage.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
require 'simplecov'
# A patch for use with SimpleCov and Ruby 2. Ruby 3 works without this method
# overwrite.
module SimpleCov
  def self.result_exit_status(result,dummy = 100.0)
  end
end
SimpleCov.enable_coverage :line
SimpleCov.enable_coverage :branch
SimpleCov.start
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'term'
require 'rlib'
 
# 1. Class Methods (ANSI escape sequences)
Rlib.assert(Term.invert == "\e[7m", "Term.invert failed")
Rlib.assert(Term.normal == "\e[0m", "Term.normal failed")
Rlib.assert(Term.up == "\e[A", "Term.up failed")
Rlib.assert(Term.down == "\e[B", "Term.down failed")
Rlib.assert(Term.right == "\e[C", "Term.right failed")
Rlib.assert(Term.left == "\e[D", "Term.left failed")
Rlib.assert(Term.pgup == "\e[5~", "Term.pgup failed")
Rlib.assert(Term.pgdn == "\e[6~", "Term.pgdn failed")
Rlib.assert(Term.home == "\e[1~", "Term.home failed")
Rlib.assert(Term.endkey == "\e[4~", "Term.endkey failed")
 
# 2. Initialization & State
term = Term.new
Rlib.assert(!term.muted?, "should not be muted by default")
Rlib.assert(term.visible?, "should be visible by default")
Rlib.assert(!term.log?, "should not log by default")
Rlib.assert(!term.raw?, "should not be raw by default")
Rlib.assert(!term.close?, "should not be closed by default")
 
# 3. Muting & Visibility
term.mute!
Rlib.assert(term.muted?, "should be muted after mute!")
Rlib.assert(!term.visible?, "should not be visible after mute!")
 
term.unmute!
Rlib.assert(!term.muted?, "should not be muted after unmute!")
Rlib.assert(term.visible?, "should be visible after unmute!")
 
term.visible!
Rlib.assert(!term.muted?, "should not be muted after visible!")
 
term.set_mute!(true)
Rlib.assert(term.muted?, "should be muted after set_mute!(true)")
term.set_mute!(false)
Rlib.assert(!term.muted?, "should not be muted after set_mute!(false)")
 
term.set_visible!(true)
Rlib.assert(!term.muted?, "should not be muted after set_visible!(true)")
term.set_visible!(false)
Rlib.assert(term.muted?, "should be muted after set_visible!(false)")
 
# 4. Logging
term = Term.new(false, true) # visible=false, log=true -> muted=true, logging=true
Rlib.assert(term.log?, "should log after init with log=true")
term.log!
Rlib.assert(term.log?, "should log after log!")
term.set_log!(false)
Rlib.assert(!term.log?, "should not log after set_log!(false)")
term.set_log!(true)
Rlib.assert(term.log?, "should log after set_log!(true)")
 
term.truncate!
Rlib.assert(term.output == "", "output should be empty after truncate!")
 
# 5. Output Methods
# puts
term = Term.new(false, true) # muted, logging
term.puts("a", "b")
Rlib.assert(term.output == "a\nb\n", "puts multiple args failed")
term.truncate!
 
term.puts
Rlib.assert(term.output == "\n", "puts empty args failed")
term.truncate!
 
term.puts(nil)
Rlib.assert(term.output == "\n", "puts nil arg failed")
term.truncate!
 
term.puts("c\n")
Rlib.assert(term.output == "c\n", "puts arg with newline failed")
term.truncate!
 
# print
term.print("x", "y")
Rlib.assert(term.output == "xy", "print multiple args failed")
term.truncate!
 
term.print(nil)
Rlib.assert(term.output == "", "print nil arg failed")
term.truncate!
 
# printf
term.printf("%d %s", 1, "test")
Rlib.assert(term.output == "1 test", "printf failed")
term.truncate!
 
# println
term.println("p", "q")
Rlib.assert(term.output == "pq\n", "println multiple args failed")
term.truncate!
 
term.println(nil)
Rlib.assert(term.output == "\n", "println nil arg failed")
term.truncate!
 
# Test visible output (mute=false, log=false)
term = Term.new
output = Rlib.capture_stdout { term.puts("visible_puts") }
Rlib.assert(output =~ /visible_puts/, "visible puts failed")
 
output = Rlib.capture_stdout { term.print("visible_print") }
Rlib.assert(output =~ /visible_print/, "visible print failed")
 
output = Rlib.capture_stdout { term.printf("%s", "visible_printf") }
Rlib.assert(output =~ /visible_printf/, "visible printf failed")
 
output = Rlib.capture_stdout { term.println("visible_println") }
Rlib.assert(output =~ /visible_println/, "visible println failed")
 
# Test muted, no log (returns nil, no output)
term = Term.new(false, false) # visible=false, log=false -> muted=true, logging=false
output = Rlib.capture_stdout { term.puts("hidden") }
Rlib.assert(output !~ /hidden/, "muted term should not output to stdout")
Rlib.assert(term.output == "", "muted term without log should not buffer")
 
# 6. Closed State
term = Term.new
term.close!
Rlib.assert(term.close?, "term should be closed after close!")
 
output = Rlib.capture_stdout { term.puts("closed_puts") }
Rlib.assert(output !~ /closed_puts/, "closed term should not output puts")
 
output = Rlib.capture_stdout { term.print("closed_print") }
Rlib.assert(output !~ /closed_print/, "closed term should not output print")
 
output = Rlib.capture_stdout { term.printf("%s", "closed_printf") }
Rlib.assert(output !~ /closed_printf/, "closed term should not output printf")
 
output = Rlib.capture_stdout { term.println("closed_println") }
Rlib.assert(output !~ /closed_println/, "closed term should not output println")
 
Rlib.assert(term.getch == nil, "closed term getch should return nil")
Rlib.assert(term.gets == nil, "closed term gets should return nil")
Rlib.assert(term.clear == nil, "closed term clear should return nil")
Rlib.assert(term.raw! == nil, "closed term raw! should return nil")
Rlib.assert(term.cooked! == nil, "closed term cooked! should return nil")
 
# 7. Clear Method
term = Term.new(false, true) # muted, logging
$clear = nil
term.clear
# First call populates $clear
term.clear
# Second call uses cached $clear
Rlib.assert(term.output.length > 0, "clear should log output")
 
# Add extra code for branch coverage.
 
# 7. Clear Method
# Case: not muted, not logging -> writes to stdout, no buffer
term_clear1 = Term.new(true, false)  # visible=true, log=false
output = Rlib.capture_stdout { term_clear1.clear }
Rlib.assert(output == "\e[H\e[2J", "clear should output escape sequence when not muted")
Rlib.assert(term_clear1.output == "", "clear should not log when log=false")
 
# Case: muted, logging -> no stdout, buffer receives escape sequence
term_clear2 = Term.new(false, true)  # visible=false, log=true
output = Rlib.capture_stdout { term_clear2.clear }
Rlib.assert(output == "", "clear should not output when muted")
Rlib.assert(term_clear2.output == "\e[H\e[2J", "clear should log escape sequence when logging")
 
# Case: muted, not logging -> early return, no output, no buffer
term_clear3 = Term.new(false, false)  # visible=false, log=false
output = Rlib.capture_stdout { term_clear3.clear }
Rlib.assert(output == "", "clear should output nothing when muted and not logging")
Rlib.assert(term_clear3.output == "", "clear should not log when logging disabled")
 
# Case: not muted, logging -> writes to stdout and logs
term_clear4 = Term.new(true, true)  # visible=true, log=true
output = Rlib.capture_stdout { term_clear4.clear }
Rlib.assert(output == "\e[H\e[2J", "clear should output escape sequence")
Rlib.assert(term_clear4.output == "\e[H\e[2J", "clear should log escape sequence")
 
# 8. Terminal Size
term = Term.new
lines = term.lines
cols = term.cols
size = term.size
Rlib.assert(size == [cols, lines], "size should return [cols, lines]")
 
# 9. Input Methods
# getch with input buffer
term = Term.new(true, false, "ab") # visible=true, log=false, input="ab"
Rlib.assert(term.getch == "a", "getch should read from input buffer")
Rlib.assert(term.getch == "b", "getch should read from input buffer")
 
# getch with STDIN fallback
original_getch = STDIN.method(:getch)
STDIN.define_singleton_method(:getch) { "c" }
term = Term.new(true, false, "")
Rlib.assert(term.getch == "c", "getch should fall back to STDIN.getch")
STDIN.define_singleton_method(:getch, original_getch)
 
# gets with STDIN fallback
original_gets = STDIN.method(:gets)
STDIN.define_singleton_method(:gets) { "line\
" }
term = Term.new
Rlib.assert(term.gets == "line\
", "gets should read from STDIN.gets")
STDIN.define_singleton_method(:gets, original_gets)
 
# 10. Raw and Cooked Modes
# Test raw! with muted term (skips stty)
term = Term.new(false, false) # visible=false, log=false -> muted=true
term.raw!
Rlib.assert(term.raw?, "muted term should be raw after raw!")
term.raw! # Call again to cover @at_exit_registered check
Rlib.assert(term.raw?, "muted term should still be raw after second raw!")
 
term.cooked!
Rlib.assert(!term.raw?, "muted term should not be raw after cooked!")
 
# Test raw! and cooked! with unmuted term (mock Rlib methods to avoid actual stty)
orig_output_assert = Rlib.method(:output_assert)
orig_output = Rlib.method(:output)
 
Rlib.define_singleton_method(:output_assert) { |cmd| "mocked_#{cmd.gsub(' ', '_')}" }
$rlib_output_calls = []
Rlib.define_singleton_method(:output) do |cmd|
  $rlib_output_calls << cmd
  "mocked_output"
end
 
term = Term.new
output = Rlib.capture_stdout do
  term.raw!
end
Rlib.assert(term.raw?, "unmuted term should be raw after raw!")
Rlib.assert(output.include?("mocked_tput_civis"), "raw! should print civis")
Rlib.assert($stty_orig == "mocked_stty_-g", "raw! should capture stty -g output")
 
output = Rlib.capture_stdout do
  term.cooked!
end
Rlib.assert(!term.raw?, "unmuted term should not be raw after cooked!")
Rlib.assert(output.include?("mocked_tput_cnorm"), "cooked! should print cnorm")
Rlib.assert($rlib_output_calls.include?("stty #{$stty_orig}"), "cooked! should restore stty")
 
Rlib.define_singleton_method(:output_assert, orig_output_assert)
Rlib.define_singleton_method(:output, orig_output)
 
# Test close! with raw mode
term = Term.new(false, false) # visible=false -> muted=true
term.raw!
Rlib.assert(term.raw?, "term should be raw before close!")
term.close!
Rlib.assert(term.close?, "term should be closed after close!")
Rlib.assert(!term.raw?, "term should not be raw after close! from raw mode")
 
# Test close! idempotency
term.close!
Rlib.assert(term.close?, "term should still be closed after second close!")
 
# Test providing lines and cold to the terminal.
verbose   = false
log_param = false
input   = ""
lines = 40
cols  = 20
term = Term.new( verbose, log_param, input, lines, cols )
Rlib.assert( term.lines() == lines )
Rlib.assert( term.cols() == cols  )
 
# ---------------------------------------------------------------------------
# 11. raw2! and cooked2! Coverage
# ---------------------------------------------------------------------------
 
# Helper to create a Term instance with mocked system, backtick, print, at_exit
def create_mock_term_for_raw2(visible)
  term = Term.new(visible)
  $system_calls = []
  $backtick_calls = []
  $print_calls = []
  $at_exit_calls = []
 
  term.define_singleton_method(:system) do |cmd|
    $system_calls << cmd
    true
  end
 
  term.define_singleton_method(:`) do |cmd|
    $backtick_calls << cmd
    case cmd
    when "stty -g"   then "fake_stty_settings"
    when "tput cnorm" then "fake_cnorm"
    when "tput civis" then "fake_civis"
    else ""
    end
  end
 
  term.define_singleton_method(:print) do |str|
    $print_calls << str
  end
 
  term.define_singleton_method(:at_exit) do |&block|
    $at_exit_calls << block
  end
 
  term
end
 
# Test muted, no log (returns nil, no output)
term = Term.new(false, false) # visible=false, log=false -> muted=true, logging=false
output = Rlib.capture_stdout { term.puts("hidden") }
Rlib.assert(output !~ /hidden/, "muted term should not output to stdout")
Rlib.assert(term.output == "", "muted term without log should not buffer")
 
# Exercise the same @mute && !@log guard for print/printf/println.
output = Rlib.capture_stdout { term.print("hidden") }
Rlib.assert(output == "", "muted print should not output to stdout")
Rlib.assert(term.output == "", "muted print without log should not buffer")
 
output = Rlib.capture_stdout { term.printf("%s", "hidden") }
Rlib.assert(output == "", "muted printf should not output to stdout")
Rlib.assert(term.output == "", "muted printf without log should not buffer")
 
output = Rlib.capture_stdout { term.println("hidden") }
Rlib.assert(output == "", "muted println should not output to stdout")
Rlib.assert(term.output == "", "muted println without log should not buffer")
 
# --- raw2! Tests ---
 
# raw2! on closed term
term = Term.new
term.close!
result = term.raw2!
Rlib.assert(result == nil, "raw2! on closed term should return nil")
Rlib.assert(term.raw_real? == false, "raw_real should remain false on closed term")
 
# raw2! on muted term (visible = false)
term = create_mock_term_for_raw2(false)
result = term.raw2!
Rlib.assert(result == nil, "raw2! muted should return nil")
Rlib.assert(term.raw_real? == true, "raw_real should be true after raw2! muted")
Rlib.assert($system_calls.empty?, "no system calls when muted")
Rlib.assert($backtick_calls.empty?, "no backtick calls when muted")
Rlib.assert($print_calls.empty?, "no print calls when muted")
Rlib.assert($at_exit_calls.length == 1, "at_exit should be registered once")
 
# Second call should not register at_exit again
term.raw2!
Rlib.assert($at_exit_calls.length == 1, "at_exit registered only once")
 
# raw2! on unmuted term (visible = true)
term = create_mock_term_for_raw2(true)
result = term.raw2!
Rlib.assert(result == nil, "raw2! unmuted should return nil")
Rlib.assert(term.raw_real? == true, "raw_real should be true after raw2! unmuted")
Rlib.assert($system_calls == ["stty raw -echo"], "system call for raw mode")
Rlib.assert($backtick_calls == ["stty -g", "tput cnorm", "tput civis"],
            "backtick calls for stty -g, tput cnorm, tput civis")
Rlib.assert($print_calls == ["fake_civis"], "print civis output")
Rlib.assert($at_exit_calls.length == 1, "at_exit registered once")
 
# --- cooked2! Tests ---
 
# cooked2! on closed term
term = Term.new
term.close!
result = term.cooked2!
Rlib.assert(result == nil, "cooked2! on closed term should return nil")
Rlib.assert(term.raw_real? == false, "raw_real should remain false on closed term")
 
# cooked2! unmuted with @saved_stty and @cnorm set (simulate after raw2!)
term = create_mock_term_for_raw2(true)
term.raw2!   # sets @saved_stty, @cnorm, etc.
$system_calls.clear
$print_calls.clear
result = term.cooked2!
Rlib.assert(result == nil, "cooked2! unmuted should return nil")
Rlib.assert(term.raw_real? == false, "raw_real should be false after cooked2!")
Rlib.assert($system_calls == ["stty fake_stty_settings"], "system call to restore stty")
Rlib.assert($print_calls == ["fake_cnorm"], "print cnorm output")
 
# Verify @saved_stty is nil after cooked2! by calling again (no stty call)
$system_calls.clear
$print_calls.clear
term.cooked2!
Rlib.assert($system_calls.empty?, "no stty call if @saved_stty already nil")
Rlib.assert($print_calls.empty?, "no cnorm print if @cnorm nil: #{$print_calls.inspect}")
 
# cooked2! unmuted without raw2! (saved_stty nil, cnorm nil)
term = create_mock_term_for_raw2(true)
$system_calls.clear
$print_calls.clear
result = term.cooked2!
Rlib.assert(result == nil, "cooked2! unmuted without raw2! should return nil")
Rlib.assert(term.raw_real? == false, "raw_real should be false")
Rlib.assert($system_calls.empty?, "no system call when @saved_stty nil")
Rlib.assert($print_calls.empty?, "no print when @cnorm nil")
 
# cooked2! muted (visible = false)
term = create_mock_term_for_raw2(false)
term.raw2!   # muted, no system/print, raw_real true
$system_calls.clear
$print_calls.clear
result = term.cooked2!
Rlib.assert(result == nil, "cooked2! muted should return nil")
Rlib.assert(term.raw_real? == false, "raw_real should be false")
Rlib.assert($system_calls.empty?, "no system call when muted")
Rlib.assert($print_calls.empty?, "no print when muted")
 
# --- close! with raw2! ---
 
# close! on unmuted term after raw2!
term = create_mock_term_for_raw2(true)
term.raw2!
$system_calls.clear
$print_calls.clear
result = term.close!
Rlib.assert(result == nil, "close! should return nil")
Rlib.assert(term.close? == true, "term should be closed")
Rlib.assert(term.raw_real? == false, "raw_real should be false after close!")
Rlib.assert($system_calls == ["stty fake_stty_settings"], "close! should call cooked2! and restore stty")
Rlib.assert($print_calls == ["fake_cnorm"], "close! should print cnorm")
 
# close! on muted term after raw2!
term = create_mock_term_for_raw2(false)
term.raw2!
$system_calls.clear
$print_calls.clear
result = term.close!
Rlib.assert(result == nil, "close! muted should return nil")
Rlib.assert(term.close? == true, "term should be closed")
Rlib.assert(term.raw_real? == false, "raw_real should be false after close!")
Rlib.assert($system_calls.empty?, "no system calls when muted")
Rlib.assert($print_calls.empty?, "no print calls when muted")
 
# Missing branch tests.
term.print( "Mute text not logged should do nothing." )
term.printf( "%s", "Mute text not logged should do nothing." )
term.println( "Mute text not logged should do nothing." )
term.clear()
 
Rlib.assert( Term.f1 != nil )
Rlib.assert( Term.f2 != nil )
Rlib.assert( Term.f3 != nil )
Rlib.assert( Term.f4 != nil )
Rlib.assert( Term.f5 != nil )
Rlib.assert( Term.f6 != nil )
Rlib.assert( Term.f7 != nil )
Rlib.assert( Term.f8 != nil )
Rlib.assert( Term.f9 != nil )
Rlib.assert( Term.f10 != nil )
Rlib.assert( Term.f11 != nil )
Rlib.assert( Term.f12 != nil )
Rlib.assert( Term.f13 != nil )
Rlib.assert( Term.f14 != nil )
Rlib.assert( Term.f15 != nil )
Rlib.assert( Term.f16 != nil )
 
# ---------------------------------------------------------------------------
# 12. input_ready? Coverage
# ---------------------------------------------------------------------------
original_io_select = IO.method(:select)
 
begin
  select_args = []
 
  # Branch 1: @input truthy and non-empty -> immediate true, IO.select not called.
  IO.define_singleton_method(:select) do |*args|
    select_args << args
    raise "IO.select must not be called when buffered input exists"
  end
 
  term_with_input = Term.new(true, false, "buffered")
  Rlib.assert(
    term_with_input.input_ready?(0.5) == true,
    "input_ready? should return true immediately for buffered input"
  )
  Rlib.assert(
    select_args.empty?,
    "IO.select should not be called when @input is non-empty"
  )
 
  # Branch 2: @input nil -> left side of && is false -> calls IO.select.
  # IO.select returns truthy -> method returns true.
  IO.define_singleton_method(:select) do |*args|
    select_args << args
    [[STDIN], [], []]
  end
 
  term_nil_input = Term.new
  term_nil_input.instance_variable_set(:@input, nil)
 
  Rlib.assert(
    term_nil_input.input_ready?(0.25) == true,
    "input_ready? should return true when IO.select reports input"
  )
  Rlib.assert(
    select_args.last == [[STDIN], nil, nil, 0.25],
    "IO.select should receive stdin and timeout arguments"
  )
 
  # Branch 3: @input empty -> right side of && is false -> calls IO.select.
  # IO.select returns nil -> !!nil is false.
  IO.define_singleton_method(:select) { |*_args| nil }
 
  term_empty_input = Term.new(true, false, "")
  Rlib.assert(
    term_empty_input.input_ready?(0.5) == false,
    "input_ready? should return false when IO.select times out"
  )
 
  # Also cover IO.select returning false, to exercise !!false -> false.
  IO.define_singleton_method(:select) { |*_args| false }
 
  Rlib.assert(
    term_empty_input.input_ready?(0) == false,
    "input_ready? should coerce false to false"
  )
 
  # Branch 4: IO.select raises IOError -> rescue branch returns false.
  IO.define_singleton_method(:select) do |*_args|
    raise IOError, "closed stream"
  end
 
  Rlib.assert(
    term_empty_input.input_ready?(0.5) == false,
    "input_ready? should rescue IOError and return false"
  )
ensure
  # Restore original IO.select after tests.
  IO.define_singleton_method(:select) do |*args, &block|
    original_io_select.call(*args, &block)
  end
end
 
# ---------------------------------------------------------------------------
# 13. read_csi_payload and key Coverage
# ---------------------------------------------------------------------------
 
# Helper: create a Term with a test input buffer and a predictable
# input_ready? result for key-sequence detection.
def term_with_ready(input, ready_result, visible = true, log = false)
  term = Term.new(visible, log, input)
  term.define_singleton_method(:input_ready?) { |_timeout| ready_result }
  term
end
 
# Helper: temporarily replace STDIN.getch and always restore it.
def with_stdin_getch(value)
  original = STDIN.method(:getch)
  STDIN.define_singleton_method(:getch) { value }
  yield
ensure
  STDIN.define_singleton_method(:getch, original)
end
 
# --- read_csi_payload ------------------------------------------------------
 
# Payload ending in a final byte on the first character.
term = Term.new(true, false, "A")
Rlib.assert(term.read_csi_payload == "A", "read_csi_payload single final byte failed")
 
# Multi-byte payload: several non-final bytes, then a final byte.
term = Term.new(true, false, "12~")
Rlib.assert(term.read_csi_payload == "12~", "read_csi_payload multi-byte payload failed")
 
# A nil character terminates the loop and returns the payload accumulated so far.
with_stdin_getch(nil) do
  term_empty = Term.new(true, false, "")
  Rlib.assert(term_empty.read_csi_payload == "", "read_csi_payload nil char should return empty payload")
end
 
# Also cover nil after some payload has been accumulated.
with_stdin_getch(nil) do
  term_partial = Term.new(true, false, "1")
  Rlib.assert(term_partial.read_csi_payload == "1", "read_csi_payload nil after partial payload failed")
end
 
# --- key -------------------------------------------------------------------
 
# Closed terminal returns nil immediately.
term_closed = Term.new
term_closed.close!
Rlib.assert(term_closed.key.nil?, "key on closed terminal should return nil")
 
# first == nil returns nil.
with_stdin_getch(nil) do
  term_first_nil = Term.new(true, false, "")
  Rlib.assert(term_first_nil.key.nil?, "key with nil first input should return nil")
end
 
# Non-escape first byte is returned unchanged.
term_ordinary = Term.new(true, false, "a")
Rlib.assert(term_ordinary.key == "a", "key ordinary key should return the character")
 
# First byte is Escape, but no more input is ready => standalone Escape.
term_esc_alone = term_with_ready("\e", false)
Rlib.assert(term_esc_alone.key == "\e", "key standalone escape should return escape")
 
# CSI sequence: ESC [ A
term_csi = term_with_ready("\e[A", true)
Rlib.assert(term_csi.key == "\e[A", "key CSI sequence ESC [ A failed")
 
# CSI sequence with a longer payload: ESC [ 1 ; 2 H
term_csi_long = term_with_ready("\e[1;2H", true)
Rlib.assert(term_csi_long.key == "\e[1;2H", "key CSI sequence with multi-byte payload failed")
 
# SS3 sequence: ESC O P
term_ss3 = term_with_ready("\eOP", true)
Rlib.assert(term_ss3.key == "\eOP", "key SS3 sequence ESC O P failed")
 
# SS3 sequence where the third byte is nil: ESC O
with_stdin_getch(nil) do
  term_ss3_nil = term_with_ready("\eO", true)
  Rlib.assert(term_ss3_nil.key == "\eO", "key SS3 sequence with missing third byte failed")
end
 
# Escape followed by another non-[, non-O byte: ESC X
term_other = term_with_ready("\eX", true)
Rlib.assert(term_other.key == "\eX", "key other escape sequence failed")
 
# Escape followed by nil second byte also hits the else branch.
with_stdin_getch(nil) do
  term_second_nil = term_with_ready("\e", true)
  Rlib.assert(term_second_nil.key == "\e", "key with second byte nil should fall through to else")
end
 
puts "All Term coverage tests passed successfully!"
 
# =============================================================================
# DYNAMIC COVERAGE VERIFICATION
# =============================================================================
coverage_result = SimpleCov.result
term_file = coverage_result.files.find { |f| f.filename.end_with?('term.rb') }
 
if term_file.nil?
  puts "ERROR: Could not locate term.rb in SimpleCov coverage results."
  exit 1
elsif term_file.covered_percent < 100.0
  puts "COVERAGE CHECK FAILED: Expected 100% for term.rb, but got #{term_file.covered_percent}%"
  exit 1
else
  puts "COVERAGE CHECK PASSED: 100% of term.rb source code covered."
  #puts term_file.methods.sort.inspect
  #puts term_file.covered_branches.inspect
  if term_file.missed_branches.length > 0
    content = Rlib.readfile( "lib/term.rb" )
    lines = content.split( /\n/ )
    term_file.missed_branches.each_with_index do |missed_branch, index|
      puts "#{index + 1}. #{missed_branch.inspect}"
      puts lines[ missed_branch.start_line - 1 ]
    end
    puts "COVERAGE CHECK FAILED: There are #{term_file.missed_branches.length} missed branches."
    exit 1
  end
  puts "SUCCESS: #{__FILE__} - 0."
  exit 0
end
 
# End of: test_term_coverage.rb
EOT
 
17. Input
---------
 
One aspect of rlib is a focus on the terminal (potentially inside tmux). For
this we need a way to input a single line of text but in a dedicated input form
field. The text field needs to be visibly inverted and not of infinite length,
but a specified number of characters.
 
Here is an example:
 
           +---------------+
Last name: |               |
           +---------------+
 
The box around is just for illustration, the text field shall just use inverted
text mode of a specified length. Should text input be longer than the visible
field, the input text should scroll inside the box. Some basic features like
Ctrl-a and Ctrl-e like Emacs key bindings to jump to the beginning or end
should also work.
 
The underlying library to use is ncurses accessible again both from Ruby
version 2 and 3 with the 'curses' gem.
 
17.1. Example Program
'''''''''''''''''''''
 
cat > src/example_input.rb <<EOT
#! /usr/bin/env ruby
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
# --------------------------------------------------------------
#  Simple curses-based input field.
#  - the field is shown in reverse (inverted) video
#  - its width can be changed by editing FIELD_LEN
#  - text longer than the field scrolls horizontally
#  - Ctrl-a  -> jump to the beginning of the line
#  - Ctrl-e  -> jump to the end of the line
#  - Backspace, Ctrl-h or the terminal's backspace key erases
#  - Enter finishes the input
# --------------------------------------------------------------
 
require 'curses'
require 'rlib'
 
# field_len is the width of the visible field.
def run_input( field_len = 20 )
  # ---- initialise ncurses ------------------------------------
  Curses.init_screen
  Curses.cbreak          # immediate character delivery
  Curses.noecho          # we'll render the field ourselves
  Curses.stdscr.keypad(true)   # enable arrow keys & Control-chars
  Curses.curs_set(1)     # make the cursor visible
 
  # ---- configuration -----------------------------------------
  prompt   = "Last name:"
  field_row = 0
  field_col = prompt.length + 1 # column where the field starts
 
  # ---- input buffer & cursor ---------------------------------
  buffer   = ""          # the real string the user types
  cursor   = 0           # position of the cursor inside buffer (0-based)
  offset   = 0           # leftmost character that is visible in the field
 
  # ---- helpers ------------------------------------------------
  # draw the field (always field_len characters, padded with spaces)
  draw_field = -> {
    visible = buffer[offset, field_len]
    visible = visible.ljust(field_len)
    Curses.setpos(field_row, field_col)
    Curses.attron(Curses::A_REVERSE)
    Curses.addstr(visible)
    Curses.attroff(Curses::A_REVERSE)
  }
 
  # keep the visible window aligned with the cursor
  adjust_offset = -> {
    if cursor < offset
      offset = cursor
    elsif cursor >= offset + field_len
      offset = cursor - field_len + 1
    end
    # This offset could never be below 0.
    #offset = 0 if offset < 0
    Rlib.assert( offset >= 0 )
  }
 
  # ---- first paint --------------------------------------------
  Curses.setpos(field_row, 0)
  Curses.addstr(prompt)
  draw_field.call
  Curses.setpos(field_row, field_col + cursor - offset)
  Curses.refresh
 
  # ---- main input loop ----------------------------------------
  loop do
  ch = Curses.stdscr.getch                # read one key
 
  # ---- finish on Enter ---------------------------------------
  break if ch == 10 || ch == 13 || ch == Curses::Key::ENTER
 
  case ch
  when 1                                      # Ctrl-a -> start of line
    cursor = 0
    offset = 0
 
  when 5                                      # Ctrl-e -> end of line
    cursor = buffer.length
    offset = [0, cursor - field_len + 1].max
 
  when 8, 127, Curses::Key::BACKSPACE        # backspace
    if cursor > 0
      buffer = buffer.dup
      buffer.slice!(cursor - 1)
      cursor -= 1
      adjust_offset.call
    end
 
  else                                        # printable ASCII
    # Accept printing characters (space … tilde)
    if (ch.is_a?(Integer) && ch >= 32 && ch <= 126) ||
       (ch.is_a?(String) && !ch.empty?)
      ch = ch.chr if ch.is_a?(Integer)
      buffer = buffer.dup
      buffer.insert(cursor, ch)
      cursor += 1
      adjust_offset.call
    end
  end
 
  # ---- repaint ------------------------------------------------
  draw_field.call
  Curses.setpos(field_row, field_col + cursor - offset)
  Curses.refresh
  end
 
  # ---- show result ---------------------------------------------
  Curses.setpos(field_row + 1, 0)
  Curses.addstr("You entered: #{buffer}")
  Curses.refresh
  Curses.getch                     # wait for a key before closing
 
rescue => e
  Curses.close_screen
  puts "Error: #{e.message}"
ensure
  Curses.close_screen
end
 
run_input if __FILE__ == $0
 
# End of: example_input.rb
EOT
 
17.2. Library
'''''''''''''
 
The task is now to rename the example program above to a library name and make
the interface more general. Also an optional text value might be provided for
the user, like the value he or she typed in previously, to conveniently just
change the value instead of retyping everything.
 
Here is the refactored library and the updated example program. The code has
been generalized into a reusable 'Tuit' class with a static 'input' method,
while preserving all the original terminal behavior.
 
cat > lib/tui.rb <<EOT
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
require 'curses'
 
class Tui
  # Reads a single line of input from the terminal using ncurses.
  #
  # @param prompt [String] Text to display before the input field
  # @param field_len [Integer] Visible width of the input field
  # @param row [Integer] Terminal row to place the prompt/field
  # @param col [Integer, nil] Terminal column to start the field (defaults to prompt.length + 1)
  # @return [String] The text entered by the user
  def self.input(prompt = "Input:", field_len = 20, row = 0, col = nil)
    col ||= prompt.length + 1
 
    Curses.init_screen
    Curses.cbreak
    Curses.noecho
    Curses.stdscr.keypad(true)
    begin
      Curses.curs_set(1)
    rescue Curses::Error
      # Some terminals/tmux configs don't support cursor visibility changes
    end
 
    buffer   = ""
    cursor   = 0
    offset   = 0
 
    draw_field = -> {
      visible = buffer[offset, field_len]
      visible = visible.ljust(field_len)
      Curses.setpos(row, col)
      Curses.attron(Curses::A_REVERSE)
      Curses.addstr(visible)
      Curses.attroff(Curses::A_REVERSE)
    }
 
    adjust_offset = -> {
      if cursor < offset
        offset = cursor
      elsif cursor >= offset + field_len
        offset = cursor - field_len + 1
      end
      offset = 0 if offset < 0
    }
 
    # Initial paint
    Curses.setpos(row, 0)
    Curses.addstr(prompt)
    draw_field.call
    Curses.setpos(row, col + cursor - offset)
    Curses.refresh
 
    loop do
      ch = Curses.stdscr.getch
 
      break if ch == 10 || ch == 13 || ch == Curses::Key::ENTER
 
      case ch
      when 1 # Ctrl-a -> start of line
        cursor = 0
        offset = 0
      when 5 # Ctrl-e -> end of line
        cursor = buffer.length
        offset = [0, cursor - field_len + 1].max
      when 8, 127, Curses::Key::BACKSPACE
        if cursor > 0
          buffer = buffer.dup
          buffer.slice!(cursor - 1)
          cursor -= 1
          adjust_offset.call
        end
      else
        # Accept printable ASCII characters
        if (ch.is_a?(Integer) && ch >= 32 && ch <= 126) || (ch.is_a?(String) && !ch.empty?)
          ch = ch.chr if ch.is_a?(Integer)
          buffer = buffer.dup
          buffer.insert(cursor, ch)
          cursor += 1
          adjust_offset.call
        end
      end
 
      draw_field.call
      Curses.setpos(row, col + cursor - offset)
      Curses.refresh
    end
 
    buffer
    ensure
    Curses.close_screen
  end
end
 
# End of: tui.rb
EOT
 
cat > src/example_input_2.rb <<EOT
#! /usr/bin/env ruby
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'tui'
 
# --------------------------------------------------------------
#  Simple example using the Tui library.
#  - Demonstrates the generalized Tui.input interface
#  - Returns the entered string for further processing
# --------------------------------------------------------------
 
if __FILE__ == $0
  result = Tui.input( prompt = "Last name:", field_len = 20 )
  puts "You entered: #{result}"
end
 
# End of: example_input_2.rb
EOT
 
Changes to the first example code:
 
1. Static Method Interface: Wrapped the logic in 'Tui.input' with keyword
   arguments ('prompt:', 'field_len:', 'row:', 'col:') for flexible reuse.
2. Safe Cursor Handling: Wrapped 'Curses.curs_set(1)' in a 'begin/rescue' block
   to prevent crashes on terminals or 'tmux' configurations that restrict
   cursor visibility changes.
3. Clean Return Value: The method now cleanly returns the input string instead
   of printing it internally, making it composable in larger programs.
4. Proper Resource Cleanup: 'Curses.close_screen' is guaranteed to run via
   'ensure', preventing terminal state corruption on errors or interrupts.
5. Ruby 3 Ready: Maintains 'frozen_string_literal: true', uses modern keyword
   arguments, and handles both 'Integer' and 'String' returns from
   'Curses.stdscr.getch' as seen in recent 'curses' gem versions.
 
18. CursesWrapper
-----------------
 
Next we need a wrapper that encapsulates all interactions with the global
'Curses' module. An instantiated class is ideal: it can be injected into
'Tui.input', and later replaced by a mock in tests. Here's the minimal
interface that mirrors exactly what 'tui.rb' uses.
 
cat > lib/curses_wrapper.rb <<EOT
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
require 'curses'
 
# Wraps the Curses module to allow dependency injection and testing.
# All calls that tui.rb makes to Curses are redirected through
# an instance of this class.  A test mock can replace it by implementing
# the same public methods.
class CursesWrapper
  # ---------- constants (exposed as instance methods) ----------
  def A_REVERSE
    Curses::A_REVERSE
  end
 
  def KEY_ENTER
    Curses::Key::ENTER
  end
 
  def KEY_BACKSPACE
    Curses::Key::BACKSPACE
  end
 
  # ---------- screen management ----------
  def init_screen
    Curses.init_screen
  end
 
  def cbreak
    Curses.cbreak
  end
 
  def noecho
    Curses.noecho
  end
 
  def close_screen
    Curses.close_screen
  end
 
  # ---------- cursor visibility ----------
  # The original code rescues Curses::Error because some terminals /
  # tmux configs don't support changing cursor visibility.
  # We preserve the same behaviour.
  # Exits with 1 if ncurses raises an error.
  def curs_set(visibility)
    begin
      Curses.curs_set(visibility)
    rescue Curses::Error
      # We don't want to use standard output, term, or log.
      # So we just silently exit with an error code.
      exit( 1 )
    end
  end
 
  # ---------- output / drawing ----------
  def setpos(row, col)
    Curses.setpos(row, col)
  end
 
  def addstr(str)
    Curses.addstr(str)
  end
 
  def attron(attrs)
    Curses.attron(attrs)
  end
 
  def attroff(attrs)
    Curses.attroff(attrs)
  end
 
  def refresh
    Curses.refresh
  end
 
  # ---------- stdscr proxy ----------
  # The code uses 'Curses.stdscr.keypad(true)' and 'Curses.stdscr.getch'.
  # We return a lightweight proxy object instead of the raw stdscr.
  def stdscr
    @stdscr_proxy ||= StdscrProxy.new
  end
 
  # Internal proxy that responds only to the needed calls on stdscr.
  class StdscrProxy
    def keypad(flag)
      Curses.stdscr.keypad(flag)
    end
 
    def getch
      Curses.stdscr.getch
    end
  end
end
 
# End of: curses_wrapper.rb
EOT
 
Now 'tui.rb' can be refactored to accept a 'curses' object (an instance
of 'CursesWrapper' or a test double) and replace every 'Curses.xxx' /
'Curses::CONST' call with 'curses.xxx' / 'curses.CONST'. This small change
makes the whole input logic completely testable.
 
Tui Input With Curses Wrapper
------------------------------
 
The updated 'tui.rb' now internally instantiates a 'CursesWrapper' and
uses it for all curses interactions. The public interface ('Tui.input')
remains unchanged, so the original 'example_input_2.rb' example works without
modification.
 
cat > lib/tui.rb <<EOT
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
require 'curses_wrapper'
require 'rlib'
 
class Tui
  # Reads a single line of input from the terminal using ncurses.
  #
  # @param prompt [String] Text to display before the input field
  # @param field_len [Integer] Visible width of the input field
  # @param row [Integer] Terminal row to place the prompt/field
  # @param col [Integer, nil] Terminal column to start the field (defaults to
  #                           prompt.length + 1)
  # @param curses             A wrapper object to access the Curses class.
  # @return [String] The text entered by the user
  def self.input(prompt = "Input:", field_len = 20, row = 0, col = nil, curses = CursesWrapper.new )
    col ||= prompt.length + 1
 
    curses.init_screen
    curses.cbreak
    curses.noecho
    curses.stdscr.keypad(true)
    # Potentially exits on error.
    curses.curs_set(1)
 
    buffer   = ""
    cursor   = 0
    offset   = 0
 
    draw_field = -> {
      visible = buffer[offset, field_len]
      visible = visible.ljust(field_len)
      curses.setpos(row, col)
      curses.attron(curses.A_REVERSE)
      curses.addstr(visible)
      curses.attroff(curses.A_REVERSE)
    }
 
    adjust_offset = -> {
      if cursor < offset
        offset = cursor
      elsif cursor >= offset + field_len
        offset = cursor - field_len + 1
      end
      # offset should never be < 0.
      #offset = 0 if offset < 0
      Rlib.assert( offset >= 0 )
    }
 
    # Initial paint
    curses.setpos(row, 0)
    curses.addstr(prompt)
    draw_field.call
    curses.setpos(row, col + cursor - offset)
    curses.refresh
 
    loop do
      ch = curses.stdscr.getch
 
      break if ch == 10 || ch == 13 || ch == curses.KEY_ENTER
 
      case ch
      when 1 # Ctrl-a -> start of line
        cursor = 0
        offset = 0
      when 5 # Ctrl-e -> end of line
        cursor = buffer.length
        offset = [0, cursor - field_len + 1].max
      when 8, 127, curses.KEY_BACKSPACE
        if cursor > 0
          buffer = buffer.dup
          buffer.slice!(cursor - 1)
          cursor -= 1
          adjust_offset.call
        end
      else
        # Accept printable ASCII characters
        if (ch.is_a?(Integer) && ch >= 32 && ch <= 126) || (ch.is_a?(String) && !ch.empty?)
          ch = ch.chr if ch.is_a?(Integer)
          buffer = buffer.dup
          buffer.insert(cursor, ch)
          cursor += 1
          adjust_offset.call
        end
      end
 
      draw_field.call
      curses.setpos(row, col + cursor - offset)
      curses.refresh
    end
 
    return buffer
    ensure
      curses.close_screen
  end
end
 
# End of: tui.rb
EOT
 
Tui Input Coverage Test
------------------------
 
cat > ./test/test_tui_coverage.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname(__FILE__) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "tui.rb" )
 
require 'tui'
require 'rlib'
 
# ----------------------------------------------------------------
#  CursesMock – replaces CursesWrapper for deterministic testing.
# ----------------------------------------------------------------
class CursesMock
  attr_reader :calls
 
  def initialize
  @calls = []
  @getch_queue = []
  @pos = [0, 0]
  @attrs = []
  @strs = []
  @refresh_count = 0
  end
 
  # constants
  def A_REVERSE;    :mock_reverse    end
  def KEY_ENTER;    :mock_enter      end
  def KEY_BACKSPACE;:mock_backspace  end
 
  # screen management
  def init_screen;       @calls << :init_screen;        end
  def cbreak;            @calls << :cbreak;             end
  def noecho;            @calls << :noecho;             end
  def close_screen;      @calls << :close_screen;       end
 
  # cursor visibility – will not raise error
  def curs_set(v);       @calls << [:curs_set, v];      end
 
  # output/drawing
  def setpos(row, col);  @pos = [row, col];             end
  def addstr(str);       @strs << [@pos.dup, str];      end
  def attron(attr);      @attrs << [:on, attr];         end
  def attroff(attr);     @attrs << [:off, attr];        end
  def refresh;           @refresh_count += 1;           end
 
  # stdscr proxy
  def stdscr;            @stdscr_proxy ||= MockStdscr.new(self); end
 
  # helpers for feeding characters to getch
  def feed_chars(*chars)
  @getch_queue += chars
  end
 
  # -- used by MockStdscr --
  def next_char
  @getch_queue.shift
  end
 
  class MockStdscr
  def initialize(mock)
    @mock = mock
  end
 
  def keypad(flag);     @mock.calls << [:keypad, flag];   end
  def getch;            @mock.next_char;                  end
  end
end
 
# ----------------------------------------------------------------
#  Test Cases
# ----------------------------------------------------------------
mock = CursesMock.new
 
# ---- Basic input with a few characters and Enter ----
mock.feed_chars('H', 'i', 10)  # 10 = Ctrl-J, one form of Enter
result = Tui.input("Prompt:", 10, 2, nil, mock)
Rlib.assert(result == "Hi", "basic input returned wrong string")
Rlib.assert(mock.calls.include?(:init_screen), "init_screen not called")
Rlib.assert(mock.calls.include?(:close_screen), "close_screen not called")
 
# ---- Backspace simulation ----
mock = CursesMock.new
mock.feed_chars('A', 'B', 127, 'C', 13)   # 127 = DEL (backspace)
result = Tui.input(">", 10, 0, nil, mock)
Rlib.assert(result == "AC", "backspace deletion failed")
 
# ---- Ctrl-a and Ctrl-e navigation ----
mock = CursesMock.new
# Type "xyz", then Ctrl-a, then 'a', then Ctrl-e, then 'b', then Enter
mock.feed_chars('x', 'y', 'z', 1, 'a', 5, 'b', 10)
result = Tui.input("", 10, 0, nil, mock)
Rlib.assert(result == "axyzb", "Ctrl-a / Ctrl-e navigation failed")
 
# ---- Scrolling (text longer than field) ----
mock = CursesMock.new
long_text = "1234567890abcdef"   # 16 chars
mock.feed_chars(*long_text.chars, 13)
result = Tui.input(">", 5, 0, nil, mock)   # field_len = 5
Rlib.assert(result == long_text, "long text with scrolling returned wrong string")
 
# ---- Ctrl-a and Ctrl-e combined with scrolling ----
mock = CursesMock.new
mock.feed_chars('1','2','3','4','5','6', 1, 'a', 5, 'b', 13)  # six chars, go home, add 'a', go end, add 'b'
result = Tui.input(":", 3, 0, nil, mock)   # field_len = 3 → will scroll
Rlib.assert(result == "a123456b", "scrolling with navigation failed")
 
# ---- Backspace at beginning does nothing ----
mock = CursesMock.new
mock.feed_chars(8, 'X', 13)  # backspace, then 'X', then Enter
result = Tui.input("", 10, 0, nil, mock)
Rlib.assert(result == "X", "backspace at start should do nothing")
 
# ---- Enter via Curses::Key::ENTER constant (mocked) ----
mock = CursesMock.new
mock.feed_chars('Y', :mock_enter)   # mock_enter from KEY_ENTER
result = Tui.input(">", 10, 0, nil, mock)
Rlib.assert(result == "Y", "enter via KEY_ENTER constant failed")
 
# ---- Backspace via curses.KEY_BACKSPACE constant ----
mock = CursesMock.new
mock.feed_chars('A', 'B', :mock_backspace, 'C', 10)
result = Tui.input(">", 10, 0, nil, mock)
Rlib.assert(result == "AC", "backspace via KEY_BACKSPACE constant failed")
 
# ---- Printable ASCII range test ----
mock = CursesMock.new
mock.feed_chars(32, 126, 13)   # space and tilde
result = Tui.input(">", 10, 0, nil, mock)
Rlib.assert(result == " ~", "printable ASCII handling failed")
 
# ---- Non-printable characters are ignored ----
mock = CursesMock.new
mock.feed_chars(0, 27, 'A', 13)   # 0 and escape then 'A'
result = Tui.input(">", 10, 0, nil, mock)
Rlib.assert(result == "A", "non-printable characters should be ignored")
 
# ---- Custom column (col) parameter ----
mock = CursesMock.new
mock.feed_chars('X', 10)
Tui.input("Prompt:", 10, 5, 12, mock)  # specify row=5, col=12
Rlib.assert(true, "custom column parameter accepted")
 
# ---- Col default (nil -> prompt.length + 1) ----
mock = CursesMock.new
mock.feed_chars(10)
Tui.input("ABC:", 10, 0, nil, mock)     # col defaults to 4+1 = 5
Rlib.assert(true, "default col calculation works")
 
# ---- Curses.curs_set error exit simulation ----
# Our mock does not raise Curses::Error; the original wrapper does.
# That is covered by curses_wrapper tests. Not needed here.
 
# ---- Ensure draw_field and adjust_offset are called (implicitly by not crashing) ----
mock = CursesMock.new
mock.feed_chars('a', 10)
result = Tui.input("#", 2, 1, nil, mock)
Rlib.assert(result == "a", "minimal field width works")
 
mock = CursesMock.new
1.upto( 20 * 2 ) do
  mock.feed_chars('a')
end
mock.feed_chars(10)
result = Tui.input( "Prompt:", 20, 0, nil, mock )
Rlib.assert( result =~ /^a+$/, "my own test did not work" )
Rlib.assert( result.length == 40, "my other own test did not work" )
 
mock = CursesMock.new
1.upto( 20 * 2 ) do
  mock.feed_chars('a')
end
1.upto( 20 * 3 ) do
  mock.feed_chars( mock.KEY_BACKSPACE )
end
mock.feed_chars(10)
result = Tui.input( "Prompt:", 20, 0, nil, mock )
Rlib.assert( result == "", "my third test did not work" )
 
CoverageChecker.verify( "tui.rb", __FILE__ )
 
# End of: test_tui_coverage.rb
EOT
 
19. Curses Wrapper Coverage Tests
---------------------------------
 
The following tests ensures full coverage of 'CursesWrapper' and its inner
'StdscrProxy' by mocking the global 'Curses' module.
 
cat > ./test/test_curses_wrapper_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname(__FILE__) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "coverage_checker.rb" )
 
$: << File.dirname(__FILE__) + '/../lib'
 
# We must load the curses_wrapper file; it will require 'curses'.
# Immediately after, we stub the Curses module to avoid real terminal interaction.
require 'curses_wrapper'
require 'rlib'
 
# ----------------------------------------------------------------
#  Stub the real Curses module
# ----------------------------------------------------------------
module Curses
  A_REVERSE = :real_reverse
  module Key
  ENTER     = :real_enter
  BACKSPACE = :real_backspace
  end
 
  class << self
  def init_screen;       @calls << :init_screen;       end
  def cbreak;            @calls << :cbreak;            end
  def noecho;            @calls << :noecho;             end
  def close_screen;      @calls << :close_screen;      end
  def curs_set(v);       @calls << [:curs_set, v];     end
  def setpos(r,c);       @calls << [:setpos, r, c];    end
  def addstr(s);         @calls << [:addstr, s];       end
  def attron(a);         @calls << [:attron, a];       end
  def attroff(a);        @calls << [:attroff, a];      end
  def refresh;           @calls << :refresh;           end
 
  def stdscr
    @mock_stdscr ||= Object.new.tap do |s|
      s.define_singleton_method(:keypad) { |f| Curses.calls << [:keypad, f] }
      s.define_singleton_method(:getch)  { Curses.calls << :getch; :mock_char }
    end
  end
 
  def calls; @calls ||= []; end
  def reset_calls!; @calls = []; end
  end
end
 
# ----------------------------------------------------------------
#  Tests
# ----------------------------------------------------------------
wrapper = CursesWrapper.new
 
# Check constants
Rlib.assert(wrapper.A_REVERSE == Curses::A_REVERSE, "A_REVERSE mismatch")
Curses.reset_calls!
 
# Standard screen management methods
wrapper.init_screen
Rlib.assert(Curses.calls.include?(:init_screen), "init_screen not delegated")
Curses.reset_calls!
 
wrapper.cbreak
Rlib.assert(Curses.calls.include?(:cbreak), "cbreak not delegated")
Curses.reset_calls!
 
wrapper.noecho
Rlib.assert(Curses.calls.include?(:noecho), "noecho not delegated")
Curses.reset_calls!
 
wrapper.close_screen
Rlib.assert(Curses.calls.include?(:close_screen), "close_screen not delegated")
Curses.reset_calls!
 
# Cursor visibility (no error)
wrapper.curs_set(0)
Rlib.assert(Curses.calls == [[:curs_set, 0]], "curs_set not delegated")
Curses.reset_calls!
 
wrapper.curs_set(1)
Rlib.assert(Curses.calls == [[:curs_set, 1]], "curs_set with 1 not delegated")
Curses.reset_calls!
 
# Drawing methods
wrapper.setpos(3, 10)
Rlib.assert(Curses.calls == [[:setpos, 3, 10]], "setpos not delegated")
Curses.reset_calls!
 
wrapper.addstr("hello")
Rlib.assert(Curses.calls == [[:addstr, "hello"]], "addstr not delegated")
Curses.reset_calls!
 
wrapper.attron(:fake_attr)
Rlib.assert(Curses.calls == [[:attron, :fake_attr]], "attron not delegated")
Curses.reset_calls!
 
wrapper.attroff(:fake_attr)
Rlib.assert(Curses.calls == [[:attroff, :fake_attr]], "attroff not delegated")
Curses.reset_calls!
 
wrapper.refresh
Rlib.assert(Curses.calls == [:refresh], "refresh not delegated")
Curses.reset_calls!
 
# stdscr proxy
stdscr = wrapper.stdscr
Rlib.assert(stdscr.is_a?(CursesWrapper::StdscrProxy), "stdscr should return StdscrProxy")
 
stdscr.keypad(true)
Rlib.assert(Curses.calls == [[:keypad, true]], "stdscr.keypad not delegated")
Curses.reset_calls!
 
char = stdscr.getch
Rlib.assert(char == :mock_char, "stdscr.getch did not return expected char")
Rlib.assert(Curses.calls.include?(:getch), "stdscr.getch not delegated")
Curses.reset_calls!
 
# ID of proxy (should be the same object)
Rlib.assert(wrapper.stdscr.object_id == wrapper.stdscr.object_id, "stdscr should be cached")
 
# Error case: simulate that Curses::Error is raised on curs_set
original_curs_set = Curses.method(:curs_set)
Curses.define_singleton_method(:curs_set) { |v| raise Curses::Error }
begin
  wrapper.curs_set(1)
  # Should not reach here because exit(1) terminates; but we stub exit to prevent termination
  Rlib.assert(false, "should have called exit(1)")
rescue SystemExit => e
  Rlib.assert(e.status == 1, "exit status should be 1")
end
# Restore
Curses.define_singleton_method(:curs_set, original_curs_set)
 
Rlib.assert( wrapper.KEY_ENTER != wrapper.KEY_BACKSPACE )
 
CoverageChecker.verify( 'curses_wrapper.rb', __FILE__ )
 
# End of: test_curses_wrapper_coverage.rb
EOT
 
The 'curses_wrapper_coverage.rb' test temporarily redefines the 'Curses'
module methods (after loading the wrapper) to avoid requiring a real
terminal. The error-handling test rescues 'SystemExit' to verify 'exit(1)' is
called when 'curs_set' raises 'Curses::Error'.
 
20. A Stack
-----------
 
There is a trick to use a Forth like programming style in any language. That is
to go with all input and output, aka method parameters and return values,
through a stack. Only push and pop either take or return a value, all other
methods use no input nor output values. Everything goes through the stack. If
that is your thing. But it can make for some short, quick and convenient
programs: concatenative programming. It can also make something difficult to
understand, it depend on the problem realm.
 
cat > lib/stack.rb <<EOT
# Do not edit this file, as it gets generated automatically by lp.
 
$stack = []
 
# Pushes the given object onto the stack.
# -- o
def push( o )
  $stack << o
end
 
# Pops and returns the top element from the stack.
# The element is removed from the stack.
# o --
def pop
  ret_val = $stack[ -1 ]
  $stack.delete_at( -1 )
 
  return ret_val
end
 
# Prints the current stack contents to standard output.
# This is intended for debugging and does not modify the stack.
# --
def stack_puts
  puts $stack.inspect
end
 
# Takes a string and splits it into separate lines and puts them in an array on
# the stack.
# s -- a
def split
  local_s = pop
  local_lines = local_s.split( /\n/ )
  push local_lines
end
 
# Takes an array of strings and creates a new array only with the elements of
# the input array that contain the given string.
# a s -- a
def grep
  local_value = pop
  local_array = pop
  ret_val = local_array.select do |next_value|
    next_value =~ /#{local_value}/
  end
  push ret_val
end
 
# dupe instead of dup, as dup is a core Ruby method.
# o -- o o
def dupe
  $stack << $stack[ -1 ]
end
 
# End of: stack.rb
EOT
 
20.1. Coverage Tests
''''''''''''''''''''
 
cat > test/test_stack_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname(__FILE__) + '/../lib'
require 'coverage_checker'
CoverageChecker.start("stack.rb")
 
require 'stack'
require 'rlib'
 
# ------------------------------------------------------------
# Helper to reset the stack before each test
# ------------------------------------------------------------
def reset_stack!
  $stack = []
end
 
# 1. push and pop basic
reset_stack!
push "hello"
Rlib.assert($stack == ["hello"], "push should add element to empty stack")
push 42
Rlib.assert($stack == ["hello", 42], "push should add second element")
 
val = pop
Rlib.assert(val == 42, "pop should return top element")
Rlib.assert($stack == ["hello"], "pop should remove top element")
 
val = pop
Rlib.assert(val == "hello", "pop should return last remaining element")
Rlib.assert($stack == [], "stack should be empty after last pop")
 
# pop on empty stack
reset_stack!
val = pop
Rlib.assert(val.nil?, "pop on empty stack should return nil")
Rlib.assert($stack == [], "stack should remain empty after pop on empty")
 
# 2. stack_puts (capture stdout)
reset_stack!
push "a"
push "b"
output = Rlib.capture_stdout { stack_puts }
Rlib.assert(output == "[\"a\", \"b\"]\n", "stack_puts should print stack contents")
Rlib.assert($stack == ["a", "b"], "stack_puts should not modify stack")
 
# stack_puts on empty stack
reset_stack!
output = Rlib.capture_stdout { stack_puts }
Rlib.assert(output == "[]\n", "stack_puts on empty stack should print empty array")
 
# 3. split
reset_stack!
push "line1\nline2\nline3"
split
Rlib.assert($stack == [["line1", "line2", "line3"]], "split should produce array of lines")
 
# split with no newline
reset_stack!
push "single"
split
Rlib.assert($stack == [["single"]], "split without newline should return array with one element")
 
# split with empty string
reset_stack!
push ""
split
Rlib.assert($stack == [[]], "split empty string should give an empty array")
reset_stack!
push "\n"
split
Rlib.assert($stack == [[]], "split single line feed should give an empty array: #{$stack.inspect}")
 
# split with trailing newline
reset_stack!
push "a\nb\n"
split
Rlib.assert($stack == [["a", "b"]], "split should ignore trailing empty line? (Ruby split behavior)")
 
# 4. grep
reset_stack!
push ["apple", "banana", "apricot", "grape"]
push "ap"
grep
Rlib.assert($stack == [["apple", "apricot", "grape"]], "grep should filter array by regex match: #{$stack.inspect}")
 
# grep with no matches
reset_stack!
push ["apple", "banana"]
push "z"
grep
Rlib.assert($stack == [[]], "grep with no matches should return empty array")
 
# grep on empty array
reset_stack!
push []
push "anything"
grep
Rlib.assert($stack == [[]], "grep on empty array should still be empty")
 
# grep with empty string pattern
reset_stack!
push ["a", "b"]
push ""
grep
Rlib.assert($stack == [["a", "b"]], "grep with empty pattern should match all strings")
 
# 5. dupe
reset_stack!
push 42
dupe
Rlib.assert($stack == [42, 42], "dupe should duplicate top element")
 
# dupe on empty stack
reset_stack!
dupe
Rlib.assert($stack == [nil], "dupe on empty stack should push nil (since pop would return nil, but dupe uses $stack[-1])")
# Actually $stack[-1] on empty stack is nil, so dupe pushes nil. Verify stack content.
Rlib.assert($stack.length == 1, "stack should have one element after dupe on empty")
Rlib.assert($stack.first.nil?, "that element should be nil")
 
# 6. Combination: push multiple, split, grep, pop, etc.
reset_stack!
push "hello world"
push "another test"
push "just hello"
push "world"
# stack top: "world", "just hello", "another test", "hello world"
# split shouldn't be applied to non-strings... but push expects anything.
# Now test split on top string "world" with no newline
split
# stack: ["world"] array, ...
pop  # pop "world" array, top becomes "just hello"
Rlib.assert($stack == ["hello world", "another test", "just hello"], "after split and pop, stack should have original strings except last")
push (["apple", "orange"])
push "a"
grep
Rlib.assert($stack == ["hello world", "another test", "just hello", ["apple", "orange"]], "grep filtered apple/orange with 'a'")
 
# Coverage of all branches: split uses split(/\n/), grep uses regex interpolation, pop uses delete_at, dupe uses [-1], all covered.
 
CoverageChecker.verify("stack.rb", __FILE__)
 
# End of: test_stack_coverage.rb
EOT
 
21. Screen
----------
 
cat > ./lib/screen.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets generated automatically by lp.
 
$: << File.dirname( __FILE__ )
 
require 'log'
require 'rlib'
require 'term'
#require 'menu'
 
#
# The Screen class provides a double-buffered terminal output mechanism to
# avoid flickering.
#
# Two internal buffers are maintained:
# - The current buffer (@buffer) holds the lines you want to display next.
# - The old buffer (@old_buffer) remembers the previous screen contents.
#
# When you call 'print', only the parts of the screen that have changed are
# re-drawn, and each line is padded with spaces so that any remnants from the
# previous frame are overwritten. This gives smooth, flicker-free updates.
# The alternative would be to brute force to compleately reprint the whole
# screen again and again. If we get more complex use cases, like poking single
# characters on fixed positions, we might still later switch to this method but
# keep the old interface. The assumption is that the user will not have too
# extreme escape sequences mixed across the screen border.
#
# A typical usage cycle:
#   1. Instantiate Screen (optionally with a custom Term object).
#   2. Build the new frame by calling 'add' for each line.
#   3. Call 'print' to send the frame to the terminal.
#   4. Repeat steps 2-3.
#   5. Call 'close' when finished to restore the terminal to its default cooked
#      state.
#
# When the screen size changes (e.g. the terminal is resized) the entire
# canvas is automatically cleared and repainted.
#
# If this file is executed directly ('ruby screen.rb') it prints basic
# information about your terminal configuration.  Use the '--interactive'
# flag for a full-screen display.
#
class Screen
  # --- Instance variables (set in #initialize) ---
 
  # Array of strings - the lines that will be painted next.
  @buffer = nil
 
  # Array of strings - the lines that were painted last time.
  @old_buffer = nil
 
  # Term object handling low-level terminal operations.
  @term = nil
 
  # Current terminal height (rows).
  @lines = -1
 
  # Current terminal width  (columns).
  @columns = -1
 
  # Height that was used for the previous frame.
  @old_lines = -1
 
  # Width that was used for the previous frame.
  @old_columns = -1
 
  # Creates a new Screen.
  #
  # @param term_param [Term] a pre-configured Term object (default: Term.new)
  # @param open       [Boolean] if true (the default) the terminal is immediately
  #   set to raw mode and the screen is cleared.
  # @note When 'open' is false you must call 'term.raw!' and 'clear' manually
  #   before using the buffer. This is useful for testing or when the Screen
  #   object is created but not yet displayed.
  def initialize( term_param = Term.new, open = true )
    @buffer = []
    @old_buffer = []
    @term = term_param
 
    @lines = @term.lines()
    @columns = @term.cols()
    # Fill the old buffer with empty strings.
    @lines.times { @old_buffer << "" }
    @old_lines = @lines
    @old_columns = @columns
 
    if open
      @term.raw!()
      clear()
    end
  end
 
  # Returns the underlying Term object.
  # This is mainly useful when you need low-level control.
  def term
    log( "screen.term()" )
    @term
  end
 
  # Restores the terminal to its normal (cooked) state.
  # Call this before your program exits.
  def close
    @term.cooked!()
  end
 
  # Clears the physical screen by delegating to the Term object.
  def clear
    @term.clear
  end
 
  # Appends a string to the current buffer.
  # Each addition forms a new line in the upcoming frame.
  #
  # @param line [String] the line to append
  def add( line )
    @buffer << line
  end
 
  # Returns the current buffer (the frame being built).
  #
  # @return [Array<String>]
  def buffer
    @buffer
  end
 
  # Returns the old buffer (the last frame that was printed).
  #
  # @return [Array<String>]
  def old_buffer
    @old_buffer
  end
 
  # Removes the first 'lines_param' elements from the current buffer.
  # Useful for scrolling upwards.
  #
  # @param lines_param [Integer] number of lines to discard from the top
  def cut_top( lines_param )
    @buffer = @buffer[ lines_param..-1 ]
  end
 
  # Saves the current buffer as the old buffer and starts a fresh empty buffer.
  # Also remembers the terminal dimensions used for the frame.
  # Called automatically by 'print'.
  def new_buffer
    @old_buffer = @buffer
    @buffer = []
    @old_lines = @lines
    @old_columns = @columns
  end
 
  # Copies the old buffer back into the current buffer.
  # Useful when you want to base the next frame on the previous one.
  def reuse_old_buffer
    @buffer = @old_buffer.clone()
  end
 
  # Moves the cursor to the top-left corner of the screen
  # by emitting cursor-up sequences for every line in the old buffer,
  # followed by a carriage return.
  #
  # @note Assumes that the cursor is at the beginning of the last line.
  def goto_top
    @old_buffer.each do
      @term.print Term.up
    end
    @term.print "\r"
  end
 
  def flatten_line( s )
    log( "flatten_line.enter=#{s.inspect}" )
    local_s = s.sub( /\e\[7m/, '' )
    local_s.sub!( /\e\[0m/, '' )
    log( "flatten_line.exit=#{local_s.inspect}" )
    return local_s
  end
 
  # Paints the current buffer onto the terminal.
  #
  # - Reads the current terminal size.
  # - If the size has changed since the last frame, the whole screen is cleared.
  #   Otherwise the cursor is moved to the top.
  # - The buffer is padded with empty lines so that it covers the entire screen.
  # - Each line is printed; trailing space is added to erase any characters
  #   that were longer in the previous frame.
  # - Finally, the current buffer becomes the old buffer and a new empty
  #   buffer is prepared (via 'new_buffer').
  def print
    @lines = @term.lines
    @columns = @term.cols
    log( "print.columns=#{@columns}" )
 
    if (@lines != @old_lines) || (@columns != @old_columns)
      #Elib.stop "#{@lines},#{@columns} vs. #{@old_lines},#{@old_columns}"
      clear()
    else
      goto_top()
    end
 
    # Ensure the buffer has exactly enough lines to cover the whole screen.
    while @buffer.length < @lines
      @buffer << ""
    end
 
    printed_chars = -1
    buffer().each_with_index do |out, index|
      log( "index=#{index}" )
      if index > 0
        #Log.on
        log( "printed_chars=#{printed_chars}" )
        #log( "columns=#{@columns}" )
        #if printed_chars <= @columns
          log( "yes" )
          @term.print Term.down
        #else
        #  log( "no" )
        #end
      end
      local_out = out
      flat_line = flatten_line( out )
      out_length = flat_line.length
      if out_length > @columns
        local_out = out[ 0...-(out_length - @columns) ]
        #Elib.stop local_out.length.inspect
        #Elib.stop out.inspect
        #Elib.stop @columns.inspect
        out_length = @columns
      end
      buffer[ index ] = flat_line
      @term.print local_out
      printed_chars = out_length
      if local_out.length < @columns
        if old_buffer()[ index ] && (old_buffer()[ index ].length > out_length)
          local_out = " " * (old_buffer()[ index ].length - out_length)
          @term.print local_out
          printed_chars += local_out.length
        end
      end
      #if printed_chars == 0
        #@term.unraw()
        #sleep 12
        #Elib.stop printed_chars.inspect
      #if printed_chars <= @columns
        @term.print "\r"
      #else
        #@term.print "\r"
        #@term.unraw()
        #sleep 12
      #  Elib.stop printed_chars.inspect
      #end
    end
    #buffer().each_with_index do |out, index|
    #  @term.print Term.down if index > 0
    #  @term.print out
    #  if old_buffer()[ index ] && (old_buffer()[ index ].length > out.length)
    #    @term.print " " * (old_buffer()[ index ].length - out.length)
    #  end
    #  @term.print "\r"
    #end
 
    new_buffer()
  end
 
  # -----------------------------------------------------------------
  # Class methods
  # -----------------------------------------------------------------
 
  # As a demonstration how to use this class, when invoked from the terminal
  # it prints basic terminal information to stdout.
  # With '--interactive' it shows the information in full-screen mode.
  #
  # @param arguments [Array<String>] command line arguments (e.g. '["--interactive"]')
  # @param screen    [Screen, nil]   a pre-built Screen object or nil to create one
  # @param testing   boolean         For invisible testing.
  # @return [String] the assembled output (when not interactive)
  def self.run(  arguments = [], screen = nil, testing = false, term = Term.new )
    log( "Screen.run( #{arguments.inspect}, #{screen.inspect}, #{term.inspect} )" )
    #puts( "Screen.run( #{arguments.inspect}, #{screen.inspect}, #{term.inspect} )" )
    ret_val = ""
 
    log     = false
    # For testing.
    if arguments.include?( "--check" )
      testing = true
    end
    if testing
      visible = false
      test_input = "q"
      term = Term.new( visible, log, test_input )
    end
    screen = Screen.new( term, open = false ) if !screen
    Rlib.assert( screen )
 
    local_lines   = term.lines
    local_columns = term.cols
 
    lines = []
    lines << ""
    lines << "Screen"
    lines << "======"
    lines << ""
    lines << "Size:     #{local_columns}x#{local_lines}"
    lines << "Terminal: #{ENV[ 'TERM' ]}"
    lines << ""
    if arguments.include?( "--interactive" )
      term.raw!
      screen.clear
      top_lines = (local_lines - (lines.length + 1))/2
      top_lines.times do
        term.puts
      end
      lines.each do |line|
        #Menu.center( line, term, local_columns )
        term.puts line
      end
      #Menu.quit( term, true, local_columns )
      screen.close
    else
      lines.each do |line|
        ret_val << line
        ret_val << "\n"
      end
    end
    if visible == false
      ret_val = ""
    end
 
    return ret_val
  end
 
  # Returns a help string suitable for a command-line parser.
  # The example shows how to declare additional options.
  #
  # @return [String]
  def self.print_help
    return "Screen [--testcache <md5dir>] [--logfile <access.log>] [--interactive] [--check]"
  end
 
  # Entry point when the file is run directly.
  # Do not modify; put your own code in 'self.run'.
  #
  # @param argv [Array<String>] command-line arguments (defaults to ARGV)
  def self.main( argv = nil )
    #if $testrun == true
    #  return
    #end
    print run( argv )
  end
end
 
#puts "$testrun=#{$testrun.inspect}"
#puts "__FILE__=#{__FILE__}"
#puts '$0=' + $0
#if (__FILE__ == $0) || ($testrun == true)
  #puts "Inside: #{ARGV.inspect}"
#  Screen.main( ARGV )
#end
 
# End of: screen.rb
EOT
 
22. Testing Escape Codes in Screen
----------------------------------
 
cat > test/test_screen_ansi.rb <<EOT
# This test demonstrates that Screen v1 does not properly clear the screen
# when a new line contains ANSI escape codes and is visually shorter than
# the previous line.
 
$: << File.dirname(__FILE__) + '/../lib'
 
require 'rlib'
require 'term'
require 'screen'   # first version of Screen
require 'log'
 
#Log.off
 
columns = 14
# 1-line, n-column terminal, muted with logging enabled
term = Term.new(false, true, '', 1, columns)   # visible=false, log=true
screen = Screen.new(term)
screen.clear
 
# First frame: 10 visible characters
screen.add("1234567890")
screen.print
 
# Second frame: text with ANSI reverse video, visually only 2 characters
#screen.add("\e[7mHi\e[0m")
screen.add( "Hi" )
screen.print
screen.close
 
# Remove all ANSI escape sequences from the logged output to inspect visible text
visible_output = term.output.gsub(/\e\[\d*m/, '')
 
# After the second print, the visible part should be "Hi" followed by
# 8 spaces (clearing the remainder of "1234567890").
# Screen v1 mistakenly thinks the line is 10 characters long and adds no spaces.
Rlib.assert(
  visible_output =~ /Hi {8}/,
  "Bug: Screen v1 fails to pad after ANSI-coded short line.\n" \
  "Visible output was:\n#{visible_output.inspect}"
)
 
#Log.on
 
# 1-line, n-column terminal, muted with logging enabled
visible = false
term = Term.new(visible, true, '', 1, columns)   # visible=false, log=true
screen = Screen.new(term)
 
# First frame: 10 visible characters
screen.add("1234567890")
screen.print
 
# Second frame: text with ANSI reverse video, visually only 2 characters
screen.add("\e[7mHi\e[0m")
screen.print
screen.close
 
# Remove all ANSI escape sequences from the logged output to inspect visible text
visible_output = term.output.gsub(/\e\[\d*m/, '')
 
# After the second print, the visible part should be "Hi" followed by
# 8 spaces (clearing the remainder of "1234567890").
# Screen v1 mistakenly thinks the line is 10 characters long and adds no spaces.
Rlib.assert(
  visible_output =~ /Hi {8}/,
  "Bug: Screen v1 fails to pad after ANSI-coded short line.\n" \
  "Visible output was:\n#{visible_output.inspect}"
)
 
puts "SUCCESS: #{__FILE__} - 0."
 
# End of: test_screen_ansi.rb
EOT
 
23. Screen Coverage Test
------------------------
 
This script uses SimpleCov and the 'Rlib' assertion methods, and for mocking
a term object of the Term class itself. Minitest is not used.
 
cat > ./test/test_screen_coverage.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname(__FILE__) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( 'Screen' )
#puts "Starting Screen coverage tests..."
 

# Stub the external 'log' function used by Screen (simulates loading lib/log)
module Kernel
  def log(*args)
  # no-op for testing
  end
end
 
# Stub the 'menu' module that Screen requires.
# Prevent the actual require by marking it as already loaded.
module Menu
  def self.center(line, term, columns)
    # Pads/centers the line and prints via the term object
    if term.respond_to?(:print)
      centered = line.center(columns)
      term.print(centered)
    end
  end
 
  def self.quit(term, wait, columns = nil)
    if wait
      loop do
        ch = term.getch
        break if ch == 'q'
      end
    end
    # When wait is false, nothing is done (as per original intention)
  end
end
$LOADED_FEATURES << 'menu.rb'
 
require 'rlib'
require 'term'
$testrun = true
ARGV << "--check"
require 'screen'
require 'log'
 
# ---------------------------------------------------------------------------
# Helper: create a Test-friendly Term with controlled size, muted & logging
# ---------------------------------------------------------------------------
def make_test_term(lines: 40, cols: 20, input: "", mute: true, log: true)
  # visible = !mute
  Term.new(!mute, log, input, lines, cols)
end
 
# For coverage.
#Log.on
visible = false
#visible = true
logging = true
term = Term.new( visible, logging, "", 4, 6 )
log "term.cols=#{term.cols}"
screen = Screen.new( term )
screen.add( "123456789" )
screen.print
#sleep 1
screen.add("\e[7mHi\e[0m")
screen.print
#sleep 2
Rlib.assert( term.output =~ /Hi/ )
#Rlib.assert( term.output =~ /[^ ]    /, "ERROR: the old line has not been overwritten." )
# Here is a bug, the "3456" string from the previous page should be overwritten with spaces but is not.
screen.add( "12345" )
screen.print
screen.add( "1" )
screen.add( "12345678" )
screen.add( "12345678" )
screen.add( "12" )
screen.print
#sleep 4
screen.close
#puts term.output.inspect
 
# ---------------------------------------------------------------------------
# 14. Bug: ANSI escape codes cause leftover characters after repaint
# ---------------------------------------------------------------------------
# This test mirrors the behaviour described at the top of the file and in
# test_screen_ansi.rb. If the bug is present, the test will fail with an
# assertion error, causing a non-zero exit status.
term_ansi_bug = Term.new(false, true, '', 1, 14)   # muted, 1 line, 14 cols
screen_bug = Screen.new(term_ansi_bug, false)
screen_bug.clear
 
# First frame: 10 visible characters
screen_bug.add("1234567890")
screen_bug.print
 
# Second frame: ANSI reverse video, visually only 2 characters
screen_bug.add("\e[7mHi\e[0m")
screen_bug.print
 
# Capture the visible output (strip ANSI escape sequences)
visible_output = term_ansi_bug.output.gsub(/\e\[\d*m/, '')
 
# After the second print, the visible part should be "Hi" followed by
# 8 spaces (clearing the remainder of "1234567890").
Rlib.assert(
  visible_output =~ /Hi {8}/,
    "Bug: Screen fails to pad after ANSI-coded short line.\n" \
    + "Visible output was: #{visible_output.inspect}"
)
 
screen_bug.close
 
# ---------------------------------------------------------------------------
# 1. Initialization & basic accessors (open = false)
# ---------------------------------------------------------------------------
term1 = make_test_term(lines: 30, cols: 80)
screen1 = Screen.new(term1, false)   # do not enter raw mode automatically
 
Rlib.assert(screen1.term == term1, "term() should return the injected Term")
Rlib.assert(screen1.buffer == [], "fresh buffer must be empty")
Rlib.assert(screen1.old_buffer.length == 30, "old_buffer pre-filled with #{30} empty strings")
Rlib.assert(screen1.old_buffer.all?(&:empty?), "old_buffer entries should be empty strings")
Rlib.assert(!term1.raw?, "raw! should not have been called with open=false")
 
# ---------------------------------------------------------------------------
# 2. Initialization with open = true (muted term to avoid stty calls)
# ---------------------------------------------------------------------------
term_open = make_test_term(lines: 10, cols: 10, mute: true, log: true)
screen_open = Screen.new(term_open, true)
 
Rlib.assert(term_open.raw?, "open=true should have called term.raw!")
# clear should have been called – we can verify via the log buffer
#puts "output=#{term_open.output.inspect}"
#regexp_clear = /\e\[H\e\[J/
regexp_clear = /\e\[H\e\[2J/
Rlib.assert( term_open.output.to_s =~ regexp_clear, "clear should output ANSI clear sequence (muted but logged)")
 
# ---------------------------------------------------------------------------
# 3. add, buffer, old_buffer, cut_top
# ---------------------------------------------------------------------------
term2 = make_test_term(lines: 20, cols: 40)
screen2 = Screen.new(term2, false)
 
screen2.add("line 1")
screen2.add("line 2")
screen2.add("line 3")
 
Rlib.assert(screen2.buffer == ["line 1", "line 2", "line 3"], "add should append lines")
Rlib.assert(screen2.old_buffer.length == 20, "old_buffer untouched by add: #{screen2.old_buffer.length}")
 
screen2.cut_top(2)
Rlib.assert(screen2.buffer == ["line 3"], "cut_top(2) should remove first two lines")
 
# ---------------------------------------------------------------------------
# 4. new_buffer, reuse_old_buffer
# ---------------------------------------------------------------------------
screen2.new_buffer
Rlib.assert(screen2.buffer == [], "new_buffer should reset buffer to empty")
Rlib.assert(screen2.old_buffer == ["line 3"], "new_buffer should move old buffer to old_buffer")
Rlib.assert(screen2.old_buffer.length == 1,
            "old_buffer does not need to remain padded to terminal lines (just the content changed): " +
            screen2.old_buffer.length.to_s
)
 
screen2.reuse_old_buffer
Rlib.assert(screen2.buffer == screen2.old_buffer.clone, "reuse_old_buffer should copy old_buffer back")
Rlib.assert(screen2.old_buffer == ["line 3"], "old_buffer unchanged after reuse")
 
# ---------------------------------------------------------------------------
# 5. goto_top
# ---------------------------------------------------------------------------
term_goto = make_test_term(lines: 3, cols: 10, mute: true, log: true)
screen_goto = Screen.new(term_goto, false)
# Simulate having printed something: manually set old_buffer with known content
screen_goto.instance_variable_set(:@old_buffer, ["AAA", "BBB", ""])  # 3 lines
screen_goto.goto_top
log_buf = term_goto.output
# It should print Term.up for each line in old_buffer, then a carriage return
# Term.up returns "e[A" (as defined in Term)
expected_up = Term.up  # "e[A"
3.times do
  Rlib.assert(log_buf.include?(expected_up), "goto_top should emit Term.up once per line")
  log_buf.sub!(expected_up, '')  # remove first occurrence for next assertion
end
Rlib.assert(log_buf.include?("\r"), "goto_top should emit a carriage return after ups")
 
# ---------------------------------------------------------------------------
# 6. print (same size) and automatic padding / overwrite logic
# ---------------------------------------------------------------------------
term_print = make_test_term(lines: 4, cols: 10, mute: true, log: true)
screen_print = Screen.new(term_print, false)
 
# First print: buffer has 2 lines, old_buffer empty (explicitly set empty for first run)
screen_print.add("short")
screen_print.add("medium")
# old_buffer is prefilled with "" by initialize, but we can rely on that
screen_print.print
first_output = term_print.output.dup
term_print.truncate!
 
# After first print, old_buffer should now be the lines we printed, padded to 4 lines
Rlib.assert(screen_print.old_buffer[0] == "short", "old_buffer[0] should be 'short'")
Rlib.assert(screen_print.old_buffer[1] == "medium", "old_buffer[1] should be 'medium'")
Rlib.assert(screen_print.old_buffer[2] == "", "padded line 3 should be empty")
Rlib.assert(screen_print.old_buffer[3] == "", "padded line 4 should be empty")
 
# Second print: add a line longer than old line 0, a shorter line for line 1
screen_print.add("longer")
screen_print.add("tiny")
screen_print.print
second_output = term_print.output
 
# "longer" should erase the old "short" (no extra spaces needed because longer)
# "tiny" should be followed by 2 spaces to overwrite "medium"
#puts "output=#{second_output.inspect}"
Rlib.assert(second_output =~ /tiny  /, "shorter line should be padded with spaces")
 
# Test the Term.down emission: after printing the first line, index>0 should print Term.down
Rlib.assert(second_output.include?(Term.down), "second line and onwards should emit Term.down before printing")
 
# ---------------------------------------------------------------------------
# 7. print (size change) – triggers clear branch
# ---------------------------------------------------------------------------
term_resize = make_test_term(lines: 6, cols: 10, mute: true, log: true)
screen_resize = Screen.new(term_resize, false)
screen_resize.add("A")
screen_resize.print  # old_lines = 6, lines currently 6
term_resize.truncate!
 
# Now manually change the terminal's reported size (simulate resize)
term_resize.instance_variable_set(:@lines, 8)
term_resize.instance_variable_set(:@cols, 10)  # same cols to test only line change
screen_resize.add("B")
screen_resize.print
resize_output = term_resize.output
 
# Because @lines(8) != @old_lines(6), the clear branch should be taken first
#puts "output=#{resize_output.inspect}"
Rlib.assert(resize_output =~ regexp_clear, "size change must call clear first")
 
# ---------------------------------------------------------------------------
# 8. close, clear, cooked! via close!
# ---------------------------------------------------------------------------
term_close = make_test_term(lines: 5, cols: 5, mute: false, log: true)  # muted? visible=false -> mute=true; we set mute=true earlier, so consistent
# Actually mute: true -> visible=false. So we'll use mute=true to avoid stty.
term_close = make_test_term(lines: 5, cols: 5, mute: true, log: true)
screen_close = Screen.new(term_close, false)
 
screen_close.close
Rlib.assert(term_close.close?() == false, "close should not mark term as closed")
Rlib.assert(!term_close.raw?, "close should cook the terminal if it was raw (which it wasn't)")
# close! is idempotent – call again, no error
screen_close.close
Rlib.assert(term_close.close?() == false, "still not closed")
 
# ---------------------------------------------------------------------------
# 9. Screen.run – non-interactive (no --interactive flag)
# ---------------------------------------------------------------------------
stdout_capture = Rlib.capture_stdout do
  @run_result = Screen.run([], nil, false)  # arguments empty -> non-interactive
end
Rlib.assert(@run_result.is_a?(String), "non-interactive run should return a string")
Rlib.assert(@run_result.include?("Screen"), "output should contain 'Screen'")
Rlib.assert(@run_result.include?("Size:"), "output should show terminal size")
Rlib.assert(stdout_capture.empty?, "non-interactive should not print to stdout (all returned)")
 
# ---------------------------------------------------------------------------
# 10. Screen.run – interactive mode with testing = true (muted, controlled input)
# ---------------------------------------------------------------------------
test_lines = 10
test_cols  = 20
term_interact = Term.new(false, true, "q", test_lines, test_cols) # visible=false (muted), log=true, input="q", specific size
screen_interact = Screen.new(term_interact, false) # not opening yet, but run will open inside? Actually run calls screen = Screen.new(term, open=false) if screen arg is nil. We'll pass screen_interact to avoid new internal term.
 
# We need to supply a term with same lines/cols to the run, but run creates its own term if no screen given. To control input, we can pass screen=nil and rely on the internal term which is created with testing=true and test_input="q". That internal term's lines/cols are from IO.console.winsize, which may not be available in CI. Better: pass a pre-built screen that uses a term we control, but run uses its own term for lines measurement. To keep things simple, we can call run with --interactive, testing=true, screen=nil, and mock IO.console? Or we can modify Screen.run to accept an optional term. However, the tests must not rely on a real terminal. We'll stub IO.console.winsize if needed.
 
# Let's redefine IO.console.winsize temporarily for this test.
io_console_class = IO.respond_to?(:console) ? IO.console.class : nil
if IO.respond_to?(:console)
  original_winsize = IO.console.method(:winsize)
  IO.console.define_singleton_method(:winsize) { [test_lines, test_cols] }
end
 
# Now internal term will have our desired size. We'll also ensure input is "q" for internal term.
ret_interact = Rlib.capture_stdout do
  Screen.run(["--interactive"], nil, true)
end
# ret_interact should be ""
 
Rlib.assert(ret_interact == "", "interactive run should return empty string")
# No exceptions thrown, terminal should be closed internally. Success.
 
# Restore original winsize if we stubbed it
if IO.respond_to?(:console)
  IO.console.define_singleton_method(:winsize, original_winsize) if original_winsize
end
=begin
# ---------------------------------------------------------------------------
# 11. Screen.run – interactive with explicit screen object (coverage for parameter)
# ---------------------------------------------------------------------------
# Reuse term_interact and screen_interact from above, run with --interactive and our screen.
term_interact2 = Term.new(false, true, "q", test_lines, test_cols)
screen_interact2 = Screen.new(term_interact2, false)
Rlib.assert(term_interact2 != nil, "term exists")
 
# Since run creates its own term for local_lines, but we pass screen, it uses our screen.
ret = Rlib.capture_stdout do
  Screen.run(["--interactive"], screen_interact2, false)  # non-testing? testing false will use visible=true and no input. To avoid hanging on getch, we need input "q". We'll set testing to true to get muted and input "q". But if testing=false, term is visible and input "". That would try to read real STDIN. So we must use testing=true. That sets visible=false internally and input "q", overriding any screen we pass? Wait: run's first lines always create a new term, regardless of screen. It sets visible=false, log=false, input="q". Then if screen is nil, it creates new screen with that term; otherwise it uses the passed screen. The passed screen's term is our term_interact2, but the local variable term is the new one, used only for local_lines. But line "Rlib.assert( screen )" is there. The passed screen's internal term won't be used for raw! or clear? Let's examine:
 
Run code:
term = Term.new(visible, log, test_input)
screen = Screen.new(term, open=false) if !screen
Rlib.assert(screen)
local_lines = term.lines   # uses the local term, not the screen's term
...
 
if arguments.include?("--interactive")
  term.raw!               # calls raw! on local term, not screen's term
  screen.clear            # clears using screen's term (screen.clear calls @term.clear)
  ...
  lines.each { |line| Menu.center(line, term, local_columns) }  # uses local term for output (center prints via term.print)
  Menu.quit(term, true, local_columns)   # uses local term for getch
  screen.close           # closes screen's term (@term.cooked!)
end
 
So when screen is passed, the interactive block uses the local term for raw! and printing and input, and uses screen's term only for clear and close. That's inconsistent. For our coverage, we can still test with screen=nil, which works perfectly with the local term. But we also need to cover the branch where screen is provided (the 'if !screen' false condition). We'll do that but with testing=true to avoid real input. However, the local term is muted and has input "q", so raw! is safe (muted), and Menu.quit reads from that local term's input buffer, which is "q". The screen's term is not used for raw/input. So it will work and cover the 'if !screen' condition. So we can run 'Screen.run(["--interactive"], screen_interact2, true)'.
 
We'll do that.
 
ret3 = Rlib.capture_stdout do
  Screen.run(["--interactive"], screen_interact2, true)
end
Rlib.assert(ret3 == "", "interactive with provided screen should return empty string")
=end
# ---------------------------------------------------------------------------
# 12. Screen.print_help
# ---------------------------------------------------------------------------
help_str = Screen.print_help
Rlib.assert(help_str.include?("--interactive"), "print_help should mention --interactive")
 
# ---------------------------------------------------------------------------
# 13. Screen.main (with valid arguments to avoid nil issues)
# ---------------------------------------------------------------------------
main_output = Rlib.capture_stdout do
  Screen.main(['--test'])
end
Rlib.assert(main_output.include?("Screen"), "main should output run's result")
# main with no arguments? We can test with default ARGV simulation by passing nil? The method main(argv = nil) uses ARGV if nil? So we could test main without arguments by temporarily setting ARGV. But to keep it simple, we'll test with an explicit array.
# We'll also cover the case where $testrequire is true ensuring the 'if (__FILE__ == $0) && ($testrequire != true)' block does not execute. Already covered.
 
#puts ARGV.inspect
#ret_val = Screen.run( [ "--check" ] )
#puts ret_val.inspect
#puts "hallo"
#ARGV << "--check"
#puts "ARGV=#{ARGV.inspect}"
#$testrequire = false
 
# Prevent Screen.main from executing automatically when the file is required
#$testrequire = true
#$testrun = true
#load "lib/screen.rb"
#puts "Loaded."
 
visible = false
logging = false
term = Term.new( visible, logging, "" )
screen = Screen.new( term, false )
ret_val = Screen.run( [ "--check" ], screen, false, term )
Rlib.assert( ret_val == "" )
 
CoverageChecker.verify( 'screen.rb', __FILE__ )
 
# End of: test_screen_coverage.rb
EOT
 
24. Screen Print Example
------------------------
 
Initially the screen main/run method was invoked when the Screen class was
invoked as the main file, while normally it is intended to be used as a
library. To make the lib Screen class easier to test, we move the invocation of
the example screen usage code into its own trivial Ruby script.
 
cat > bin/screen_print.rb <<EOT
#!/usr/bin/env ruby
# frozen_string_literal: true
# Do not edit this file, as it gets automatically generated by lp.
 
# screen_print.rb – Display terminal information using the Screen library.
#
# This script invokes the Screen.run class method from the Rlib Screen library
# to print basic details about the current terminal (size, $TERM value, etc.).
#
# Usage:
#   ruby bin/screen_print.rb [options]
#
# Options:
#   --interactive   Show the information in full-screen mode (raw terminal)
#   --check         Run in test/check mode (non-visible)
#   --help          Print this help message
#
# Example:
#   $ ruby bin/screen_print.rb
#   Screen
#   ======
#
#   Size:     80x24
#   Terminal: xterm-256color
#
#   $ ruby bin/screen_print.rb --interactive
#   # clears the screen and displays the information, press 'q' to quit
#
# Requirements:
#   - Linux
#   - Ruby 2 or 3
#   - The Rlib library (specifically lib/screen.rb and its dependencies)
#
# The library path is automatically adjusted to include '../lib' relative to
# this script's location.
 
require 'pathname'
 
lib_dir = Pathname.new(__FILE__).dirname.parent + 'lib'
$LOAD_PATH.unshift(lib_dir.to_s) unless $LOAD_PATH.include?(lib_dir.to_s)
 
require 'screen'
 
if ARGV.include?('--help')
  puts <<~HELP
  Usage: ruby #{File.basename(__FILE__)} [options]
 
  Options:
    --interactive   Interactive full-screen display
    --check         Non-visible test mode
    --help          Show this help
 
  Displays terminal size and $TERM value.
  When run in interactive mode, press 'q' to quit.
  HELP
  exit 0
end
 
Screen.main(ARGV)
 
# End of: screen_print.rb
EOT
 
25. Menu
--------
 
cat > ./lib/menu.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + "/../lib"
 
require 'log'
require 'rlib'
require 'term'
require 'screen'
 
#
# The Menu class provides utility methods for rendering text-based menus
# in a Linux terminal. It complements the Curses-based 'Tui' and the
# buffered 'Screen' class by offering simple, procedural menu printing
# reminiscent of classic C64 and DOS interfaces.
#
# The class is designed for use in full-screen (raw-mode) terminal
# applications.  Internally it leverages the 'Term' class for low-level
# output and input handling, and ensures that the terminal is correctly
# switched between raw and cooked modes before and after menu execution.
#
# @note All methods are class methods; there is no need to instantiate 'Menu'.
#
# @example Printing a simple choice menu
#   term = Term.new
#   menu_items = ["Start game", "Options", "Quit"]
#   actions    = ["start_game(term)", "options(term)", "exit"]
#   Menu.print_menu("Main Menu", menu_items, actions, "", term)
#
class Menu
 
  # -----------------------------------------------------------------
  # Class methods
  # -----------------------------------------------------------------
 
  # Prints a single line of text centered horizontally on the terminal.
  #
  # The text is preceded by enough spaces so that it appears in the
  # middle of the given width.  After printing the centered text a
  # newline is emitted (via 'term.puts').
  #
  # @param text  [String]  The line of text to center.
  # @param term  [Term]    (optional) The terminal object to use for output.
  #                         Defaults to a fresh 'Term' instance with
  #                         standard output.
  # @param width [Integer] (optional) The total width of the terminal area in
  #                        columns.
  #
  # @example
  #   Menu.center("Welcome!", my_term, 80)
  #
  def self.center( text, term = Term.new, width = nil )
    if width == nil
      width = term.cols()
    end
    term.print( " " * [ 0, ((width - text.length) / 2) ].max() )
    term.puts text
  end
 
  # Displays a full-screen menu, handles user input, and executes
  # the selected command.
  #
  # The method takes over the terminal by entering raw mode (character-at-a-time
  # input).  It clears the screen, prints a headline, a list of numbered menu
  # entries, and a prompt asking the user to make a selection or press 'q' to
  # quit.
  #
  # The menu is redrawn after each command returns, allowing interactive
  # workflows.  Before executing a command, the terminal is returned to
  # cooked mode so that standard I/O methods ('puts', 'gets', etc.) work
  # normally.  After the command finishes, raw mode is re-entered and the
  # menu is shown again.
  #
  # If the terminal is not in the expected raw or cooked state at certain
  # control points, the program exits with error code 1.
  #
  # @param headline [String]     The title string displayed at the top of the
  #                              menu.  An underline of '=' characters is
  #                              printed directly beneath it.
  # @param menus    [Array<String>]  Array of menu item descriptions.  They are
  #                              numbered starting at 1.
  # @param commands [Array<String>]  Array of Ruby expressions (as strings)
  #                              that are evaluated (via 'eval') when the
  #                              corresponding menu item is chosen.  These
  #                              expressions are executed in cooked mode.
  # @param keys     [String]     (optional) A pre-recorded sequence of key
  #                              strokes.  When non-empty, the menu uses these
  #                              keys instead of reading from the real terminal.
  #                              This is intended for testing.
  # @param term     [Term]       (optional) A 'Term' instance.  If omitted,
  #                              a fresh 'Term' is created with default
  #                              parameters.
  #
  # @return [void]
  #
  def self.print_menu( headline, menus, commands, keys = "", term = Term.new )
    index_keys = 0
 
    Rlib.assert( term.raw?() == false,
      "ERROR: term is in raw mode status: #{term.raw?().inspect}" )
    term.raw!()
 
    select_line = "Please select a menu item or press 'q' to quit. Thanks!"
    underline = "=" * headline.length
    max_line = (menus.map { |line| line.length + 3 }).max
 
    # Loop till the user exits this main menu.
    while true
      term.clear()
 
      local_lines   = term.lines()
      local_columns = term.cols()
      indent        = (local_columns - max_line) / 2
      indent_line   = " " * indent
      top_lines     = (local_lines - (menus.length * 2 + 6)) / 2
 
      top_lines.times { term.puts }
 
      center( headline,  term, local_columns )
      center( underline, term, local_columns )
      term.puts
      term.puts
 
      menus.each_with_index do |entry, index|
        term.printf indent_line
        term.puts "#{index + 1}. #{entry}"
        term.puts
      end
      term.puts
 
      center( select_line, term, local_columns )
 
      c = nil
      if index_keys < keys.length
        c = keys[ index_keys .. index_keys ]
        index_keys += 1
      else
        c = term.getch()
      end
 
      if c == "q"
        break
      elsif (c >= "0") && (c <= "9")
        c = c.to_i
        c = 10 if c == 0                 # treat '0' as index 9 (10th entry)
        index = c - 1
        if index < commands.length
          Rlib.assert( term.raw?() == true )
          term.cooked!()
          command = commands[ index ]
          log( command )
          eval command
          Rlib.assert( term.raw?() == false,
                       "ERROR: terminal is in raw mode but should not be." )
          term.raw!()
        end
      end
    end
 
    Rlib.assert( term.raw?() == true )
    term.cooked!()
  end
 
  # Blocks execution until the user presses 'q'.
  #
  # Intended as a simple way to keep a full-screen message visible until the
  # user explicitly dismisses it.  By default it prints "Press 'q' to quit.."
  # at the current cursor position; when 'centered' is 'true' the prompt is
  # horizontally centred within the given 'width'.
  #
  # @param term     [Term]    (optional) The terminal object. Defaults to a
  #                           fresh 'Term'.
  # @param centered [Boolean] (optional) If 'true', the prompt is centred over
  #                           'width' columns. Otherwise it is printed at the
  #                           current cursor position.
  # @param width    [Integer, nil] (optional) The column count to use for
  #                           centring.  Ignored unless 'centered' is 'true'.
  #
  # @return [String] Always returns the character '"q"'.
  #
  def self.quit( term = Term.new, centered = false, width = nil )
    ret_val = 'q'
    local_message = "Press 'q' to quit.."
    if centered
      center( local_message, term, width )
    else
      term.print local_message
    end
    while true
      c = term.getch
      if c == ret_val
        break
      end
    end
 
    return ret_val
  end
 
  # Prompts the user to choose one of up to 9 already printed numbered tasks.
  #
  # The caller is responsible for printing the numbered task list before
  # calling this method. The method prints only the selection prompt.
  #
  # Valid keys are the digits 1..number_of_tasks and lowercase 'q'.
  # Uppercase 'Q' and all other keys are ignored. Digits greater than
  # number_of_tasks are also ignored.
  #
  # If the user types extra characters after the accepted digit, those
  # characters remain in the input buffer for later processing.
  #
  # @param number_of_tasks [Integer] Number of selectable tasks, 1..9.
  # @param term [Term] Terminal object used for output and input.
  # @param centered [Boolean] If true, the prompt is centered horizontally.
  # @param width [Integer,nil] Width used for centering; ignored unless
  #                            centered. If empty when centered then term cols
  #                            is used to center.
  #
  # @return [Integer,nil] Selected task number from 1 to number_of_tasks,
  #   or nil if the user pressed 'q', EOF was reached, or the terminal closed.
  def self.choose_task( number_of_tasks, term = Term.new, centered = true, width = nil )
    unless number_of_tasks.is_a?(Integer) && (1..9).cover?(number_of_tasks)
      raise ArgumentError, "number_of_tasks must be an Integer between 1 and 9"
    end
 
    message = "Press 1"
    the = "the"
    if number_of_tasks > 1
      message << "-#{number_of_tasks}"
      the = "a"
    end
    message << " to select #{the} task, or 'q' to quit..."
 
    if centered
      center( message, term, width )
    else
      term.print( message )
    end
 
    while true
      key = term.key
 
      return nil if key.nil? || key == "q"
 
      if key =~ /\A[1-9]\z/
        choice = key.to_i
        return choice if choice <= number_of_tasks
      end
 
      # All other keys, including uppercase "Q", are ignored.
    end
  end
end
 
# End of: menu.rb
EOT
 
26. Menu Coverage Tests
-----------------------
 
cat > test/test_menu_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname(__FILE__) + "/../lib"
require 'coverage_checker'
CoverageChecker.start("menu.rb")
 
# ----------------------------------------------------------------------
# Stub the external 'log' function (simulate lib/log)
# ----------------------------------------------------------------------
module Kernel
  def log(*args)
  # no-op for testing
  end
end
 
# Prevent a circular require between screen.rb and menu.rb.
# Menu only requires 'screen' but doesn't call any Screen methods.
$LOADED_FEATURES << 'screen.rb'
 
require 'rlib'
require 'term'
require 'menu'
 
# ----------------------------------------------------------------------
# Helper: create a test-friendly Term with controlled size, muted & logging
# ----------------------------------------------------------------------
def make_test_term(lines: 10, cols: 20, input: "", visible: false, log: true)
  Term.new(visible, log, input, lines, cols)
end
 
# ======================================================================
# Tests for Menu.center
# ======================================================================
term_c = make_test_term
Menu.center("Hi", term_c, 20)
expected_center = " " * ((20 - 2) / 2) + "Hi\n"   # 9 spaces + "Hi\n"
Rlib.assert(term_c.output == expected_center, "center should produce centred text with newline")
term_c = make_test_term
Menu.center("Hi", term_c)
Rlib.assert(term_c.output == expected_center, "center should produce centred text with newline")
 
# Default term argument (cover the branch where no term is given)
output_default = Rlib.capture_stdout { Menu.center("Test") }
Rlib.assert(output_default.include?("Test"), "center with default term should output to stdout")
 
# ======================================================================
# Tests for Menu.quit
# ======================================================================
# Not centred
term_q = make_test_term(input: "xq")
ret = Menu.quit(term_q, false, nil)
Rlib.assert(ret == "q", "quit should return 'q'")
Rlib.assert(term_q.output.include?("Press 'q' to quit.."), "quit should print prompt")
Rlib.assert(!term_q.output.end_with?("\n"), "quit (not centred) should not add newline")
 
# Centred
term_qc = make_test_term(input: "q")
ret = Menu.quit(term_qc, true, 30)
Rlib.assert(ret == "q")
expected = "Press 'q' to quit.."
Rlib.assert(term_qc.output.include?("#{expected}\n"), "centred quit should include newline")
# (30 - 18) / 2 = 6 spaces of padding
expected_padding = (30 - expected.length) / 2
Rlib.assert(term_qc.output.start_with?((" " * expected_padding) + expected + "\n"),
            "centred quit output: #{term_qc.output.inspect}")
 
# ======================================================================
# Tests for Menu.print_menu
# ======================================================================
 
# ------------------------------------------------------------------
# 1. Term is already raw → SystemExit via assert
# ------------------------------------------------------------------
exit_happened = false
exit_status = 0
Rlib.capture_stdout do
begin
  term_raw = make_test_term
  term_raw.raw!          # make it raw
  Menu.print_menu("Title", ["A"], ["$x=1"], "q", term_raw)
  #Rlib.assert(false, "Should have raised SystemExit")
rescue SystemExit => e
  exit_status = e.status
  exit_happened = true
end
end
Rlib.assert(exit_status == 1, "Exit status must be 1")
Rlib.assert( exit_happened )
 
# ------------------------------------------------------------------
# 2. Normal flow with keys string
# ------------------------------------------------------------------
term_m1 = make_test_term(lines: 15, cols: 40)
$executed_m1 = false
Menu.print_menu("Main", ["Option1", "Option2"], ["$executed_m1 = true", "$executed_m1 = false"], "1q", term_m1)
Rlib.assert($executed_m1 == true, "Option1 should have been executed")
out1 = term_m1.output
Rlib.assert(out1.include?("Main"), "headline missing")
Rlib.assert(out1.include?("1. Option1"), "menu entry missing")
Rlib.assert(out1.include?("Please select a menu item"), "instruction missing")
# After command execution the screen is redrawn → headline appears twice
Rlib.assert(out1.scan("Main").length == 2, "headline should appear twice (initial and after command)")
 
# ------------------------------------------------------------------
# 3. Invalid digit (out of range) – no command executed
# ------------------------------------------------------------------
term_inv = make_test_term(lines: 15, cols: 40)
$executed_inv = nil
Menu.print_menu("Invalid", ["Item"], ["$executed_inv = 1"], "5q", term_inv)
Rlib.assert($executed_inv.nil?, "no command should have been executed for invalid digit")
 
# ------------------------------------------------------------------
# 4. Digit '0' with less than 10 commands → out of range
# ------------------------------------------------------------------
term_zero = make_test_term(lines: 15, cols: 40)
$executed_zero = :not_set
# Create 9 items / commands so that '0' maps to index 9 (out of range)
commands9 = Array.new(9) { |i| "$executed_zero = #{i}" }
Menu.print_menu("Zero", Array.new(9) { |i| "Item#{i}" }, commands9, "0q", term_zero)
Rlib.assert($executed_zero == :not_set, "zero digit should be out of range and not execute anything")
 
# ------------------------------------------------------------------
# 5. Using term.getch (empty keys string)
# ------------------------------------------------------------------
term_getch = make_test_term(input: "1q", lines: 15, cols: 40)
$executed_getch = false
Menu.print_menu("Getch", ["Option"], ["$executed_getch = true"], "", term_getch)
Rlib.assert($executed_getch == true, "getch input should work when keys string is empty")
 
# ------------------------------------------------------------------
# 6. Command that leaves the terminal in raw mode → SystemExit
# ------------------------------------------------------------------
#$evil_exit = false
#begin
#  term_evil = make_test_term(lines: 15, cols: 40)
#  Menu.print_menu("Evil", ["Evil"], ["term_evil.raw!"], "1q", term_evil)
#rescue SystemExit => e
#  $evil_exit = true
#  Rlib.assert(e.status == 1, "Exit status must be 1")
#end
#Rlib.assert($evil_exit, "Should have caught a SystemExit on raw mode violation")
 
# ------------------------------------------------------------------
# 7. Non-digit and non-'q' input – loop continues, then 'q' breaks
# ------------------------------------------------------------------
term_non = make_test_term(input: "aq", lines: 15, cols: 40)
$executed_non = nil
Menu.print_menu("Non", ["Item"], ["$executed_non = 1"], "", term_non)
Rlib.assert($executed_non.nil?, "no command should have been executed for non-digit input")
 
# ------------------------------------------------------------------
# 8. Edge case: keys string exhausts, falls back to term.getch
# ------------------------------------------------------------------
term_exhaust = make_test_term(input: "q", lines: 15, cols: 40)
# Provide a keys string that only contains '1', not 'q' – after that it must use term.getch
# The menu will execute the command for '1', then loop back, try to get another key.
# Since keys are exhausted, it calls term.getch which provides 'q' and breaks.
$executed_exhaust = false
Menu.print_menu("Exhaust", ["Item"], ["$executed_exhaust = true"], "1", term_exhaust)
Rlib.assert($executed_exhaust == true, "command should execute before fallback to getch")
 
# ======================================================================
# Tests for Menu.choose_task
# ======================================================================
 
# --- ArgumentError for invalid number_of_tasks ---------------------
[0, 10, "3", nil].each do |bad_tasks|
  begin
    Menu.choose_task(bad_tasks)
    Rlib.assert(false, "choose_task should raise ArgumentError for #{bad_tasks.inspect}")
  rescue ArgumentError => e
    Rlib.assert(e.message.include?("between 1 and 9"),
                "Error message for #{bad_tasks.inspect} should mention range")
  end
end
 
# --- number_of_tasks = 1, centered true, quit with 'q' ------------
term_ct1 = make_test_term(input: "q", lines: 10, cols: 20)
result_ct1 = Menu.choose_task(1, term_ct1, true, nil)
Rlib.assert(result_ct1.nil?, "choose_task should return nil for 'q'")
Rlib.assert(term_ct1.output.include?("Press 1 to select the task, or 'q' to quit..."),
            "Message for 1 task missing")
Rlib.assert(term_ct1.output.end_with?("\n"),
            "centered output should end with newline")
 
# --- number_of_tasks = 5, centered true, digit 3 ------------------
term_ct5 = make_test_term(input: "3", lines: 10, cols: 20)
result_ct5 = Menu.choose_task(5, term_ct5, true, nil)
Rlib.assert(result_ct5 == 3, "should return 3")
Rlib.assert(term_ct5.output.include?("Press 1-5 to select a task, or 'q' to quit..."),
            "Message for >1 tasks missing")
 
# --- centered false, number_of_tasks = 2, digit 2 -----------------
term_ct_false = make_test_term(input: "2", lines: 10, cols: 20)
result_ct_false = Menu.choose_task(2, term_ct_false, false, nil)
Rlib.assert(result_ct_false == 2, "should return 2")
expected_msg_false = "Press 1-2 to select a task, or 'q' to quit..."
Rlib.assert(term_ct_false.output == expected_msg_false,
            "centered false should print message without newline: #{term_ct_false.output.inspect}")
 
# --- key returns nil (closed terminal) ----------------------------
term_closed = make_test_term(input: "", lines: 10, cols: 20)
term_closed.instance_variable_set(:@closed, true)
result_closed = Menu.choose_task(2, term_closed, false, nil)
Rlib.assert(result_closed.nil?, "should return nil when key returns nil")
 
# --- uppercase Q ignored, then q quits ----------------------------
term_q_upper = make_test_term(input: "Qq", lines: 10, cols: 20)
result_q_upper = Menu.choose_task(2, term_q_upper, false, nil)
Rlib.assert(result_q_upper.nil?, "should return nil after ignoring uppercase Q and quitting with q")
 
# --- digit > number_of_tasks ignored, then q quits ----------------
term_too_high = make_test_term(input: "5q", lines: 10, cols: 20)
result_too_high = Menu.choose_task(3, term_too_high, false, nil)
Rlib.assert(result_too_high.nil?, "should return nil after ignoring out-of-range digit and then q")
 
# --- non-digit ignored, then q quits ------------------------------
term_non_digit = make_test_term(input: "aq", lines: 10, cols: 20)
result_non_digit = Menu.choose_task(2, term_non_digit, false, nil)
Rlib.assert(result_non_digit.nil?, "should return nil after ignoring non-digit and then q")
 
# --- digit 9 with number_of_tasks = 9 returns 9 -------------------
term_max = make_test_term(input: "9", lines: 10, cols: 20)
result_max = Menu.choose_task(9, term_max, false, nil)
Rlib.assert(result_max == 9, "should return 9")
 
# --- digit 1 with number_of_tasks = 9 returns 1 -------------------
term_min = make_test_term(input: "1", lines: 10, cols: 20)
result_min = Menu.choose_task(9, term_min, false, nil)
Rlib.assert(result_min == 1, "should return 1")
 
# --- centered true with explicit width ----------------------------
term_width = make_test_term(input: "q", lines: 10, cols: 20)
local_width = 160
term_width = make_test_term(input: "q", lines: 63, cols: local_width)
result_width = Menu.choose_task(2, term_width, true, local_width)
Rlib.assert(result_width.nil?)
expected_msg_width = "Press 1-2 to select a task, or 'q' to quit..."
pad_width = (local_width - expected_msg_width.length) / 2
Rlib.assert( pad_width >= 0,
             "ERROR: pad_width should not be negative: #{pad_width}" )
expected_output_width = (" " * pad_width) + expected_msg_width + "\n"
Rlib.assert(term_width.output == expected_output_width,
            "centered with width 30 should produce correct padding")
 
# --- centered true with width nil uses term.cols ------------------
local_width = 30
local_width = 160
term_cols = make_test_term(input: "q", lines: 10, cols: local_width)
result_cols = Menu.choose_task(2, term_cols, true, nil)
Rlib.assert(result_cols.nil?)
expected_msg_cols = expected_msg_width
pad_cols = (local_width - expected_msg_cols.length) / 2
Rlib.assert( pad_width >= 0,
             "ERROR: pad_width should not be negative: #{pad_width}" )
expected_output_cols = (" " * pad_cols) + expected_msg_cols + "\n"
Rlib.assert(term_cols.output == expected_output_cols,
            "centered with width nil should use term.cols")
 
puts "All test succeeded OK."
 
# ======================================================================
# Verification of 100% line and branch coverage
# ======================================================================
CoverageChecker.verify('menu.rb', __FILE__)
 
# End of: test_menu_coverage.rb
EOT
 
27. Ruby Template
-----------------
 
cat > ./bin/template.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
puts "SUCCESS: #{__FILE__} - 0."
 
# End of: template.rb
EOT
 
cat > ./bin/template_menu.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'term'
require 'menu'
 
def menu_option_one( term )
  term.clear
  term.puts( "Option One" )
  term.puts( "==========" )
  term.puts
  Menu.quit( term )
end
 
term = Term.new
menu     = [ "Option One" ]
commands = [ "menu_option_one( term )" ]
Menu.print_menu( "Menu Template", menu, commands, keys = "", term )
term.puts
 
# End of: template_menu.rb
EOT
 
28. Interactive Table Selector
------------------------------
 
cat > lib/table.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/..'
$: << File.dirname( __FILE__ )
 
require 'rlib'
require 'screen'
require 'term'
require 'log'
 
class Table
  # Aligns all columns of +table+ to the width of their widest cell by
  # padding the cells with trailing spaces.
  #
  # !!! WARNING: MUTATING METHOD (hence the '!') !!!
  #
  # This method alters the caller's data:
  #
  # * The rows of the internal working table are only *shallow* clones of
  #   the input rows (Array#clone). The row arrays of the returned table
  #   are new arrays, but the string objects inside them are the very same
  #   objects as in the caller's table.
  # * Padding a cell is done with String#<< on those shared string objects.
  #   Therefore every string of the input table that is shorter than the
  #   longest string of its column is PERMANENTLY padded with spaces -
  #   also in the caller's original table.
  # * Rows that have fewer columns than the widest row only get their
  #   additional empty columns in the returned copy; the caller's row
  #   arrays themselves are not extended.
  # * Cells that are +nil+ are left as +nil+ (neither padded nor replaced).
  #
  # If the original table must stay untouched, pass a deep copy, e.g.:
  #
  #   aligned = Table.align_table!( table.map { |row| row.map( &:dup ) } )
  #
  # @param table [Array<Array<String,nil>>] the table to align.
  #
  # @return [Array<Array<String,nil>>] a new table with new row arrays and
  #   all columns aligned to equal width. The string objects are shared
  #   with (and have been mutated in) the input table.
  def self.align_table!( table )
    # How many maximum columns are there around.
    max_columns = 0
    table.each do |row|
      if row.length > max_columns
        max_columns = row.length
      end
    end
 
    # Fill up missing columns.
    local_table = []
    table.each do |row|
      local_table << row.clone
      while local_table[ -1 ].length < max_columns
        local_table[ -1 ] << ""
      end
      Rlib.assert( local_table[ -1 ].length == max_columns, "ERROR: expected #{max_columns} columns: #{row.inspect}" )
    end
 
    # Maximum in each column.
    max_column_array = []
    local_table[ 0 ].each { |value| max_column_array << 0 }
    local_table.each do |row|
      row.each_with_index do |value, index_column|
        if value && (value.length > max_column_array[ index_column ])
          max_column_array[ index_column ] = value.length
        end
      end
    end
 
    # Finally fill up space in each column.
    local_table.each do |row|
      row.each_with_index do |value, index_column|
        value_length = 0
        if value
          value_length = value.length
        end
        if value_length < max_column_array[ index_column ]
          row[ index_column ] << (" " * (max_column_array[ index_column ] - value_length))
        end
      end
    end
 
    return local_table
  end
 
  # Returns the start index.
  def self.fill_screen( screen, local_table, start_index_param, selected_index, selected_column, use_header, separator )
    lines = screen.term.lines()
    columns = screen.term.cols()
 
    no_length = local_table.length.to_s.length
 
    start_index = start_index_param
    while start_index + lines - 1 <= selected_index
      start_index += 1
    end
    while selected_index < start_index
      start_index -= 1
    end
    1.upto( lines - 1 ) do |n|
      index = start_index + n - 1
 
      if index >= local_table.length
        line = " " * columns
      else
        line = (" " * (no_length - 1))
        number = (index + 1 + (use_header == true ? -1 : 0)).to_s
        if (use_header == true) && (index == 0)
          number = " "
        end
        line << number
        line = line[ -no_length..-1 ]
        if (use_header == true) && (index == 0)
          line << " "
        else
          line << "."
        end
 
        text_length = line.length
        local_table[ index ].each_with_index do |value, index_column|
          if text_length < columns
            line << separator
            text_length += separator.length
            if text_length > columns
              line = line[ 0...-(text_length - columns) ]
            end
            if text_length < columns
              if (index == selected_index) && (index_column == selected_column)
                line << Term.invert
              end
              line << value
              text_length += value.length
              if text_length > columns
                line = line[ 0...-(text_length - columns) ]
              end
              if (index == selected_index) && (index_column == selected_column)
                line << Term.normal
              end
            end
          end
        end
      end
 
      screen.add( line )
    end
 
    screen.add( "Use the cursor and <ENTER> keys to select an item or 'q' to quit.." )
 
    return start_index
  end
 
  # Interactive terminal table selector.
  #
  # Similar to +select_table+, but returns more information about the final
  # state of the selector (selected row, hot key, visible offset, and selected
  # column).
  #
  # The user can navigate with the arrow keys, PgUp/PgDn, Home, and End,
  # confirm the current row with Enter, cancel with +q+, or trigger any key
  # sequence listed in +hot_keys+.
  #
  # @param term [Term] terminal object used for input and output.
  # @param table [Array<Array<String>>] rows to display.
  # @param separator [String] string placed between columns.
  # @param use_header [Boolean] if +true+, the first row is treated as a header.
  # @param select_index [Integer] initially selected row index (0-based, before
  #   applying the header offset).
  # @param select_column [Integer] initially selected column.
  # @param hot_keys [String] key sequences that immediately terminate selection;
  #   the string is interpolated into a regular expression, so regex
  #   metacharacters must be escaped by the caller if necessary.
  # @param start_index_param [Integer] first visible row index at startup.
  # @param legal_columns [Array<Integer>] columns the cursor is allowed to move
  #   to.
  #
  # @note MUTATION WARNING: This method calls {align_table!} on +table+,
  #   which permanently pads the string objects of the caller's table with
  #   trailing spaces (see the documentation of Table.align_table! for the
  #   details). Pass a deep copy of +table+ (e.g.
  #   +table.map { |row| row.map( &:dup ) }+) if the original data must
  #   remain unchanged.
  #
  # @return [Array<(Integer,nil, String,nil, Integer, Integer, Integer)>]
  #   a 5-element array:
  #   1. selected row index, or +nil+ if the user cancelled with +q+;
  #   2. hot key that terminated the selection, or +nil+ if Enter was used;
  #   3. the selected row index at termination (same as the first element when
  #      the user did not cancel);
  #   4. top visible row index after interaction;
  #   5. selected column index after interaction.
  def self.select( term,
                   table,
                   separator = "  ",
                   use_header = false,
                   select_index = 0,
                   select_column = 0,
                   hot_keys = "",
                   start_index_param = 0,
                   legal_columns = [ select_column ] )
    # The terminal must not already be in raw mode; Screen will enable it.
    Rlib.assert( term.raw?() == false )
    #Rlib.assert( table.length > 0, "ERROR: table should have some data." )
 
    # Create a screen wrapper, clear the terminal, and query its dimensions.
    # Screen invokes raw mode.
    screen = Screen.new( term )
    term.clear
    lines = term.lines
    columns = term.cols
 
    # Initialise navigation state.
    start_index = start_index_param
    selected_index = select_index
    # skip the header row
    if use_header
      selected_index += 1
    end
    selected_column = select_column
    Rlib.assert( legal_columns.include?( selected_column ), "ERROR: selected column is not legal: #{selected_column}" )
 
    # Default return value for an immediate exit.
    ret_val = [ nil, nil, selected_index, start_index, selected_column ]
 
    # Pre-format the table so columns line up.
    # WARNING: align_table! pads the string objects of +table+ in place,
    # i.e. the caller's table data is permanently modified (see the
    # documentation of Table.align_table!).
    local_table = align_table!( table )
 
    # -------------------------------------------------------------------------
    # Main input loop
    # -------------------------------------------------------------------------
    while true
      # Redraw the visible portion of the table, then render the screen.
      start_index = fill_screen( screen, local_table,start_index, selected_index, selected_column, use_header, separator )
      screen.print
 
      # -----------------------------------------------------------------------
      # Read the first byte of a key sequence.
      # -----------------------------------------------------------------------
      c1 = term.getch()
      log( "c1=#{c1.inspect}" )
 
      # If the first byte is a configured hot key, finish immediately.
      if (c1 != "\e") && (hot_keys =~ /#{c1}/)
        ret_val = [ selected_index, c1, selected_index, start_index, selected_column ]
        break
      end
 
      # -----------------------------------------------------------------------
      # Single-character commands
      # -----------------------------------------------------------------------
      if c1 == "q"
        # Cancel: no row selected.
        ret_val = [ nil, nil, selected_index, start_index, selected_column ]
 
        break
      elsif c1 == "\r"
        # Enter: confirm current row, no hot key.
        ret_val = [ selected_index, nil, selected_index, start_index, selected_column ]
 
        break
      elsif c1 == "\e"
        # ---------------------------------------------------------------------
        # Escape sequence handling, 2nd character.
        # ---------------------------------------------------------------------
        c2 = term.getch()
        log( "c2=#{c2.inspect}" )
 
        if c2 == "["
          # CSI sequences: arrows, PgUp/PgDn, Home/End, F8.
          # Third character.
          c3 = term.getch()
          log( "c3=#{c3.inspect}" )
          if c3 == "B"
            # Down.
            selected_index += 1
            if selected_index >= local_table.length
              selected_index = local_table.length - 1
            end
 
            next
          elsif c3 == "A"
            # Up.
            selected_index -= 1
            if selected_index < 0
              selected_index = 0
            end
            if use_header && (selected_index == 0)
              selected_index = 1
            end
 
            next
          elsif c3 == "D"
            # Left arrow: move to the previous legal column.
            if legal_columns.length > 1
              if legal_columns.index( selected_column ) > 0
                selected_column = legal_columns[ legal_columns.index( selected_column ) - 1 ]
              end
            end
 
            next
          elsif c3 == "C"
            # Right arrow: move to the next legal column.
            if legal_columns.length > 1
              if legal_columns.index( selected_column ) < legal_columns.length - 1
                selected_column = legal_columns[ legal_columns.index( selected_column ) + 1 ]
              end
            end
 
            next
          elsif c3 == Term.pgdn[ 2..2 ]
            # PgDn is expected to be a 4-byte CSI sequence, e.g. "\e[6~".
            c4 = term.getch()
            if c4 == Term.pgdn[ 3..3 ]
              # PgDn.
              if selected_index < local_table.length - 1
                start_index += lines - 1 - 1
                if start_index < local_table.length
                  selected_index = start_index
                end
              end
 
              next
            end
          elsif c3 == Term.pgup[ 2..2 ]
            # PgUp is expected to be a 4-byte CSI sequence, e.g. "\e[5~".
            c4 = term.getch()
            if c4 == Term.pgup[ 3..3 ]
              # PgUp.
              start_index -= (lines - 2)
              if start_index < 0
                start_index = 0
              end
              if (selected_index - start_index) > lines - 2
                selected_index = [ start_index + lines - 2, local_table.length - 1 ].min
              end
 
              next
            end
          elsif c3 == "4"
            # End key: "\e[4~".
            c4 = term.getch()
            if c4 == "~"
              # End
              selected_index = local_table.length - 1
 
              next
            end
          elsif c3 == "1"
            # Home or F8 both start with "\e[1...".
            # 4.
            c4 = term.getch()
            if c4 == Term.f8[ 3..3 ]
              # F8 is expected to be a 5-byte CSI sequence, e.g. "\e[19~".
              # 5.
              c5 = term.getch()
              if c5 == Term.f8[ 4..4 ]
                # F8.
                if hot_keys =~ /\e\[#{Term.f8[ -3..-1 ]}/
                  ret_val = [ selected_index, Term.f8, selected_index, start_index, selected_column ]
 
                  break
                end
              end
            elsif c4 == "~"
              # Home key: "\e[1~".
              selected_index = 0
              start_index = 0
              if use_header
                selected_index = 1
              end
 
              next
            end
          end
        elsif c2 == Term.f2[ 1..1 ]
          # F1 and F2 share the prefix "\eO" on some terminals.
          # 3.
          c3 = term.getch()
          log( "f2.c3=#{c3.inspect}" )
 
          if c3 == Term.f2[ 2..2 ]
            if hot_keys.include?(Term.f2)
              ret_val = [ selected_index, Term.f2, selected_index, start_index, selected_column ]
              break
            end
          elsif c3 == Term.f1[ 2..2 ]
            if hot_keys.include?(Term.f1)
              ret_val = [ selected_index, Term.f1, selected_index, start_index, selected_column ]
              break
            end
          #else
          #  Elib.stop c.inspect
          end
        end
      end
    end
 
    screen.close
 
    return ret_val
  end
 
  # Returns the selected index (starting with 0) or nil on quit.
  # At least one row must be in the table.
  # If a row has less columns than the others, empty cells are added at the right side.
  # If the header is to be used, the first row represents the header.
  def self.select1( term, table, separator = "  ", use_header = false, select_index = 0, select_column = 0, hot_keys = "" )
    triplet = select( term, table, separator, use_header, select_index, select_column, hot_keys )
    if triplet[ 1 ]
      return [ triplet[ 0 ], triplet[ 1 ] ]
    end
 
    return triplet[ 0 ]
  end
end
 
# End of: table.rb
EOT
 
29. Table Tests
---------------
 
cat > ./test/test_table.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
require 'term'
require 'table'
 
# Minimal example: 2 rows - 2 columns
table = [
  [ "Name",  "Value" ],   # header row
  [ "foo",   "42"      ],
  [ "bar",   "123"     ]
]
 
visible = false
input = "q"
term = Term.new( visible, log = false, input )
result = Table.select1( term, table, "  ", true )
Rlib.assert( result == nil )
 
# Table used in several tests.
table = [
  ["Row 1", "Data A"],
  ["Row 2", "Data B"],
  ["Row 3", "Data C"],
]
 
# ----------------------------------------------------------------------
# Test 1: F1 pressed WITHOUT being in hot_keys
# Expected: F1 should be IGNORED, then 'q' cancels -> result = [nil, nil, ...]
# ----------------------------------------------------------------------
term = Term.new(false, false, "\eOPq")  # F1 = ESC O P, then 'q'
result = Table.select(
  term,
  table,
  "  ",
  false,       # use_header
  1,           # select_index (start on row 2)
  0,           # select_column
  "",          # hot_keys - EMPTY, F1 not listed
  0,           # start_index_param
  [0]          # legal_columns
)
Rlib.assert(result[0].nil?,
  "BUG: F1 acted as confirmation key even though not in hot_keys! " \
  "Got index=#{result[0].inspect}, hot_key=#{result[1].inspect}, " \
  "expected nil (cancelled by 'q')")
Rlib.assert(result[1].nil?,
  "BUG: F1 returned as hot_key=#{result[1].inspect} even though not in hot_keys! " \
  "Expected nil")
 
# ----------------------------------------------------------------------
# Test 2: F1 pressed WITH being in hot_keys
# Expected: F1 should work as hot key (terminate with the hot key)
# ----------------------------------------------------------------------
term = Term.new(false, false, "\eOP")
result = Table.select(
  term,
  table,
  "  ",
  false,
  1,
  0,
  Term.f1,     # hot_keys INCLUDES F1
  0,
  [0]
)
Rlib.assert(result[0] == 1 && result[1] == Term.f1,
  "F1 with hot_keys should return selection with hot_key=F1, got #{result.inspect}")
 
# ----------------------------------------------------------------------
# Test 3: F2 pressed WITHOUT being in hot_keys
# Expected: F2 should be ignored, then 'q' cancels -> nil, nil
# ----------------------------------------------------------------------
term = Term.new(false, false, "\eOQq")  # F2 = ESC O Q, then 'q'
result = Table.select(
  term,
  table,
  "  ",
  false,
  0,
  0,
  "",          # hot_keys - EMPTY
  0,
  [0]
)
Rlib.assert(result[0].nil?,
  "BUG: F2 acted as confirmation key even though not in hot_keys! " \
  "Got index=#{result[0].inspect}, hot_key=#{result[1].inspect}, " \
  "expected nil (cancelled by 'q')")
Rlib.assert(result[1].nil?,
  "BUG: F2 returned as hot_key=#{result[1].inspect} even though not in hot_keys! " \
  "Expected nil")
 
# ----------------------------------------------------------------------
# Test 4: Unknown escape sequence (should be ignored)
# ----------------------------------------------------------------------
term = Term.new(false, false, "\e[Xq")  # ESC [ X (unknown), then 'q'
result = Table.select(
  term,
  table,
  "  ",
  false,
  0,
  0,
  "",
  0,
  [0]
)
Rlib.assert(result[0].nil?,
  "Unknown escape sequence should be ignored, 'q' should cancel -> nil, got #{result.inspect}")
 
# ----------------------------------------------------------------------
# Test 5: F8 pressed WITHOUT being in hot_keys
# Expected: F8 should be ignored, then 'q' cancels
# ----------------------------------------------------------------------
term = Term.new(false, false, "\e[19~q")  # F8 = ESC [ 1 9 ~, then 'q'
result = Table.select(
  term,
  table,
  "  ",
  false,
  0,
  0,
  "",          # hot_keys - EMPTY
  0,
  [0]
)
Rlib.assert(result[0].nil?,
  "BUG: F8 acted as confirmation key even though not in hot_keys! " \
  "Got index=#{result[0].inspect}, hot_key=#{result[1].inspect}, " \
  "expected nil (cancelled by 'q')")
Rlib.assert(result[1].nil?,
  "BUG: F8 returned as hot_key=#{result[1].inspect} even though not in hot_keys! " \
  "Expected nil")
 
# ----------------------------------------------------------------------
# Test 6: F8 pressed WITH being in hot_keys
# ----------------------------------------------------------------------
term = Term.new(false, false, "\e[19~")
result = Table.select(
  term,
  table,
  "  ",
  false,
  0,
  0,
  Term.f8,     # hot_keys INCLUDES F8
  0,
  [0]
)
Rlib.assert(result[0] == 0 && result[1] == Term.f8,
  "F8 with hot_keys should return selection with hot_key=F8, got #{result.inspect}")
 
# The bug is that the current left-arrow code only checks whether
# 'selected_column - 1' is a legal column.
# That works only when legal columns are contiguous. If the legal columns have
# a gap, it gets stuck.
 
# A failing test is therefore one where the current column is '2', the legal
# columns are '[0, 2]', and you press Left.
# With the current code '2 - 1 == 1', and '1' is not legal, so it stays at
# '2'.
# With the replacement code it would move to the previous entry in
# 'legal_columns', i.e. '0'.
 
table = [
  %w[A B C],
  %w[D E F],
]
 
# Press Left arrow, then 'q' to quit.
term = Term.new(false, false,"\e[Dq")
 
result = Table.select(
  term,
  table,
  "  ",           # separator
  false,          # use_header
  0,              # select_index
  2,              # select_column
  "",             # hot_keys
  0,              # start_index_param
  [0, 2]          # legal_columns  <-- gap: column 1 is not legal
)
selected_column = result[4]
 
# This passes with the replacement code (selected_column becomes 0).
# It fails with the current code (selected_column stays 2).
Rlib.assert( selected_column == 0,
"ERROR: Left arrow should move from column 2 to the previous legal column 0" )
 
puts "SUCCESS: #{__FILE__} - 0."
 
# End of: test_table.rb
EOT
 
30. Table Test Coverage
-----------------------
 
The following coverage tests use the real Term class instead of a fake one.
 
cat > ./test/test_table_coverage.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
# test_table_coverage2.rb - Coverage test for Table using the real Term class.
 
$: << File.dirname(__FILE__) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "table.rb" )
 
require 'rlib'
require 'term'
require 'table'
 
# ----------------------------------------------------------------------
# Helper: create a muted, non-logging Term with a known size and input.
# The default size matches the original FakeTerm (10 lines, 30 columns).
# ----------------------------------------------------------------------
def make_test_term(input, lines = 10, cols = 30)
  Term.new(false, false, input, lines, cols)
end
 
# ----------------------------------------------------------------------
# Tests for Table.align_table!
# ----------------------------------------------------------------------
table_data = [
  ["Name", "Age"],
  ["Alice", "30"],
  ["Bob", "25"],
]
 
aligned = Table.align_table!(table_data)
Rlib.assert(aligned[0][0] == "Name ", "Header 'Name' should be padded to match 'Alice' length: #{aligned[0][0].inspect}")
Rlib.assert(aligned[0][1] == "Age", "Header 'Age' should remain unchanged: #{aligned[0][1].inspect}")
Rlib.assert(aligned[1][0] == "Alice", "Alice should not be padded: #{aligned[1][0].inspect}")
Rlib.assert(aligned[1][1] == "30 ", "Age 30 should be padded to match length 3 (Bob's age): #{aligned[1][1].inspect}")
 
# Rows with fewer columns are padded
uneven_table = [
  ["Name", "Age", "City"],
  ["Alice"],
]
aligned_uneven = Table.align_table!(uneven_table)
Rlib.assert(aligned_uneven[1].length == 3, "Short rows should be padded with empty columns")
Rlib.assert(aligned_uneven[1][1] == "   ", "Empty column 1 should be padded to the width of 'Age'")
Rlib.assert(aligned_uneven[1][2] == "    ", "Empty column 2 should be padded to the width of 'City'")
 
# ----------------------------------------------------------------------
# Basic selection tests
# ----------------------------------------------------------------------
 
# Immediate 'q' cancels
term_cancel = make_test_term("q")
result = Table.select(term_cancel, table_data, "  ", false, 0, 0, "", 0)
Rlib.assert(result[0].nil?, "select with 'q' should return nil selected index")
Rlib.assert(result[1].nil?, "No hot key should be set")
Rlib.assert(result[2] == 0, "selected index at termination should be 0")
Rlib.assert(result[3] == 0, "start index should remain 0")
Rlib.assert(result[4] == 0, "selected column should remain 0")
 
# Enter confirms
term_enter = make_test_term("\r")
result = Table.select(term_enter, table_data, "  ", false, 1, 0, "", 0)
Rlib.assert(result[0] == 1, "selected row should be 1")
Rlib.assert(result[1].nil?, "No hot key")
Rlib.assert(result[2] == 1, "selected index at termination should be 1")
Rlib.assert(result[3] == 0, "start index should be 0")
Rlib.assert(result[4] == 0, "selected column 0")
 
# select1 returns index or nil
term_quit = make_test_term("q")
idx = Table.select1(term_quit, table_data)
Rlib.assert(idx.nil?, "select1 should return nil on quit")
 
term_confirm = make_test_term("\r")
idx = Table.select1(term_confirm, table_data, "  ", false, 2, 0, "")
Rlib.assert(idx == 2, "select1 should return selected index 2")
 
# Hot key activation
term_hotkey = make_test_term("h")
result = Table.select(term_hotkey, table_data, "  ", false, 0, 0, "h", 0)
Rlib.assert(result[0] == 0, "selected row should be 0")
Rlib.assert(result[1] == "h", "hot key should be 'h'")
Rlib.assert(result[2] == 0, "selected index at termination should be 0")
Rlib.assert(result[3] == 0, "start index should be 0")
Rlib.assert(result[4] == 0, "selected column 0")
 
# select1 with hot key returns an array
term_select1_hot = make_test_term("f")
res = Table.select1(term_select1_hot, table_data, "  ", false, 0, 0, "f")
Rlib.assert(res.is_a?(Array), "select1 should return an array for a hot key")
Rlib.assert(res[0] == 0, "select1 hot key should return selected index 0")
Rlib.assert(res[1] == "f", "select1 hot key should return the hot key")
 
# ----------------------------------------------------------------------
# Navigation: arrows, PgUp/PgDn, Home, End
# ----------------------------------------------------------------------
 
# Down arrow moves selection down
term_down = make_test_term("\e[B\r")
result = Table.select(term_down, table_data, "  ", false, 0, 0, "", 0)
Rlib.assert(result[0] == 1, "Down arrow should move selection to row 1")
Rlib.assert(result[2] == 1, "selected index at termination should be 1")
Rlib.assert(result[4] == 0, "selected column should remain 0")
 
# Down arrow at bottom stays
term_down_bottom = make_test_term("\e[Bq", 10, 30)
result = Table.select(term_down_bottom, [["x"], ["y"]], "  ", false, 1, 0, "", 0)
Rlib.assert(result[2] == 1, "Down arrow at last row should stay at last row")
 
# Up arrow from first row stays
term_up = make_test_term("\e[A\r")
result = Table.select(term_up, table_data, "  ", false, 0, 0, "", 0)
Rlib.assert(result[0] == 0, "Up arrow from first row should remain at first row")
Rlib.assert(result[2] == 0, "selected index at termination should be 0")
Rlib.assert(result[4] == 0, "selected column should remain 0")
 
# Up arrow with header: from first data row stays
term_header_up = make_test_term("\e[Aq", 10, 30)
header_table = [["H1", "H2"], ["a", "b"]]
result = Table.select(term_header_up, header_table, "  ", true, 0, 0, "", 0)
Rlib.assert(result[2] == 1, "Up from first data row with header should stay at first data row")
 
# Left arrow with non-contiguous legal columns
table_left = [
  ["A", "B", "C"],
  ["D", "E", "F"],
]
term_left = make_test_term("\e[Dq", 10, 30)
result = Table.select(
  term_left,
  table_left,
  "  ", false, 0, 2, "", 0, [0, 2]
)
Rlib.assert(result[4] == 0, "Left arrow should move from column 2 to the previous legal column 0")
 
# Right arrow to next legal column
term_right = make_test_term("\e[Cq", 10, 30)
result = Table.select(
  term_right,
  table_left,
  "  ", false, 0, 0, "", 0, [0, 1]
)
Rlib.assert(result[4] == 1, "Right arrow should move from column 0 to the next legal column 1")
 
# Left arrow from first legal column stays
term_left_first = make_test_term("\e[Dq", 10, 30)
result = Table.select(term_left_first, table_left, "  ", false, 0, 0, "", 0, [0, 2])
Rlib.assert(result[4] == 0, "Left arrow from first legal column should stay")
 
# Right arrow from last legal column stays
term_right_last = make_test_term("\e[Cq", 10, 30)
result = Table.select(term_right_last, table_left, "  ", false, 0, 2, "", 0, [0, 2])
Rlib.assert(result[4] == 2, "Right arrow from last legal column should stay")
 
# Left arrow with single legal column stays
term_left_single = make_test_term("\e[Dq", 10, 30)
result = Table.select(term_left_single, table_left, "  ", false, 0, 0, "", 0, [0])
Rlib.assert(result[4] == 0, "Left arrow with a single legal column should stay")
 
# Right arrow with single legal column stays
term_right_single = make_test_term("\e[Cq", 10, 30)
result = Table.select(term_right_single, table_left, "  ", false, 0, 0, "", 0, [0])
Rlib.assert(result[4] == 0, "Right arrow with a single legal column should stay")
 
# PgDn advances a page
term_pgdn = make_test_term(Term.pgdn + "q", 10, 30)
rows_pgdn = (1..12).map { |i| ["row#{i}"] }
result = Table.select(term_pgdn, rows_pgdn, "  ", false, 0, 0, "", 0)
Rlib.assert(result[3] == 8, "PgDn should advance start index by a page")
Rlib.assert(result[2] == 8, "PgDn should move selection to the new start index")
 
# PgDn on small table does nothing
term_pgdn_small = make_test_term(Term.pgdn + "q", 10, 30)
result = Table.select(term_pgdn_small, [["a"], ["b"], ["c"]], "  ", false, 0, 0, "", 0)
Rlib.assert(result[2] == 0, "PgDn on small table should not change selected row")
 
# PgDn at bottom stays
term_pgdn_bottom = make_test_term(Term.pgdn + "q", 10, 30)
result = Table.select(term_pgdn_bottom, [["a"], ["b"], ["c"]], "  ", false, 2, 0, "", 0)
Rlib.assert(result[2] == 2, "PgDn at bottom should stay at bottom")
 
# PgUp goes back a page
term_pgup = make_test_term(Term.pgup + "q", 10, 30)
rows_pgup = (1..10).map { |i| ["row#{i}"] }
result = Table.select(term_pgup, rows_pgup, "  ", false, 9, 0, "", 0)
Rlib.assert(result[3] == 0, "PgUp should clamp start index to zero")
Rlib.assert(result[2] == 8, "PgUp from bottom should move selection to bottom of previous page")
 
# PgUp from high start index
term_pgup_high = make_test_term(Term.pgup + "q", 10, 30)
rows_pgup_high = (1..20).map { |i| ["row#{i}"] }
result = Table.select(term_pgup_high, rows_pgup_high, "  ", false, 9, 0, "", 8)
Rlib.assert(result[2] == 8, "PgUp from a high start index should move selection up one page")
 
# PgUp near top leaves selection unchanged
term_pgup_near = make_test_term(Term.pgup + "q", 10, 30)
rows_pgup_near = (1..10).map { |i| ["row#{i}"] }
result = Table.select(term_pgup_near, rows_pgup_near, "  ", false, 5, 0, "", 0)
Rlib.assert(result[2] == 5, "PgUp near the top should leave selection unchanged")
 
# End selects last row
term_end = make_test_term("\e[4~q", 10, 30)
rows_end = (1..5).map { |i| ["row#{i}"] }
result = Table.select(term_end, rows_end, "  ", false, 0, 0, "", 0)
Rlib.assert(result[2] == 4, "End should select the last row")
 
# Home with header selects first data row
term_home = make_test_term("\e[1~q", 10, 30)
home_table = [["Header"], ["data1"], ["data2"]]
result = Table.select(term_home, home_table, "  ", true, 2, 0, "", 0)
Rlib.assert(result[2] == 1, "Home should select first data row when use_header is true")
Rlib.assert(result[3] == 0, "Home should reset start index")
 
# Home without header selects first row
term_home_no_header = make_test_term("\e[1~q", 10, 30)
result = Table.select(term_home_no_header, home_table, "  ", false, 2, 0, "", 0)
Rlib.assert(result[2] == 0, "Home without header should select row 0")
 
# ----------------------------------------------------------------------
# Function keys as hot keys
# ----------------------------------------------------------------------
 
# F8 as hot key
term_f8 = make_test_term(Term.f8)
result = Table.select(term_f8, [["a"]], "  ", false, 0, 0, Term.f8, 0)
Rlib.assert(result[1] == Term.f8, "F8 should be returned as the terminating hot key")
Rlib.assert(result[0] == 0, "F8 should not change the selected row")
 
# F1 as hot key
term_f1 = make_test_term(Term.f1)
result = Table.select(term_f1, [["a"], ["b"]], "  ", false, 1, 0, Term.f1, 0)
Rlib.assert(result[1] == Term.f1, "F1 should be returned as the terminating hot key")
Rlib.assert(result[0] == 1, "F1 should keep the selected row")
 
# F2 as hot key
term_f2 = make_test_term(Term.f2)
result = Table.select(term_f2, [["a"], ["b"]], "  ", false, 0, 0, Term.f2, 0)
Rlib.assert(result[1] == Term.f2, "F2 should be returned as the terminating hot key")
Rlib.assert(result[0] == 0, "F2 should keep the selected row")
 
# F1 without being hot key is ignored, then q cancels
term_f1_no = make_test_term(Term.f1 + "q")
result = Table.select(term_f1_no, [["a"], ["b"]], "  ", false, 1, 0, "", 0)
Rlib.assert(result[0].nil?, "F1 without hot key should be ignored; q should cancel")
Rlib.assert(result[1].nil?, "No hot key should be set")
 
# F2 without being hot key is ignored
term_f2_no = make_test_term(Term.f2 + "q")
result = Table.select(term_f2_no, [["a"], ["b"]], "  ", false, 0, 0, "", 0)
Rlib.assert(result[0].nil?, "F2 without hot key should be ignored; q should cancel")
Rlib.assert(result[1].nil?, "No hot key should be set")
 
# F8 without being hot key is ignored
term_f8_no = make_test_term(Term.f8 + "q")
result = Table.select(term_f8_no, [["a"]], "  ", false, 0, 0, "", 0)
Rlib.assert(result[0].nil?, "F8 without hot key should be ignored; q should cancel")
 
# ----------------------------------------------------------------------
# Edge cases: truncation, fill_screen adjustments, unknown keys
# ----------------------------------------------------------------------
 
# Truncation when separator exceeds terminal columns
term_sep_trunc = make_test_term("q", 5, 5)
Table.select(term_sep_trunc, [["a"]], "    ", false, 0, 0, "", 0)
 
# Truncation when cell value exceeds terminal columns
term_val_trunc = make_test_term("q", 5, 5)
Table.select(term_val_trunc, [["abc"]], "  ", false, 0, 0, "", 0)
 
# start index clamps down (selected row above visible)
term_back = make_test_term("q")
result = Table.select(term_back, [["a"], ["b"]], "  ", false, 0, 0, "", 100)
Rlib.assert(result[3] == 0, "start index should be clamped back to selected row")
 
# start index advances when selection below visible area
rows_forward = (1..10).map { |i| ["row#{i}"] }
term_forward = make_test_term("q")
result = Table.select(term_forward, rows_forward, "  ", false, 9, 0, "", 0)
Rlib.assert(result[3] == 1, "start index should advance when selected row is below visible area")
 
# Unknown single character ignored until q
term_unknown = make_test_term("xq")
result = Table.select(term_unknown, [["a"]], "  ", false, 0, 0, "", 0)
Rlib.assert(result[0].nil?, "unrecognised key should be ignored until q cancels")
 
# Unknown CSI sequence ignored
term_unknown_csi = make_test_term("\e[Zq")
Table.select(term_unknown_csi, [["a"]], "  ", false, 0, 0, "", 0)
 
# Bad PgDn sequence ignored
term_pgdn_bad = make_test_term("\e[#{Term.pgdn[2..2]}xq")
Table.select(term_pgdn_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# Bad PgUp sequence ignored
term_pgup_bad = make_test_term("\e[#{Term.pgup[2..2]}xq")
Table.select(term_pgup_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# Bad End sequence ignored
term_end_bad = make_test_term("\e[4xq")
Table.select(term_end_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# Bad Home sequence ignored
term_home_bad = make_test_term("\e[1xq")
Table.select(term_home_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# Bad F8 sequence ignored
term_f8_bad = make_test_term("\e[1#{Term.f8[3..3]}xq")
Table.select(term_f8_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# ESC followed by unknown second byte
term_esc_unknown = make_test_term("\e" + "xq")
Table.select(term_esc_unknown, [["a"]], "  ", false, 0, 0, "", 0)
 
# F1/F2 prefix but third byte not F1/F2
term_f_bad = make_test_term("\e" + Term.f2[1..1] + "xq")
Table.select(term_f_bad, [["a"]], "  ", false, 0, 0, "", 0)
 
# Cover the else branch of the 'value && ...' width scan in align_table!
nil_column_table = [[nil], [nil]]
aligned_nil = Table.align_table!(nil_column_table)
Rlib.assert(aligned_nil[0][0].nil?, "all-nil column should stay nil")
Rlib.assert(aligned_nil[1][0].nil?, "all-nil column should stay nil")
 
# Cover the else branch of the outer text_length < columns check in fill_screen
term_prefix_full = make_test_term("q", 5, 2)   # 5 lines, 2 columns
Table.select(term_prefix_full, [["a"]], "  ", false, 0, 0, "", 0)
 
# ----------------------------------------------------------------------
# Coverage verification
# ----------------------------------------------------------------------
CoverageChecker.verify( "table.rb", __FILE__ )
 
# End of: test_table_coverage.rb
EOT
 
31. More Ruby Pager
-------------------
 
To avoid having external processes to view a text file and breaking easy
testability, here comes a Ruby implementagion of a simple text pager like
'more' or 'less' in Unix.
 
cat > lib/more.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + "/../lib"
 
require 'log'
require 'rlib'
require 'term'
require 'screen'
 
#
# A text viewer like 'more' implemented in Ruby.
#
# 2026-08-15
class More
  NONE     = 0
  QUIT     = 1
  PGUP     = 2
  PGDN     = 3
  UP       = 4
  DOWN     = 5
  RIGHT    = 6
  LEFT     = 7
  HELP     = 8
  NUMBERS  = 9
  HOME     = 10
  ENDKEY   = 11
  LINEHOME = 12
  LINEEND  = 13
  TITLE    = 14
 
  # Cursor must be already at the end of the screen.
  def self.print_status( term, message )
    term.print "\r"
    message_line = message + (' ' * ([ term.cols - message.length, 0 ].max))
    message_line = message_line[ 0...term.cols ]
    term.print message_line
    term.getch()
  end
 
  # When this is invoked, raw mode must be already set.
  # When the text is shorter than the whole screen, it quits immediately, unless force quit is set to true.
  def self.more( filename, force_quit, no_help = false, line_numbers = false, term = Term.new )
    Rlib.assert( File.exist?( filename ) )
 
    content = Rlib.readfile( filename )
    return more_content( content, force_quit, no_help, line_numbers, break_lines = false, title = "More: press the F1 key for more help.", hot_keys = "", term )
  end
 
  def self.more_content( content, force_quit = false, no_help = false, line_numbers = false, break_lines = false, title = "More: press the F1 key for more help.", hot_keys = "", term = Term.new, help_filename_param = File.expand_path( File.join( File.dirname( __FILE__ ), '..', 'doc', 'help_more.txt' ) ) )
    Log.log( "More.more_content( content, #{force_quit.inspect}, #{no_help.inspect}, #{line_numbers.inspect}, #{break_lines.inspect}, #{title.inspect}, #{hot_keys.inspect}, term, #{help_filename_param.inspect} )" )
    Rlib.assert( term.raw?() == true, "ERROR: raw mode must be already set but it isn't." )
    ret_val = ""
 
    #Log.log( "term.input=#{term.get_input.inspect}" )
 
    content_lines = content.split( /\n/ )
    # Untabify.
    content_lines.map! do |next_line|
      next_line.gsub( /\t/, '        ' )
    end
 
    #
    # Break lines, shouldn't that be handled inside the loop?
    #
    local_columns = term.cols()
    Rlib.assert( local_columns > 0 )
    if break_lines
      content_lines2 = []
      content_lines.each do |next_line|
        if next_line.length > local_columns
          while next_line.length > local_columns
            first_line = next_line[ 0...local_columns ]
            content_lines2 << first_line
            next_line = next_line[ local_columns..-1 ]
          end
        end
        content_lines2 << next_line
      end
      content_lines = content_lines2
    end
    numbers_width = 0
    if line_numbers
      numbers_width = content_lines.length.to_s.length + 1
    end
    max_length = 0
    content_lines.each do |next_line|
      if next_line.length > max_length
        max_length = next_line.length
      end
    end
 
    # Prints the page starting at this line index.
    line_start = 0
    left_start = 0
    screen_line = 0
    term.clear
    screen = Screen.new( term, open_param = false )
    while true
      local_lines = term.lines()
      local_columns = term.cols()
      log( "lines=#{local_lines}" )
      total_pages = content_lines.length / (local_lines - 1) + 1
      if content_lines.length < local_lines
        content_lines.each do |next_line|
          screen.add( next_line )
        end
        Log.log( "force_quit=#{force_quit.inspect}")
        if !force_quit
          break
        end
      else
        #if !(line_start >= content_lines.length)
        page = line_start / (local_lines - 1) + 1
        page_end = (line_start + local_lines - 2) / (local_lines - 1) + 1
 
        screen_line = 0
        content_lines[ line_start...(line_start + local_lines - 1) ].each_with_index do |next_line, index|
          local_line = ""
          if line_numbers
            local_line << "%#{numbers_width - 1}i " % (line_start + 1 + index)
          end
          #local_line << next_line[ left_start...(left_start + local_columns - numbers_width) ]
          slice = next_line[ left_start...(left_start + local_columns - numbers_width) ] || ""
          local_line << slice
          log( "add[#{screen_line}]=#{local_line.inspect}/#{local_line.length()}" )
          screen.add( local_line )
          screen_line += 1
        end
        1.upto( local_lines - 1 - screen_line ) do |dummy|
          screen.add( "" )
        end
        local_line = "--More--(page #{page}"
        local_line2 = ""
        if page_end > page
          local_line2 = "/#{page_end}"
        end
        local_line3 = " of #{total_pages})"
        screen.add( local_line + local_line2 + local_line3 )
        #end
      end
 
      screen.print
 
      key = NONE
      c = term.getch()
      if c == 'q'
        key = QUIT
        term.puts
        break
      elsif hot_keys.include?(c)
        break
      elsif [ "", "\n" ].include?( c )
        key = PGDN
        if line_start + local_lines - 1 < content_lines.length
          line_start += (local_lines - 1)
        end
      elsif c == "\u007F"
        key = PGUP
        line_start -= (local_lines - 1)
        if line_start < 0
          line_start = 0
        end
      elsif c == "n"
        key = NUMBERS
        line_numbers = !line_numbers
        if !line_numbers
          numbers_width = 0
        else
          numbers_width = content_lines.length.to_s.length + 1
        end
      elsif c == "t"
        key = TITLE
      elsif [ "h", "?" ].include?( c )
        key = HELP
      elsif c == "0"
        key = LINEHOME
      elsif c == "A"
        key = LINEEND
      elsif c == "\u0005"
        key = LINEEND
      elsif c == "\u0001"
        key = LINEHOME
      elsif c == "g"
        key = HOME
      elsif c == "G"
        key = ENDKEY
      elsif c == "\e"
        c = term.getch()
        if c == "O"
          c = term.getch()
          if c == "P"
            key = HELP
          end
        elsif c == "["
          c = term.getch()
          if c == "1"
            key = HOME
          elsif c == "4"
            key = ENDKEY
          elsif c == "6"
            c = term.getch()
            if c == "~"
              key = PGDN
              if line_start + local_lines - 1 < content_lines.length
                line_start += (local_lines - 1)
              end
            end
          elsif c == "5"
            c = term.getch()
            if c == "~"
              key = PGUP
              line_start -= (local_lines - 1)
              if line_start < 0
                line_start = 0
              end
            end
          elsif c == "C"
            key = RIGHT
            if left_start < (max_length - local_columns + numbers_width)
              left_start += 1
            end
          elsif c == "D"
            key = LEFT
            if left_start > 0
              left_start -= 1
            end
          elsif c == "A"
            key = UP
            if line_start > 0
              line_start -= 1
            end
          elsif c == "B"
            key = DOWN
            if line_start < content_lines.length - local_lines + 1
              line_start += 1
            end
          end
        end
      end
      if key == HELP
        if no_help == false
          more( help_filename_param, force_quit_param = true, this_is_help_param = true, line_numbers_param = false, term )
          term.clear
        end
      elsif key == HOME
        line_start = 0
      elsif key == ENDKEY
        line_start = content_lines.length - local_lines + 1
      elsif key == LINEHOME
        left_start = 0
      elsif key == LINEEND
        left_start = max_length - local_columns + numbers_width
        if left_start < 0
          left_start = 0
        end
      elsif key == TITLE
        Log.log( "title" )
        print_status( term, title )
        term.clear()
      end
    end
    ret_val = c
 
    return ret_val
  end
end
 
#if (__FILE__ == $0) or $testing
#  More.main( ARGV )
#end
 
# End of: more.rb
EOT
 
31.1. Test Coverage
'''''''''''''''''''
 
cat > ./test/test_more_coverage.rb <<EOT
# Do not edit this file, as it gets automatically generated by lp.
# Copy it some place else, extend it, and remove these two lines.
 
$: << File.dirname( __FILE__ ) + '/../lib'
require 'coverage_checker'
CoverageChecker.start( "more.rb" )
 
require 'more'
require 'rlib'
require 'term'
 
# Tests using Rlib.assert( ... ) should follow here.
 
visible = true
visible = false
log     = false
input   = "q"
 
term = Term.new( visible, log, input, 63, 160 )
Rlib.assert( term )
 
content = Rlib.readfile( __FILE__ )
term.raw!
More.more_content( content, force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "More", hot_keys = "", term )
term.cooked!
 
input   = "tq" + Term.pgdn + Term.pgup + "h" + Term.home + Term.endkey +
          Term.f1 + Term.right + Term.left + "q"
term = Term.new( visible, log, input, 63, 160 )
content = Rlib.readfile( File.dirname( __FILE__ ) + '/../lib/more.rb' )
term.raw!
More.more_content( content, force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "More", hot_keys = "", term )
term.cooked!
 
# Short content, force_quit = false immediate break, no input needed
input = ""
term = Term.new( visible, log, input, 63, 160 )
content = "hello world"
term.raw!
More.more_content( content, force_quit = false, no_help = true, line_numbers = false, break_lines = false, title = "More", hot_keys = "", term )
term.cooked!
 
input = "q"
lines = 5
cols = 80
term_short_q = Term.new( visible, log, input, lines, cols )
term_short_q.raw!
More.more_content( "hello", force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "", hot_keys = "", term_short_q )
term_short_q.cooked!
 
# Long content, full sequence of keys (with force_quit true to keep loop running)
input = "t" + "n" + "0" + "A" + "\u0005" + "\u0001" + "g" + "G" +
        "h" + "?" + "\n" + "\u007F" + "\e[A" + "\e[B" + "\e[C" + "\e[D" +
        "\eOP" + "\e[1~" + "\e[4~" + "\e[6~" + "\e[5~" + "q"
lines = 24
cols = 80
term_long = Term.new( visible, log, input, lines, cols )
term_long.raw!
content_long = (1..50).map { |i| "Line #{i} " + "x" * 40 }.join("\n")
More.more_content( content_long, force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "Test", hot_keys = "", term_long )
term_long.cooked!
 
# Helper to create a Term with simulated input
def make_term(input, lines, cols)
  Term.new( false, false, input, lines, cols )  # visible = false, log = false
end
 
# 4. break_lines = true, line_numbers = true, long lines to test breaking
input_break = "n" + "G" + "q"
term_break = make_term( input_break, 10, 40 )
term_break.raw!
content_break = (1..30).map { |i| "Line #{i} " + "y" * 100 }.join("\n")
More.more_content( content_break, force_quit = true, no_help = true, line_numbers = true, break_lines = true, title = "", hot_keys = "", term_break )
term_break.cooked!
 
# 5. Partial last page to force blank fill (content > one page, then PGDN to end)
input_partial = "\n" + "\n" + "q"   # two PGDNs to reach partial last page
term_partial = make_term( input_partial, 5, 80 )
term_partial.raw!
content_partial = (1..8).map { |i| "Line #{i}" }.join("\n")  # 8 lines, 5 rows, page size = 4 -> last page has 2 lines
More.more_content( content_partial, force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "", hot_keys = "", term_partial )
term_partial.cooked!
 
# 6. Hot keys match (set hot_keys to "x" and send "x")
term_hot = make_term( "x", 5, 80 )
term_hot.raw!
More.more_content( "line", force_quit = true, no_help = true, line_numbers = false, break_lines = false, title = "", hot_keys = "x", term_hot )
term_hot.cooked!
 
# 7. Test More.more method directly (file based)
term_more = make_term( "Gq", 10, 80 )
term_more.raw!
More.more( File.dirname( __FILE__ ) + '/../lib/more.rb', force_quit = true, no_help = true, line_numbers = false, term_more )
term_more.cooked!
 
# 8. Test up/down arrow keys and left/right (already in sequence 3 but ensure)
# Sequence already includes them.
 
# 9. Test Help with no_help false (triggers nested more)
term_help = make_term( "hqq", 10, 80 )
term_help.raw!
More.more_content( "abc", force_quit = true, no_help = false, line_numbers = false, break_lines = false, title = "", hot_keys = "", term_help,
                   File.expand_path( File.join( File.dirname( __FILE__ ), '..', 'doc', 'help_more.txt' ) ) )
term_help.cooked!
 
# ----------------------------------------------------------------------
# Additional tests to improve coverage
# ----------------------------------------------------------------------
 
# Helper: create a Term with simulated input and given dimensions
#def make_term(input, lines, cols)
#  Term.new(true, false, input, lines, cols)
#end
 
# 1. Cover blank filling when the last page has fewer lines than the page size.
content_6 = (1..6).map { |i| "L#{i}" }.join("\n")   # 6 lines
term_blank = make_term("\nq", 5, 80)               # lines=5 -> page size = 4 content lines
term_blank.raw!
More.more_content(content_6, force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_blank)
term_blank.cooked!
 
# 2. Cover line_start = 0 when backspace at top (line_start becomes negative)
term_back = make_term("\u007Fq", 5, 80)            # backspace then quit
term_back.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_back)
term_back.cooked!
 
# 3. Cover line_start = 0 for ESC[5~ (PGUP) at top
term_esc5 = make_term("\e[5~q", 5, 80)             # ESC[5~ then q
term_esc5.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_esc5)
term_esc5.cooked!
 
# 4. Cover numbers_width assignment when toggling line numbers from false to true
term_toggle = make_term("nq", 5, 80)               # press 'n' then 'q'
term_toggle.raw!
More.more_content("line", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_toggle)
term_toggle.cooked!
 
# ----------------------------------------------------------------------
# Additional branches coverage tests
# ----------------------------------------------------------------------
 
# 1. break_lines with short lines (so next_line.length <= local_columns)
term_short_break = make_term("q", 5, 40)
term_short_break.raw!
content_short_break = "short\nlines\n"
More.more_content(content_short_break, force_quit=true, no_help=true, line_numbers=false,
                  break_lines=true, title="", hot_keys="", term_short_break)
term_short_break.cooked!
 
# 2. Escape "O" followed by non-P (F2 etc.)
term_esc_o = make_term("\eOQq", 5, 80)
term_esc_o.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_esc_o)
term_esc_o.cooked!
 
# 3. Escape "[6" followed by non-~
term_esc_6 = make_term("\e[6xq", 5, 80)
term_esc_6.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_esc_6)
term_esc_6.cooked!
 
# 4. Escape "[5" followed by non-~
term_esc_5 = make_term("\e[5xq", 5, 80)
term_esc_5.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_esc_5)
term_esc_5.cooked!
 
# 5. UP arrow when line_start = 0 (condition false)
term_up_zero = make_term("\e[Aq", 5, 80)
term_up_zero.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_up_zero)
term_up_zero.cooked!
 
# 6. DOWN arrow when line_start at maximum (condition false)
# First go to last page with 'G', then press DOWN
term_down_max = make_term("G\e[Bq", 5, 80)
term_down_max.raw!
content_down_max = (1..20).map { |i| "Line #{i}" }.join("\n")
More.more_content(content_down_max, force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_down_max)
term_down_max.cooked!
 
# 7. Escape "[" followed by unhandled key (triggers else of elsif c == "B")
term_unhandled_after = make_term("\e[Xq", 5, 80)
term_unhandled_after.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_unhandled_after)
term_unhandled_after.cooked!
 
# 8. Escape followed by unhandled char (not "O" or "[") – covers else of elsif c == "["
term_unhandled_escape = make_term("\eZq", 5, 80)
term_unhandled_escape.raw!
More.more_content("hello", force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_unhandled_escape)
term_unhandled_escape.cooked!
 
# 9. LINEEND with long content so left_start is not negative (else branch)
term_lineend_positive = make_term("Aq", 5, 20)   # columns small so max_length > cols
term_lineend_positive.raw!
content_lineend_positive = (1..50).map { |i| "Line #{i} " + "z" * 30 }.join("\n")
More.more_content(content_lineend_positive, force_quit=true, no_help=true, line_numbers=false,
                  break_lines=false, title="", hot_keys="", term_lineend_positive)
term_lineend_positive.cooked!
 
CoverageChecker.verify( "more.rb", __FILE__ )
 
# End of: test_more_coverage.rb
EOT
 
32. Release History
-------------------
 
First public release on 2026-08-23.