aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--challenge-286/ppentchev/.editorconfig39
-rw-r--r--challenge-286/ppentchev/.gitignore8
-rw-r--r--challenge-286/ppentchev/LICENSES/BSD-2-Clause.txt9
-rw-r--r--challenge-286/ppentchev/README110
-rw-r--r--challenge-286/ppentchev/docs/index.md139
-rw-r--r--challenge-286/ppentchev/mkdocs.yml48
-rwxr-xr-xchallenge-286/ppentchev/perl/scripts/ch-1.pl57
-rwxr-xr-xchallenge-286/ppentchev/perl/scripts/ch-2.pl82
-rw-r--r--challenge-286/ppentchev/pyproject.toml17
-rw-r--r--challenge-286/ppentchev/requirements/docs.txt7
-rw-r--r--challenge-286/ppentchev/tests/01-perl-ch-1.t21
-rw-r--r--challenge-286/ppentchev/tests/02-perl-ch-2.t30
-rw-r--r--challenge-286/ppentchev/tests/lib/PWCTest/Ch286.pm146
-rw-r--r--challenge-286/ppentchev/tox.ini28
14 files changed, 740 insertions, 1 deletions
diff --git a/challenge-286/ppentchev/.editorconfig b/challenge-286/ppentchev/.editorconfig
new file mode 100644
index 0000000000..e7e09c387d
--- /dev/null
+++ b/challenge-286/ppentchev/.editorconfig
@@ -0,0 +1,39 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# https://editorconfig.org/
+
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+
+[*.md]
+indent_style = space
+indent_size = 2
+
+[*.pl]
+indent_style = tab
+indent_size = 4
+
+[*.pm]
+indent_style = tab
+indent_size = 4
+
+[*.t]
+indent_style = space
+indent_size = 4
+
+[*.toml]
+indent_style = space
+indent_size = 2
+
+[*.yml]
+indent_style = space
+indent_size = 2
+
+[{README,tox.ini}]
+indent_style = space
+indent_size = 2
diff --git a/challenge-286/ppentchev/.gitignore b/challenge-286/ppentchev/.gitignore
new file mode 100644
index 0000000000..cafbf443b5
--- /dev/null
+++ b/challenge-286/ppentchev/.gitignore
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+site/
+.tox/
+
+**/__pycache__/
+**/.mypy_cache/
diff --git a/challenge-286/ppentchev/LICENSES/BSD-2-Clause.txt b/challenge-286/ppentchev/LICENSES/BSD-2-Clause.txt
new file mode 100644
index 0000000000..5f662b354c
--- /dev/null
+++ b/challenge-286/ppentchev/LICENSES/BSD-2-Clause.txt
@@ -0,0 +1,9 @@
+Copyright (c) <year> <owner>
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/challenge-286/ppentchev/README b/challenge-286/ppentchev/README
index 9509eec3f7..1ceb3b34df 100644
--- a/challenge-286/ppentchev/README
+++ b/challenge-286/ppentchev/README
@@ -1 +1,109 @@
-Solutions by Peter Pentchev.
+<!--
+SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+SPDX-License-Identifier: BSD-2-Clause
+-->
+
+# Parse a mini-language for selecting objects by tag or name
+
+\[[Home][ringlet-parse-stages] | [GitLab][gitlab] | [PyPI][pypi] | [ReadTheDocs][readthedocs]\]
+
+## Overview
+
+The `parse-stages` Python library may be used by other tools to group
+objects (e.g. [Tox] or [Nox] test environments) for step-by-step processing
+(e.g. running some tests in parallel, then only running others if the first
+group passes).
+
+The language used by the library is described in the
+[_Grouping stages_](language.md#grouping-stages-for-step-by-step-execution)
+section.
+
+[tox]: https://tox.wiki/en/latest/ "The tox automation project"
+[nox]: https://nox.thea.codes/en/stable/ "The nox flexible automation tool"
+
+## Installation
+
+A program that uses the `parse-stages` library should specify it in
+its list of requirements, e.g. using [PEP508][pep508] syntax:
+
+ parse-stages >= 0.1.9, < 0.2
+
+[pep508]: https://peps.python.org/pep-0508/ "PEP 508 – Dependency specification for Python Software Packages"
+
+## Parsing a stage specification
+
+The `parse_spec()` function parses a string specification into
+a [BoolExpr][parse_stages.BoolExpr] object that may later be used to
+select matching objects (e.g. test environments).
+
+``` py
+ e_check = parse_stages.parse_spec("@check")
+ e_check_quick = parse_stages.parse_spec("@check and @quick")
+ e_check_no_ruff = parse_stages.parse_spec("@check and not ruff")
+
+ specs = [(spec, parse_stages.parse_spec(spec)) for spec in args.stage_specs]
+```
+
+## Check whether an object matches a parsed specification
+
+The `parse-stages` library provides two base dataclasses for objects that
+may be matched against parsed expressions:
+[TaggedFrozen][parse_stages.TaggedFrozen] and [Tagged][parse_stages.Tagged].
+Both classes have the same members:
+
+- [name][parse_stages.TaggedFrozen.name]: a string
+- [tags][parse_stages.TaggedFrozen.tags]: a list of strings
+- [get_keyword_haystacks()][parse_stages.TaggedFrozen.get_keyword_haystacks]:
+ a method that returns a list of strings,
+ `self.name` unless overridden
+
+When a `BoolExpr` object's [evaluate()][parse_stages.BoolExpr.evaluate]
+method is called for a specific
+`TaggedFrozen` or `Tagged` object, it checks whether the specification
+matches the tags and keywords defined for this object. Tags are matched
+exactly, while a keyword is considered to match if it is contained in
+the checked string; e.g. `pep` would match both `pep8` and `exp_pep563`,
+while `@black` would not match a `black-reformat` tag.
+
+The `get_keyword_haystacks()` method returns the strings to look in for
+matching keywords. By default, it only returns the `name` field;
+however, it may be extended, e.g. for Nox sessions it may also return
+the name of the Python function that implements the session, for test
+classes with methods it may return the class name and the method name, etc.
+
+``` py
+ # Obtain a list (okay, a dictionary) of test environments in some way
+ tox_envs = get_tox_environments() # {"ruff": {"tags": ["check", "quick"]}, ...}
+
+ # Convert them to objects that the parsed expressions can match
+ all_envs = [
+ parse_stages.TaggedFrozen(name, env["tags"])
+ for name, env in tox_envs.items()
+ ]
+
+ # Or define our own class that may hold additional information
+ @dataclasses.dataclass(frozen=True)
+ class TestEnv(parse_stages.TaggedFrozen):
+ """A single test environment: name, tags, etc."""
+ ...
+
+ all_envs = [TestEnv(name, env["tags"], ...) for name, env in tox_envs.items()]
+
+ # Select the ones that match the "@check" expression
+ matched = [env for env in all_envs if e_check.evaluate(env)]
+
+ # Or if we only care about the names...
+ quick_names = [env.name for env in all_envs if e_check_quick.evaluate(env)]
+```
+
+## Contact
+
+The `parse-stages` library was written by [Peter Pentchev][roam].
+It is developed in [a GitLab repository][gitlab]. This documentation is
+hosted at [Ringlet][ringlet-parse-stages] with a copy at [ReadTheDocs][readthedocs].
+
+[roam]: mailto:roam@ringlet.net "Peter Pentchev"
+[gitlab]: https://gitlab.com/ppentchev/parse-stages "The parse-stages GitLab repository"
+[pypi]: https://pypi.org/project/parse-stages/ "The parse-stages Python Package Index page"
+[readthedocs]: https://parse-stages.readthedocs.io/ "The parse-stages ReadTheDocs page"
+[ringlet-parse-stages]: https://devel.ringlet.net/devel/parse-stages/ "The Ringlet parse-stages homepage"
diff --git a/challenge-286/ppentchev/docs/index.md b/challenge-286/ppentchev/docs/index.md
new file mode 100644
index 0000000000..1a1622a804
--- /dev/null
+++ b/challenge-286/ppentchev/docs/index.md
@@ -0,0 +1,139 @@
+<!--
+SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+SPDX-License-Identifier: BSD-2-Clause
+-->
+
+# Peter Pentchev's solutions to PWC 286
+
+\[[Home][ringlet-home] | [GitHub][github]
+
+## Overview
+
+This directory contains Peter Pentchev's solutions to the two tasks in
+the [Perl & Raku Weekly Challenge 286][pwc-286].
+
+## General remarks
+
+### Task 1: Self Spammer
+
+Most programming languages have (different or the same) string-processing
+functions that can:
+
+- read a file and split it into lines at the same time
+- split the whole contents read from a file into lines
+- split a string by some commonly-accepted set of whitespace characters
+
+The tricky part here is finding the program source.
+In most of the so-called "interpreted" languages, e.g. Perl, Python, Ruby et al,
+there is either a file-global variable or a function that one can call to
+obtain the path to the file containing this particular source code line
+(note that I put "interpreted" in quotes, since it is common for these languages to
+do a precompilation phase to some intermediate representation, e.g. byte-code).
+For compiled languages such as Rust, Go, C, C++, etc., this part may be a bit
+more difficult since the end result does not generally include the program source code,
+so this part may have to depend on some specifics of the build environment and
+only be capable to run directly from a build directory near to its source code.
+
+### Task 2: Order Game
+
+There are several approaches to solving this problem:
+
+- trivial: take a pair of numbers, flip a "do we want the smaller or the larger one" flag,
+ produce the corresponding result, move on to the next pair
+- keep this information in an object, feed it numbers one by one or in pairs, let it
+ update its inner state (or, if it is an immutable object, let it produce a new one with
+ the inner state updated)
+- keep this information in an object, feed it number by number or the whole sequence,
+ have it work as an iterator that produces numbers for the programming language's
+ constructs to consume
+
+Some languages have built-in constructs for dealing with iterators, others can
+handle immutable objects easily and efficiently, so the approach depends on
+the programming language.
+
+## Running the test suite
+
+The `tests/` subdirectory contains a Perl test suite that outputs TAP, so that
+it can be run using `prove tests` (or, for the more adventurous, `prove -v tests`).
+
+### Task 1: Self Spammer
+
+This task does not involve any input data (except for the program source itself),
+so the programs are executed without any parameters.
+
+The `PWCTest::Ch286` module in `tests/lib/` defines a `test_self_spammer` function that
+runs the specified program, checks whether it output a single line containing a single word, and
+then looks for that word in the specified source file (the program itself by default).
+Of course, for the Perl implementation there is a bit of a chicken-and-egg problem, since
+the test itself must implement the same algorithm to check whether the word that
+the program output is actually present as a separate word in the source file.
+I played around a bit with the idea of using an external `grep` program to look for
+the word, but it turns out that at least GNU grep has some... idionsyncrasies regarding
+character sequences like `\<`, `\>`, `'')`, etc, so the output of Perl's `quotemeta`
+function was not suitable for feeding to `grep` directly.
+Eh, let's hope my solution is correct anyway :)
+
+### Task 2: Order Game
+
+This task takes some input, so there are two ways of running the program:
+
+- if the `PWC_FROM_STDIN` environment variable is not set to the exact value `1`,
+ the program runs the three sequences given as examples, and produces three numbers on
+ its standard output stream, each one on a line by itself.
+ In other words, the program must output `1\n0\n2\n` exactly.
+- if the `PWC_FROM_STDIN` environment variable is set, the program reads a single line of
+ text from its standard input, treats it as a sequence of decimal integers separated by
+ one or more whitespace characters, runs the order game on that sequence, and produces
+ a single number on a line by itself on its standard output stream.
+
+The `PWCTest::Ch286` module in `tests/lib/` defines a `test_order_game_default` function that
+runs a program with `PWC_FROM_STDIN` unset and expects the exact output, and also
+a `test_order_game` function that runs a series of tests with different sequences,
+each time running the program with `PWC_FROM_STDIN` set to 1 and feeding it the sequence.
+
+If the implementation in any language should provide more than one method, then
+the program should honor the `PWC_METHOD` environment variable.
+The value "0" indicates the use of the most natural method for the language,
+the value "1" indicates the use of an alternative method, and if there are more than two,
+then the values "2", "3", etc, are used to select them.
+If `PWC_METHOD` is set to a non-numeric value or to a value that is higher than
+the index of the last supported methods, it is ignored and the program proceeds as if
+`PWC_METHOD` was set to "0".
+
+The `tests/02-perl-ch-2.t` test runs these functions on the Perl implementation and
+produces TAP output suitable for the `prove` tool.
+
+## Implementation details
+
+## Task 1: Self Spammer
+
+### Perl
+
+We use the [FindBin core module][perl-findbin] and its `$Bin` and `$Script` variables to
+figure out where the program source is.
+
+## Task 2: Order Game
+
+The Perl solution has three major functions:
+
+- `round_trivial` - run a single round on a list, producing a list with half the number of
+ elements using the trivial approach: take numbers in pairs, keep a flag, produce
+ the smaller or the larger one as the flag says, flip it.
+- `run_order_game` - run the whole order game on the specified sequence of numbers.
+ This function currently only uses a single "run a round" implementation, `round_trivial`.
+ The `PWC_METHOD` environment variable is ignored even if set.
+- `parse_stdin` - read a line of numbers from the standard input, break it down into
+ a list of integers.
+
+## Contact
+
+These solutions were written by [Peter Pentchev][roam].
+They are developed in [the common PWC-club GitHub repository][github].
+This documentation is hosted at [Ringlet][ringlet-home].
+
+[roam]: mailto:roam@ringlet.net "Peter Pentchev"
+[github]: https://github.com/manwar/perlweeklychallenge-club/tree/master/challenge-286/ppentchev "These solutions at GitHub"
+[ringlet-home]: https://devel.ringlet.net/misc/perlweeklychallenge-club/286/ "This documentation at Ringlet"
+
+[perl-findbin]: https://perldoc.perl.org/FindBin "The FindBin Perl core module"
+[pwc-286]: https://theweeklychallenge.org/blog/perl-weekly-challenge-286/ "The 286th Perl & Raku Weekly Challenge"
diff --git a/challenge-286/ppentchev/mkdocs.yml b/challenge-286/ppentchev/mkdocs.yml
new file mode 100644
index 0000000000..8848957f9c
--- /dev/null
+++ b/challenge-286/ppentchev/mkdocs.yml
@@ -0,0 +1,48 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+theme:
+ name: material
+ features:
+ - navigation.instant
+ - navigation.tracking
+ - toc.integrate
+ - toc.follow
+ - content.code.copy
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ toggle:
+ icon: material/weather-night
+ name: Switch to light mode
+site_name: pwc-286-ppentchev
+repo_url: https://github.com/manwar/perlweeklychallenge-club/tree/master/challenge-286/ppentchev
+repo_name: perlweeklychallenge-club
+site_author: ppentchev
+site_url: https://devel.ringlet.net/misc/perlweeklychallenge-club/286/
+site_dir: site/docs
+nav:
+ - 'index.md'
+markdown_extensions:
+ - toc:
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.inlinehilite:
+ - pymdownx.superfences:
+plugins:
+ - mkdocstrings:
+ default_handler: python
+ handlers:
+ python:
+ paths: [python/src]
+ options:
+ heading_level: 3
+ show_root_heading: true
+ - search
+# watch:
+# - 'src/parse_stages'
diff --git a/challenge-286/ppentchev/perl/scripts/ch-1.pl b/challenge-286/ppentchev/perl/scripts/ch-1.pl
new file mode 100755
index 0000000000..35fb054f4d
--- /dev/null
+++ b/challenge-286/ppentchev/perl/scripts/ch-1.pl
@@ -0,0 +1,57 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+use v5.16;
+use strict;
+use warnings;
+
+use FindBin;
+
+use constant PROG => "$FindBin::Bin/$FindBin::RealScript";
+use constant HASHBANG => '#!/usr/bin/perl';
+use constant PWC_QUIET => ($ENV{PWC_QUIET} // '') eq 1;
+
+sub diag($) {
+ say STDERR $_[0] unless PWC_QUIET;
+}
+
+sub solve_self_spammer(;$) {
+ my ($prog) = @_;
+
+ $prog //= PROG;
+
+ diag "About to examine $prog";
+ my $contents = do {
+ open my $src, '<', $prog or die "Could not open $prog for reading: $!\n";
+ my $contents = do {
+ local $/;
+ <$src>
+ };
+ close $src or die "Could not read all of $prog: $!\n";
+
+ # A quick sanity check
+ if (substr($contents, length(HASHBANG) + 1) != HASHBANG."\n") {
+ die "Unexpected first line in $prog, expected ".HASHBANG."\n";
+ }
+
+ # Seems fine
+ $contents
+ };
+ diag "Read ".length($contents)." characters from $prog";
+
+ my @words = split /\s+/, $contents;
+ my $index = int rand @words;
+ my $chosen_one = $words[$index];
+ if ($chosen_one eq '') {
+ use Data::Dumper;
+
+ die "Empty word at index $index\n".Dumper($contents, \@words);
+ }
+ say "$words[$index]";
+}
+
+MAIN:
+{
+ solve_self_spammer();
+}
diff --git a/challenge-286/ppentchev/perl/scripts/ch-2.pl b/challenge-286/ppentchev/perl/scripts/ch-2.pl
new file mode 100755
index 0000000000..be6eaab66b
--- /dev/null
+++ b/challenge-286/ppentchev/perl/scripts/ch-2.pl
@@ -0,0 +1,82 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+use v5.16;
+use strict;
+use warnings;
+
+use List::Util qw(min max pairs);
+
+use constant PWC_QUIET => ($ENV{PWC_QUIET} // '') eq 1;
+
+my @TEST_SEQUENCES = (
+ [2, 1, 4, 5, 6, 3, 0, 2],
+ [0, 5, 3, 2],
+ [9, 2, 1, 4, 5, 6, 0, 7, 3, 1, 3, 5, 7, 9, 0, 8],
+);
+
+my $re_int = qr{^ (?P<value> -? (?: 0 | [1-9][0-9]* ) ) $}x;
+
+sub diag($) {
+ say STDERR $_[0] unless PWC_QUIET;
+}
+
+sub round_trivial(@)
+{
+ my (@ints) = @_;
+
+ diag "- round_trivial() invoked for @ints";
+ my @res;
+ my $use_max = 0;
+ for my $pair (pairs @ints) {
+ my $winner = $use_max ? max @{$pair} : min @{$pair};
+ push @res, $winner;
+ $use_max = !$use_max;
+ }
+ @res
+}
+
+sub run_order_game(@)
+{
+ my (@ints) = @_;
+
+ diag "run_order_game() invoked for ".scalar(@ints)." numbers: @ints";
+ die 'run_order_game() expects at least one integer in the list' unless @ints;
+ while (@ints > 1) {
+ @ints = round_trivial @ints;
+ }
+ $ints[0]
+}
+
+sub parse_stdin()
+{
+ my $line = <STDIN>;
+ if (!defined $line) {
+ die "Could not read a line of space-separated numbers from the standard input\n";
+ }
+ chomp $line;
+
+ my @res;
+ for my $word (split /\s+/, $line) {
+ if ($word !~ $re_int) {
+ die "Not an integer: '$word'\n";
+ }
+ push @res, +$+{value};
+ }
+ @res
+}
+
+MAIN:
+{
+ if (($ENV{PWC_FROM_STDIN} // '') eq '1') {
+ my @ints = parse_stdin;
+ my $result = run_order_game @ints;
+ say $result;
+ } else {
+ for my $seq (@TEST_SEQUENCES) {
+ my $result = run_order_game @{$seq};
+ say $result;
+ }
+ }
+}
diff --git a/challenge-286/ppentchev/pyproject.toml b/challenge-286/ppentchev/pyproject.toml
new file mode 100644
index 0000000000..70389829d7
--- /dev/null
+++ b/challenge-286/ppentchev/pyproject.toml
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+[tool.publync.format.version]
+major = 0
+minor = 1
+
+[tool.publync.build.tox]
+
+[tool.publync.sync.rsync]
+remote = "marla.ludost.net:vhosts/devel.ringlet.net/public_html/misc/perlweeklychallenge-club/286"
+
+[tool.test-stages]
+stages = [
+ "(@check or @docs) and @quick and not @manual",
+ "(@check or @docs) and not @manual",
+]
diff --git a/challenge-286/ppentchev/requirements/docs.txt b/challenge-286/ppentchev/requirements/docs.txt
new file mode 100644
index 0000000000..d62a6241d0
--- /dev/null
+++ b/challenge-286/ppentchev/requirements/docs.txt
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+mkdocs >= 1.4.2, < 2
+mkdocs-material >= 9.1.2, < 10
+mkdocstrings >= 0.25, < 0.26
+mkdocstrings-python >= 1, < 2
diff --git a/challenge-286/ppentchev/tests/01-perl-ch-1.t b/challenge-286/ppentchev/tests/01-perl-ch-1.t
new file mode 100644
index 0000000000..deec7c59f4
--- /dev/null
+++ b/challenge-286/ppentchev/tests/01-perl-ch-1.t
@@ -0,0 +1,21 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+use v5.16;
+use strict;
+use warnings;
+
+use Test::More;
+
+use lib 'tests/lib';
+
+use PWCTest::Ch286 qw(test_self_spammer);
+
+use constant PROG => 'perl/scripts/ch-1.pl';
+
+plan tests => 1;
+
+subtest self_spammer => sub {
+ test_self_spammer PROG;
+};
diff --git a/challenge-286/ppentchev/tests/02-perl-ch-2.t b/challenge-286/ppentchev/tests/02-perl-ch-2.t
new file mode 100644
index 0000000000..f68867b7d2
--- /dev/null
+++ b/challenge-286/ppentchev/tests/02-perl-ch-2.t
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+use v5.16;
+use strict;
+use warnings;
+
+use Test::More;
+
+use lib 'tests/lib';
+
+use PWCTest::Ch286 qw(test_order_game test_order_game_count test_order_game_default);
+
+use constant PROG => 'perl/scripts/ch-2.pl';
+
+plan tests => 2;
+
+subtest order_game_default => sub {
+ test_order_game_default PROG;
+};
+
+subtest order_game => sub {
+ plan tests => test_order_game_count;
+ for my $idx (1..test_order_game_count) {
+ subtest "run $idx" => sub {
+ test_order_game PROG, $idx - 1;
+ };
+ }
+};
diff --git a/challenge-286/ppentchev/tests/lib/PWCTest/Ch286.pm b/challenge-286/ppentchev/tests/lib/PWCTest/Ch286.pm
new file mode 100644
index 0000000000..31bbdc6000
--- /dev/null
+++ b/challenge-286/ppentchev/tests/lib/PWCTest/Ch286.pm
@@ -0,0 +1,146 @@
+#!/usr/bin/perl
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+use v5.16;
+use strict;
+use warnings;
+
+package PWCTest::Ch286;
+
+use v5.16;
+use strict;
+use warnings;
+
+use Exporter qw(import);
+use POSIX qw(dup2);
+use Socket qw(AF_UNIX SOCK_STREAM);
+use Test::Command qw();
+use Test::More;
+
+our @EXPORT_OK = qw(
+ test_order_game
+ test_order_game_count
+ test_order_game_default
+ test_self_spammer
+);
+
+use constant NUM_TESTS => 100;
+
+# The last three of these are the example sequences from task 2.
+# The rest are simple tests to make sure the order of numbers within the pairs does not matter.
+my @TEST_ORDER_SEQUENCES = (
+ [[1, 2], 1],
+ [[2, 1], 1],
+ [[1, 2, 3, 4], 1],
+ [[2, 1, 4, 3], 1],
+ [[4, 3, 2, 1], 2],
+ [[3, 4, 1, 2], 2],
+
+ [[2, 1, 4, 5, 6, 3, 0, 2], 1],
+ [[0, 5, 3, 2], 0],
+ [[9, 2, 1, 4, 5, 6, 0, 7, 3, 1, 3, 5, 7, 9, 0, 8], 2],
+);
+
+my $re_single_line = qr{^ (?<word> [^\n\s]+ ) \n $}x;
+
+sub test_self_spammer($;$) {
+ my ($prog, $source) = @_;
+ $source //= $prog;
+
+ # So... to test this, we basically have to implement it, right?
+ my @words = do {
+ open my $inf, '<', $source or die "Could not open $source: $!\n";
+ my $contents = do {
+ local $/;
+ <$inf>
+ };
+ close $inf or die "Could not read the whole of $source: $!\n";
+ split /\s+/, $contents;
+ };
+
+ plan tests => 4 * NUM_TESTS;
+
+ for my $run (1..NUM_TESTS) {
+ diag "About to run $prog";
+ my $cmd = Test::Command->new(cmd => [$prog]);
+ $cmd->exit_is_num(0, "$prog exited with code 0");
+ $cmd->stdout_isnt_eq('', "$prog output something");
+
+ SKIP:
+ {
+ my $output = $cmd->stdout_value;
+ if ($output !~ $re_single_line) {
+ fail "$prog output a single line containing a single word";
+ skip 1, "no program output to look for in $source";
+ return;
+ }
+ pass "$prog output a single line containing a single word";
+
+ my $word = $+{word};
+ diag "Looking for '$word' in $source";
+ ok grep { $_ eq $word } @words, "'$word' is present in $source";
+ }
+ }
+}
+
+sub test_order_game_default($) {
+ my ($prog) = @_;
+
+ plan tests => 2;
+
+ my $cmd = Test::Command->new(cmd => ['env', 'PWC_FROM_STDIN=', $prog]);
+ $cmd->exit_is_num(0, "$prog exited with code 0");
+ $cmd->stdout_is_eq("1\n0\n2\n", "$prog produced the correct output in autotest mode");
+}
+
+sub test_order_game_count() {
+ scalar @TEST_ORDER_SEQUENCES
+}
+
+sub test_order_game($ $) {
+ my ($prog, $idx) = @_;
+
+ plan tests => 2;
+
+ my ($seq, $expected) = @{$TEST_ORDER_SEQUENCES[$idx]};
+ my $joined = join ' ', @{$seq};
+
+ # OK, so Test::Command cannot handle this one; let's do it ourselves
+ socketpair(my $parent_in, my $child_in, AF_UNIX, SOCK_STREAM, 0) or
+ die "Could not create the stdin socket pair: $!\n";
+ socketpair(my $child_out, my $parent_out, AF_UNIX, SOCK_STREAM, 0) or
+ die "Could not create the stdout socket pair: $!\n";
+ my $pid = fork();
+ if (!defined $pid) {
+ die "Could not fork for $prog: $!\n";
+ } elsif ($pid == 0) {
+ close $parent_in or die "Child: could not close parent_in: $!\n";
+ close $parent_out or die "Child: could not close parent_out: $!\n";
+ dup2(fileno $child_in, 0) or die "Child: could not dup2 child_in onto stdin: $!\n";
+ dup2(fileno $child_out, 1) or die "Child: could not dup2 child_out onto stdout: $!\n";
+
+ $ENV{PWC_FROM_STDIN} = '1';
+ exec { $prog } $prog;
+ die "Child: could not execute $prog: $!\n";
+ }
+
+ close $child_in or die "Parent: could not close child_in: $!\n";
+ close $child_out or die "Parent: could not close child_out: $!\n";
+
+ say $parent_in $joined or die "Parent: could not write to the child: $!\n";
+ close $parent_in or die "Parent: could not close parent_in: $!\n";
+
+ my $line = <$parent_out>;
+ close $parent_out or die "Parent: could not close parent_out: $!\n";
+ is $line, "$expected\n", "$prog produced the correct output";
+ my $awaited_pid = waitpid $pid, 0;
+ if (!defined $awaited_pid) {
+ die "Parent: could not wait for child pid $pid: $!\n";
+ } elsif ($awaited_pid != $pid) {
+ die "Parent: waited for pid $pid, yet got status $? for pid $awaited_pid\n";
+ }
+ is $?, 0, "$prog exited with code 0";
+}
+
+1;
diff --git a/challenge-286/ppentchev/tox.ini b/challenge-286/ppentchev/tox.ini
new file mode 100644
index 0000000000..1e7a5a4227
--- /dev/null
+++ b/challenge-286/ppentchev/tox.ini
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
+# SPDX-License-Identifier: BSD-2-Clause
+
+[tox]
+minversion = 4.1
+envlist =
+ reuse
+ docs
+isolated_build = True
+
+[testenv:docs]
+skip_install = True
+tags =
+ docs
+deps =
+ -r requirements/docs.txt
+commands =
+ mkdocs build
+
+[testenv:reuse]
+skip_install = True
+tags =
+ check
+ quick
+deps =
+ reuse >= 4, < 5
+commands =
+ reuse --root . lint