The first real plan filed 1,106 chapters from 14 unrelated top-level folders onto one shelf, because all 1,106 carry the album tag [Unknown Album] and the same artist tag. Plex's album title is the authority for the shelf only when it names a book; a placeholder names nothing, and unrelated books share it. Those chapters now park with the path they have today, timestamp-glued variants included; Various Artists stays a legitimate multi-cast narrator.
425 lines
17 KiB
Bash
Executable File
425 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# Seam: the audiobook-library relayout — scripts/media-census.py audiobooks (the
|
|
# export of the rows Plex matched) and scripts/media-audiobooks.py (the plan).
|
|
#
|
|
# The audiobook tree is the one library on this box that Jellyfin does not read
|
|
# at all and Plex reads completely: every chapter here is a Plex Track row under
|
|
# an Album row (the BOOK) under one Artist row (the NARRATOR). That is the
|
|
# authority this seam files by — never the collection folder a release pack made.
|
|
#
|
|
# Two laws here are the opposite of the music tool's, and both come from what the
|
|
# rows actually contain. Files are NEVER renumbered: 7,556 of 10,485 books carry
|
|
# at least one chapter whose index Plex does not know, so a `00 -` invented from
|
|
# a missing index would order a book wrong, and playback order comes from the
|
|
# embedded track tag either way — the name buys nothing and can only lie. And the
|
|
# folder artist is the NARRATOR even though a reader thinks in authors: every
|
|
# book has exactly one artist row, while the author survives only in 10% of the
|
|
# album titles (`Kryptos by Graham McNeill`) and in no column Plex stores, so one
|
|
# rule beats a shelf that is half authors and half narrators.
|
|
#
|
|
# Every test states which authority a file has and asserts where the PLAN files
|
|
# it, never how the planner decided.
|
|
|
|
load helpers
|
|
|
|
PLAN="$REPO_ROOT/scripts/media-audiobooks.py"
|
|
CENSUS="$REPO_ROOT/scripts/media-census.py"
|
|
APPLY="$REPO_ROOT/scripts/media-apply.py"
|
|
|
|
# at PATH SIZE — a file with deterministic content.
|
|
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
|
|
|
|
# row FILE NARRATOR BOOK YEAR TRACK CHAPTER
|
|
# One row of the authority export scripts/media-census.py audiobooks writes.
|
|
row() {
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$1" "$2" "$3" "${4:-}" "${5:-}" "$6" >> "$ROWS"
|
|
}
|
|
|
|
# dst SRC — the destination the plan gives SRC, or nothing when it does not move.
|
|
dst() {
|
|
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
|
|
}
|
|
|
|
# kind SRC — the kind the plan gave SRC's move row.
|
|
kind() {
|
|
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $4}' "$OUT"
|
|
}
|
|
|
|
setup() {
|
|
AB="$BATS_TEST_TMPDIR/Audiobooks"
|
|
PARK="$AB/_unsorted"
|
|
ENUM="$BATS_TEST_TMPDIR/enum.tsv"
|
|
ROWS="$BATS_TEST_TMPDIR/rows.tsv"
|
|
OUT="$BATS_TEST_TMPDIR/plan.tsv"
|
|
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
|
|
# The header is part of the authority export, so the fixture carries it.
|
|
printf 'file\tnarrator\tbook\tyear\ttrack\tchapter\n' > "$ROWS"
|
|
|
|
# One book of one author, split across the two part folders a pack shipped it
|
|
# in, with a cover above part 1 and a playlist beside the chapters.
|
|
A="$AB/Stephen King/It (1986)"
|
|
at "$A [Part 1]/01 - Chapter 1.mp3" 9000
|
|
at "$A [Part 1]/02 - Chapter 2.mp3" 9000
|
|
at "$A [Part 1]/cover.jpg" 20
|
|
at "$A [Part 1]/It.m3u" 30
|
|
at "$A [Part 2]/03 - Chapter 3.mp3" 9000
|
|
row "$A [Part 1]/01 - Chapter 1.mp3" "George Guidall" "It" 1986 1 "Chapter 1"
|
|
row "$A [Part 1]/02 - Chapter 2.mp3" "George Guidall" "It" 1986 2 "Chapter 2"
|
|
row "$A [Part 2]/03 - Chapter 3.mp3" "George Guidall" "It" 1986 3 "Chapter 3"
|
|
|
|
# A book whose album row carries no year at all.
|
|
B="$AB/Collected/Project Gold"
|
|
at "$B/01.ogg" 9000
|
|
row "$B/01.ogg" "Operator X" "Project Gold" "" 1 "Opening"
|
|
|
|
# One book title, two narrators: two different books.
|
|
C="$AB/Shared"
|
|
at "$C/Dune side a.m4b" 9000
|
|
at "$C/Dune side b.m4b" 9000
|
|
at "$C/Dune 2020.m4b" 9000
|
|
row "$C/Dune side a.m4b" "Scott Brick" "Dune" 1965 1 "Prologue"
|
|
row "$C/Dune side b.m4b" "Scott Brick" "Dune" 1965 2 "Dune"
|
|
row "$C/Dune 2020.m4b" "Malcolm Hillgartner" "Dune" 2020 1 "Book One"
|
|
|
|
# Two part folders of one book whose chapters share a file name.
|
|
D="$AB/Pulp"
|
|
at "$D/Book A/vol1.mp3" 9000
|
|
at "$D/Book A vol2/vol1.mp3" 9000
|
|
row "$D/Book A/vol1.mp3" "Nora Pace" "Hard Food" 2001 1 "Vol 1"
|
|
row "$D/Book A vol2/vol1.mp3" "Nora Pace" "Hard Food" 2001 2 "Vol 1"
|
|
|
|
# The same narrator spelled two ways across one book's chapters.
|
|
E="$AB/Spell"
|
|
at "$E/kharn 1.mp3" 9000
|
|
at "$E/kharn 2.mp3" 9000
|
|
at "$E/kharn 3.mp3" 9000
|
|
row "$E/kharn 1.mp3" "David Timson" "Kharn" 2013 1 "One"
|
|
row "$E/kharn 2.mp3" "David timson" "Kharn" 2013 2 "Two"
|
|
row "$E/kharn 3.mp3" "David Timson" "Kharn" 2013 3 "Three"
|
|
|
|
# A folder holding two different books: its loose note has no owner.
|
|
F="$AB/Mixed"
|
|
at "$F/book one chapter.mp3" 9000
|
|
at "$F/book two chapter.mp3" 9000
|
|
at "$F/notes.txt" 10
|
|
row "$F/book one chapter.mp3" "Ann Reader" "Book One" 2001 1 "One"
|
|
row "$F/book two chapter.mp3" "Bob Reader" "Book Two" 2002 1 "Two"
|
|
|
|
# A file already parked: the pile is for a human, so a row that would file it
|
|
# must not pull it back into the tree.
|
|
at "$PARK/Old/nobody.mp3" 9000
|
|
row "$PARK/Old/nobody.mp3" "Ann Reader" "Book One" 2001 1 "One"
|
|
|
|
# The odd cases: no row, a video container, a name carrying a character Plex
|
|
# says breaks playback, rows with a blank book and a blank narrator, and a
|
|
# matched chapter whose file has no extension.
|
|
G="$AB/Odd"
|
|
at "$G/loose.mp3" 9000
|
|
at "$G/concert.mp4" 9000
|
|
at "$G/Bad: Name/01 chapter.mp3" 9000
|
|
row "$G/Bad: Name/01 chapter.mp3" "Some Reader" "Trail: Marked" 1999 1 "Marked"
|
|
at "$G/blank book.mp3" 9000
|
|
row "$G/blank book.mp3" "Some Reader" "" 2000 1 "No Book"
|
|
at "$G/blank narrator.mp3" 9000
|
|
row "$G/blank narrator.mp3" "" "No Artist" 2000 1 "No Artist"
|
|
at "$G/NoExt/part01" 9000
|
|
row "$G/NoExt/part01" "Archivist" "Tapes" 1971 1 "Tape One"
|
|
|
|
# Tag-scan placeholders. Two unrelated books whose album rows both read
|
|
# `[Unknown Album]`: the name is shared, so filing on it would merge books
|
|
# that have nothing to do with each other. A rip timestamp can be glued to
|
|
# the placeholder, and a multi-cast read says `Various Artists` — which IS a
|
|
# narrator.
|
|
H="$AB/Packs"
|
|
at "$H/Stross/Ship/c1.mp3" 9000
|
|
at "$H/Leckie/State/c1.mp3" 9000
|
|
at "$H/Rip/tape.mp3" 9000
|
|
at "$H/NoWho/Real Book.mp3" 9000
|
|
at "$H/Multicast/Genesis.mp3" 9000
|
|
row "$H/Stross/Ship/c1.mp3" "Various Artists" "[Unknown Album]" "" 1 "Part 1"
|
|
row "$H/Leckie/State/c1.mp3" "Various Artists" "[Unknown Album]" "" 1 "Part 1"
|
|
row "$H/Rip/tape.mp3" "Ann Reader" "Unknown album (03/03/2015 21:39:55)" "" 1 "One"
|
|
row "$H/NoWho/Real Book.mp3" "[Unknown Artist]" "A Named Book" 2001 1 "One"
|
|
row "$H/Multicast/Genesis.mp3" "Various Artists" "The Bible" "" 1 "Genesis"
|
|
|
|
# The enum TSV is what scripts/media-census.py enumerate writes: bytes, ext,
|
|
# path, no header.
|
|
python3 - "$AB" "$ENUM" <<'PY'
|
|
import os, sys
|
|
root, out = sys.argv[1], sys.argv[2]
|
|
with open(out, "w") as fh:
|
|
for dirpath, _dirs, files in os.walk(root):
|
|
for fn in files:
|
|
p = os.path.join(dirpath, fn)
|
|
fh.write(f"{os.path.getsize(p)}\t{os.path.splitext(fn)[1].lower()}\t{p}\n")
|
|
PY
|
|
}
|
|
|
|
plan() {
|
|
run python3 "$PLAN" --root "$AB" --enum "$ENUM" --rows "$ROWS" \
|
|
--out "$OUT" --exceptions "$EXC"
|
|
if [ "$status" -ne 0 ]; then
|
|
echo "planner said: $output"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# db_with_part DB FILE NARRATOR BOOK YEAR TRACK CHAPTER — one Plex Track row
|
|
# under an Album under an Artist, in the three tables the export joins.
|
|
db_with_part() {
|
|
local db="$1" file="$2" narrator="$3" book="$4" year="$5" track="$6" chapter="$7"
|
|
local del="${8:-0}"
|
|
python3 - "$db" "$file" "$narrator" "$book" "$year" "$track" "$chapter" "$del" <<'PY'
|
|
import sqlite3, sys
|
|
db, file, narrator, book, year, track, chapter, deleted = sys.argv[1:9]
|
|
c = sqlite3.connect(db)
|
|
c.execute("create table if not exists media_parts (id integer primary key,"
|
|
" media_item_id, file, deleted_at)")
|
|
c.execute("create table if not exists media_items (id integer primary key,"
|
|
" metadata_item_id, deleted_at)")
|
|
c.execute("""create table if not exists metadata_items (id integer primary key,
|
|
parent_id, metadata_type, title, year, "index", deleted_at)""")
|
|
now = None if deleted == "0" else "2026-01-01"
|
|
ar = c.execute("insert into metadata_items (parent_id, metadata_type, title, year,"
|
|
" \"index\", deleted_at) VALUES (NULL, 8, ?, NULL, NULL, NULL)",
|
|
(narrator,)).lastrowid
|
|
al = c.execute("insert into metadata_items (parent_id, metadata_type, title, year,"
|
|
" \"index\", deleted_at) VALUES (?, 9, ?, ?, NULL, NULL)",
|
|
(ar, book, int(year) if year else None)).lastrowid
|
|
mi = c.execute("insert into metadata_items (parent_id, metadata_type, title, year,"
|
|
" \"index\", deleted_at) VALUES (?, 10, ?, NULL, ?, NULL)",
|
|
(al, chapter, int(track) if track else -1)).lastrowid
|
|
it = c.execute("insert into media_items (metadata_item_id, deleted_at) VALUES (?, NULL)",
|
|
(mi,)).lastrowid
|
|
c.execute("insert into media_parts (media_item_id, file, deleted_at) VALUES (?,?,?)",
|
|
(it, file, now))
|
|
c.commit()
|
|
PY
|
|
}
|
|
|
|
# ------------------------------------------------------------------ the export
|
|
|
|
@test "the census exports the narrator, book and chapter Plex matched for a part" {
|
|
DB="$BATS_TEST_TMPDIR/plex.db"
|
|
db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Pack/It part 1/01.mp3" \
|
|
"George Guidall" "It" 1986 1 "Chapter 1"
|
|
run python3 "$CENSUS" audiobooks --root "$BATS_TEST_TMPDIR/Audiobooks" --db "$DB" \
|
|
--out "$BATS_TEST_TMPDIR/exp.tsv"
|
|
[ "$status" -eq 0 ]
|
|
run head -1 "$BATS_TEST_TMPDIR/exp.tsv"
|
|
[ "${lines[0]}" = "file narrator book year track chapter" ]
|
|
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
|
|
| grep -q "^$BATS_TEST_TMPDIR/Audiobooks/Pack/It part 1/01\.mp3 George Guidall It 1986 1 Chapter 1$"
|
|
}
|
|
|
|
@test "the census exports nothing outside the root, and nothing Plex deleted" {
|
|
DB="$BATS_TEST_TMPDIR/plex.db"
|
|
db_with_part "$DB" "/mnt/other/Library/01.mp3" "Someone" "Elsewhere" 1990 1 "One"
|
|
db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Gone/01.mp3" "Someone" \
|
|
"Deleted" 1990 1 "One" 1
|
|
run python3 "$CENSUS" audiobooks --root "$BATS_TEST_TMPDIR/Audiobooks" --db "$DB" \
|
|
--out "$BATS_TEST_TMPDIR/exp.tsv"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only
|
|
}
|
|
|
|
# -------------------------------------------------------------------- the plan
|
|
|
|
@test "a matched chapter files under its narrator and its book, name untouched" {
|
|
plan
|
|
[ "$(dst "$A [Part 1]/01 - Chapter 1.mp3")" = \
|
|
"$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" ]
|
|
}
|
|
|
|
@test "one book arrives in one folder however many part folders it shipped in" {
|
|
plan
|
|
[ "$(dst "$A [Part 2]/03 - Chapter 3.mp3")" = \
|
|
"$AB/George Guidall/It (1986)/03 - Chapter 3.mp3" ]
|
|
[ "$(dst "$A [Part 1]/02 - Chapter 2.mp3")" = \
|
|
"$AB/George Guidall/It (1986)/02 - Chapter 2.mp3" ]
|
|
}
|
|
|
|
@test "a chapter is never renumbered, whether or not Plex knows its index" {
|
|
# The law this guards: playback order comes from the embedded track tag, and
|
|
# three quarters of the books have chapters with no index at all, so a
|
|
# number written into a name would be invented and would lie.
|
|
plan
|
|
[ "$(dst "$B/01.ogg")" = "$AB/Operator X/Project Gold/01.ogg" ]
|
|
[ "$(dst "$A [Part 1]/01 - Chapter 1.mp3")" = \
|
|
"$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" ]
|
|
}
|
|
|
|
@test "a book whose album row has no year gets no parentheses" {
|
|
plan
|
|
[ "$(dst "$B/01.ogg")" = "$AB/Operator X/Project Gold/01.ogg" ]
|
|
}
|
|
|
|
@test "two chapters claiming one name inside one book are named apart" {
|
|
plan
|
|
first="$(dst "$D/Book A/vol1.mp3")"
|
|
second="$(dst "$D/Book A vol2/vol1.mp3")"
|
|
[ "$first" != "$second" ]
|
|
[ -n "$first" ] && [ -n "$second" ]
|
|
# and the plan still passes the executor's preflight, which refuses a target
|
|
# two sources claim.
|
|
run python3 "$APPLY" apply --plan "$OUT" --root "$AB"
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" != *"double-claim"* ]]
|
|
}
|
|
|
|
@test "a cover follows the book whose folder it lies in" {
|
|
plan
|
|
[ "$(dst "$A [Part 1]/cover.jpg")" = "$AB/George Guidall/It (1986)/cover.jpg" ]
|
|
}
|
|
|
|
@test "a playlist moves with its book and its bytes are never rewritten" {
|
|
plan
|
|
[ "$(dst "$A [Part 1]/It.m3u")" = "$AB/George Guidall/It (1986)/It.m3u" ]
|
|
before=$(md5sum "$A [Part 1]/It.m3u" | cut -d" " -f1)
|
|
run python3 "$APPLY" apply --plan "$OUT" --root "$AB" --journal "$BATS_TEST_TMPDIR/j.jsonl" --apply
|
|
[ "$status" -eq 0 ]
|
|
[ "$(md5sum "$AB/George Guidall/It (1986)/It.m3u" | cut -d" " -f1)" = "$before" ]
|
|
# the stored paths inside it still name the old file names, which the tool
|
|
# leaves alone rather than editing a file the operator owns.
|
|
[ "$(kind "$A [Part 1]/It.m3u")" != "chapter" ]
|
|
}
|
|
|
|
@test "a note in a folder holding two books has no owner and parks" {
|
|
plan
|
|
[ "$(kind "$F/notes.txt")" = "park" ]
|
|
[ "$(dst "$F/notes.txt")" = "$PARK/Mixed/notes.txt" ]
|
|
}
|
|
|
|
@test "an audio file no row names parks with the path it has today" {
|
|
plan
|
|
[ "$(kind "$G/loose.mp3")" = "park" ]
|
|
[ "$(dst "$G/loose.mp3")" = "$PARK/Odd/loose.mp3" ]
|
|
}
|
|
|
|
@test "a video container parks instead of being renamed into audio" {
|
|
plan
|
|
[ "$(kind "$G/concert.mp4")" = "park" ]
|
|
run grep -c "concert\.mp4" "$EXC"
|
|
[ "$output" != "0" ]
|
|
}
|
|
|
|
@test "a character Plex says breaks playback never reaches a destination" {
|
|
plan
|
|
[ "$(dst "$G/Bad: Name/01 chapter.mp3")" = \
|
|
"$AB/Some Reader/Trail Marked (1999)/01 chapter.mp3" ]
|
|
run cut -f3 "$OUT"
|
|
[[ "$output" != *":"* ]]
|
|
}
|
|
|
|
@test "one book title with two narrators stays two books" {
|
|
plan
|
|
[ "$(dst "$C/Dune side a.m4b")" = "$AB/Scott Brick/Dune (1965)/Dune side a.m4b" ]
|
|
[ "$(dst "$C/Dune 2020.m4b")" = \
|
|
"$AB/Malcolm Hillgartner/Dune (2020)/Dune 2020.m4b" ]
|
|
}
|
|
|
|
@test "the spelling most chapters carry names the folder, and they share one folder" {
|
|
plan
|
|
[ "$(dst "$E/kharn 1.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 1.mp3" ]
|
|
[ "$(dst "$E/kharn 2.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 2.mp3" ]
|
|
[ "$(dst "$E/kharn 3.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 3.mp3" ]
|
|
}
|
|
|
|
@test "a row with no book title parks rather than inventing a folder" {
|
|
plan
|
|
[ "$(kind "$G/blank book.mp3")" = "park" ]
|
|
}
|
|
|
|
@test "a row with no narrator parks rather than inventing a shelf" {
|
|
plan
|
|
[ "$(kind "$G/blank narrator.mp3")" = "park" ]
|
|
}
|
|
|
|
@test "books that share a placeholder album name never share a shelf" {
|
|
plan
|
|
[ "$(dst "$H/Stross/Ship/c1.mp3")" = "$PARK/Packs/Stross/Ship/c1.mp3" ]
|
|
[ "$(dst "$H/Leckie/State/c1.mp3")" = "$PARK/Packs/Leckie/State/c1.mp3" ]
|
|
}
|
|
|
|
@test "a placeholder with a rip timestamp glued to it is still a placeholder" {
|
|
plan
|
|
[ "$(kind "$H/Rip/tape.mp3")" = "park" ]
|
|
}
|
|
|
|
@test "a narrator placeholder parks even when the book is named" {
|
|
plan
|
|
[ "$(kind "$H/NoWho/Real Book.mp3")" = "park" ]
|
|
}
|
|
|
|
@test "Various Artists is a multi-cast narrator and files its book" {
|
|
plan
|
|
[ "$(dst "$H/Multicast/Genesis.mp3")" = \
|
|
"$AB/Various Artists/The Bible/Genesis.mp3" ]
|
|
}
|
|
|
|
@test "the row, not the extension, decides what a chapter is" {
|
|
plan
|
|
[ "$(kind "$G/NoExt/part01")" = "chapter" ]
|
|
[ "$(dst "$G/NoExt/part01")" = "$AB/Archivist/Tapes (1971)/part01" ]
|
|
}
|
|
|
|
@test "the plan replays through the executor with nothing refused" {
|
|
plan
|
|
run python3 "$APPLY" apply --plan "$OUT" --root "$AB" \
|
|
--journal "$BATS_TEST_TMPDIR/journal.jsonl"
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" != *"refusing"* ]]
|
|
# The refusal this guards is two sources claiming one target: the executor
|
|
# stops the whole run on it, so the plan must never hand a path out twice.
|
|
[ -z "$(awk -F '\t' '$1 == "move" {print $3}' "$OUT" | sort | uniq -d)" ]
|
|
}
|
|
|
|
@test "an occupied destination is refused rather than overwritten" {
|
|
plan
|
|
mkdir -p "$AB/George Guidall/It (1986)"
|
|
at "$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" 1
|
|
run python3 "$APPLY" apply --plan "$OUT" --root "$AB"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"occupied"* ]]
|
|
[ -e "$A [Part 1]/01 - Chapter 1.mp3" ]
|
|
}
|
|
|
|
@test "applying the plan and re-planning moves nothing" {
|
|
plan
|
|
run python3 "$APPLY" apply --plan "$OUT" --root "$AB" \
|
|
--journal "$BATS_TEST_TMPDIR/j.jsonl" --apply
|
|
[ "$status" -eq 0 ]
|
|
python3 - "$AB" "$ENUM" <<'PY'
|
|
import os, sys
|
|
root, out = sys.argv[1], sys.argv[2]
|
|
with open(out, "w") as fh:
|
|
for dirpath, _dirs, files in os.walk(root):
|
|
for fn in files:
|
|
p = os.path.join(dirpath, fn)
|
|
fh.write(f"{os.path.getsize(p)}\t{os.path.splitext(fn)[1].lower()}\t{p}\n")
|
|
PY
|
|
# The rows still name the paths the chapters were placed at: that is what a
|
|
# server re-scan of the moved tree reports.
|
|
python3 - "$OUT" "$ROWS" <<'PY'
|
|
import csv, sys
|
|
out, rows = sys.argv[1], sys.argv[2]
|
|
moved = {r[1]: r[2] for r in csv.reader(open(out), delimiter="\t") if r[0] == "move"}
|
|
with open(rows) as fh, open(rows + ".2", "w") as fh2:
|
|
fh2.write(fh.readline().rstrip("\n") + "\n")
|
|
for line in fh:
|
|
f = line.split("\t")[0]
|
|
fh2.write("\t".join([moved.get(f, f)] + line.rstrip("\n").split("\t")[1:]) + "\n")
|
|
PY
|
|
run python3 "$PLAN" --root "$AB" --enum "$ENUM" --rows "$ROWS.2" \
|
|
--out "$OUT.2" --exceptions "$EXC.2"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(awk -F '\t' '$1 == "move"' "$OUT.2" | wc -l)" -eq 0 ]
|
|
}
|
|
|
|
@test "nothing under the park root is planned again" {
|
|
plan
|
|
run awk -F '\t' '$1 == "move" && $2 ~ /_unsorted/' "$OUT"
|
|
[ -z "$output" ]
|
|
}
|